From f2b5fcd2be52bf49b679a16e8b232b64055fddbe Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Tue, 8 Sep 2026 21:49:09 +0200 Subject: [PATCH 1/9] feat: add Combined data table state/action/reducer foundation [DHIS2-20543] Ports the state layer for the Combined view (cross-layer data table joins) from feat/datatable-pr7-multilayer: combinedView on state.dataTable, cross-layer selection on state.selection, combinedVisibleIds on state.ui, and per-layer combinedLayerKey/ combinedJoinConfig/combinedColumnConfig on state.map.mapViews, plus their action types/creators. Also fixes removeLayer to forward combinedLayerKey so the new LAYER_REMOVE cleanup in reducers/map.js can actually match. The map reducer's DATA_TABLE_COMBINED_VIEW_TOGGLE case (which creates the backing reference org-unit layer) is intentionally deferred to the next commit, since it depends on getDefaultReferenceRows, which in turn depends on the join utilities ported in that step. Co-Authored-By: Claude Sonnet 5 --- src/actions/__tests__/dataTable.spec.js | 44 +++++++ src/actions/__tests__/layers.spec.js | 8 ++ src/actions/dataTable.js | 21 ++++ src/actions/layers.js | 3 +- src/actions/selection.js | 5 + .../layers/overlays/OverlayCard.jsx | 2 +- src/constants/actionTypes.js | 6 + src/constants/layers.js | 1 + src/reducers/__tests__/dataTable.spec.js | 78 +++++++++++- src/reducers/__tests__/map.spec.js | 117 ++++++++++++++++++ src/reducers/__tests__/selection.spec.js | 68 ++++++++++ src/reducers/dataTable.js | 10 ++ src/reducers/map.js | 50 +++++++- src/reducers/selection.js | 32 ++++- src/reducers/ui.js | 9 ++ 15 files changed, 446 insertions(+), 8 deletions(-) diff --git a/src/actions/__tests__/dataTable.spec.js b/src/actions/__tests__/dataTable.spec.js index b4c12b8bda..5df0148adc 100644 --- a/src/actions/__tests__/dataTable.spec.js +++ b/src/actions/__tests__/dataTable.spec.js @@ -11,6 +11,10 @@ import { setHighlightColor, setDataTableColumnConfig, setActiveTimelinePeriod, + toggleCombinedView, + setJoinConfig, + setCombinedColumnConfig, + setCombinedVisibleIds, } from '../dataTable.js' describe('closeDataTable', () => { @@ -115,3 +119,43 @@ describe('setActiveTimelinePeriod', () => { }) }) }) + +describe('toggleCombinedView', () => { + it('creates a DATA_TABLE_COMBINED_VIEW_TOGGLE action', () => { + expect(toggleCombinedView()).toEqual({ + type: types.DATA_TABLE_COMBINED_VIEW_TOGGLE, + }) + }) +}) + +describe('setJoinConfig', () => { + it('creates a DATA_TABLE_JOIN_CONFIG_SET action', () => { + const layers = { layer1: { type: 'orgUnit', aggregation: {} } } + expect(setJoinConfig('ref1', layers)).toEqual({ + type: types.DATA_TABLE_JOIN_CONFIG_SET, + layerId: 'ref1', + layers, + }) + }) +}) + +describe('setCombinedColumnConfig', () => { + it('creates a DATA_TABLE_COMBINED_COLUMN_CONFIG_SET action', () => { + const config = { pinnedKeys: ['name'] } + expect(setCombinedColumnConfig('ref1', config)).toEqual({ + type: types.DATA_TABLE_COMBINED_COLUMN_CONFIG_SET, + layerId: 'ref1', + config, + }) + }) +}) + +describe('setCombinedVisibleIds', () => { + it('creates a COMBINED_VISIBLE_IDS_SET action', () => { + const idsByLayer = { layer1: ['a', 'b'] } + expect(setCombinedVisibleIds(idsByLayer)).toEqual({ + type: types.COMBINED_VISIBLE_IDS_SET, + idsByLayer, + }) + }) +}) diff --git a/src/actions/__tests__/layers.spec.js b/src/actions/__tests__/layers.spec.js index 57b69a6f38..3f8aa205a2 100644 --- a/src/actions/__tests__/layers.spec.js +++ b/src/actions/__tests__/layers.spec.js @@ -31,6 +31,14 @@ describe('removeLayer', () => { id: 'layer1', }) }) + + it('includes combinedLayerKey when provided', () => { + expect(removeLayer('layer1', 'layer1Key')).toEqual({ + type: types.LAYER_REMOVE, + id: 'layer1', + combinedLayerKey: 'layer1Key', + }) + }) }) describe('duplicateLayer', () => { diff --git a/src/actions/dataTable.js b/src/actions/dataTable.js index 94375753aa..c7ff73b4d2 100644 --- a/src/actions/dataTable.js +++ b/src/actions/dataTable.js @@ -52,3 +52,24 @@ export const setActiveTimelinePeriod = (period) => ({ type: types.ACTIVE_TIMELINE_PERIOD_SET, period, }) + +export const toggleCombinedView = () => ({ + type: types.DATA_TABLE_COMBINED_VIEW_TOGGLE, +}) + +export const setJoinConfig = (layerId, layers) => ({ + type: types.DATA_TABLE_JOIN_CONFIG_SET, + layerId, + layers, +}) + +export const setCombinedColumnConfig = (layerId, config) => ({ + type: types.DATA_TABLE_COMBINED_COLUMN_CONFIG_SET, + layerId, + config, +}) + +export const setCombinedVisibleIds = (idsByLayer) => ({ + type: types.COMBINED_VISIBLE_IDS_SET, + idsByLayer, +}) diff --git a/src/actions/layers.js b/src/actions/layers.js index 4583af12f7..c1b31ebbfe 100644 --- a/src/actions/layers.js +++ b/src/actions/layers.js @@ -7,9 +7,10 @@ export const addLayer = (config) => ({ }) // Remove an overlay -export const removeLayer = (id) => ({ +export const removeLayer = (id, combinedLayerKey) => ({ type: types.LAYER_REMOVE, id, + combinedLayerKey, }) // Duplicate an overlay diff --git a/src/actions/selection.js b/src/actions/selection.js index 6b5795c46e..aa0ffe1fd6 100644 --- a/src/actions/selection.js +++ b/src/actions/selection.js @@ -18,6 +18,11 @@ export const selectFeatureRange = (ids, layerId) => ({ layerId, }) +export const setCrossLayerSelection = (crossLayerIds) => ({ + type: types.SELECTION_SET_CROSS_LAYER, + crossLayerIds, +}) + export const clearSelection = () => ({ type: types.SELECTION_CLEAR, }) diff --git a/src/components/layers/overlays/OverlayCard.jsx b/src/components/layers/overlays/OverlayCard.jsx index ecf981a97a..d472337760 100644 --- a/src/components/layers/overlays/OverlayCard.jsx +++ b/src/components/layers/overlays/OverlayCard.jsx @@ -136,7 +136,7 @@ const OverlayCard = ({ } onDuplicate={() => duplicateLayer(id)} onRemove={() => { - removeLayer(id) + removeLayer(id, layer.combinedLayerKey) layerRemovedAlert.show({ msg: i18n.t('{{- name}} deleted.', { name }), }) diff --git a/src/constants/actionTypes.js b/src/constants/actionTypes.js index 8387f1e066..0dc047615a 100644 --- a/src/constants/actionTypes.js +++ b/src/constants/actionTypes.js @@ -50,6 +50,11 @@ export const HIGHLIGHT_COLOR_SET = 'HIGHLIGHT_COLOR_SET' export const MAP_FEATURE_CLICKED = 'MAP_FEATURE_CLICKED' export const DATA_TABLE_COLUMN_CONFIG_SET = 'DATA_TABLE_COLUMN_CONFIG_SET' export const ACTIVE_TIMELINE_PERIOD_SET = 'ACTIVE_TIMELINE_PERIOD_SET' +export const DATA_TABLE_COMBINED_VIEW_TOGGLE = 'DATA_TABLE_COMBINED_VIEW_TOGGLE' +export const DATA_TABLE_JOIN_CONFIG_SET = 'DATA_TABLE_JOIN_CONFIG_SET' +export const DATA_TABLE_COMBINED_COLUMN_CONFIG_SET = + 'DATA_TABLE_COMBINED_COLUMN_CONFIG_SET' +export const COMBINED_VISIBLE_IDS_SET = 'COMBINED_VISIBLE_IDS_SET' /* DATA FILTER */ export const DATA_FILTER_SET = 'DATA_FILTER_SET' @@ -196,6 +201,7 @@ export const FEATURE_PROFILE_CLOSE = 'FEATURE_PROFILE_CLOSE' export const FEATURE_TOGGLE_SELECTION = 'FEATURE_TOGGLE_SELECTION' export const SELECTION_SET_ALL = 'SELECTION_SET_ALL' export const SELECTION_ADD_RANGE = 'SELECTION_ADD_RANGE' +export const SELECTION_SET_CROSS_LAYER = 'SELECTION_SET_CROSS_LAYER' export const SELECTION_CLEAR = 'SELECTION_CLEAR' /* AGGREGATIONS */ diff --git a/src/constants/layers.js b/src/constants/layers.js index f88c1e1123..d397e900f6 100644 --- a/src/constants/layers.js +++ b/src/constants/layers.js @@ -17,6 +17,7 @@ export const TRACKED_ENTITY_LAYER = 'trackedEntity' export const GEOJSON_LAYER = 'geoJson' export const GROUP_LAYER = 'group' export const GEOJSON_URL_LAYER = 'geoJsonUrl' +export const COMBINED_TABLE_REF_LAYER = 'combinedTableRef' // Non-rendered org-unit layer backing the Combined data table's join export const MAP_SERVICE_KEY_TESTS = { keyBingMapsApiKey: [ diff --git a/src/reducers/__tests__/dataTable.spec.js b/src/reducers/__tests__/dataTable.spec.js index 41412d16d0..4c61b878b3 100644 --- a/src/reducers/__tests__/dataTable.spec.js +++ b/src/reducers/__tests__/dataTable.spec.js @@ -3,6 +3,7 @@ import dataTable from '../dataTable.js' const initialState = { openIds: [], + combinedView: false, isPanelVisible: false, activeLayerId: null, } @@ -20,6 +21,7 @@ describe('dataTable reducer', () => { ])('resets fully to the initial state on %s', (type) => { const state = { openIds: ['layer1', 'layer2'], + combinedView: true, isPanelVisible: true, activeLayerId: 'layer1', } @@ -28,9 +30,10 @@ describe('dataTable reducer', () => { }) describe('DATA_TABLE_CLOSE', () => { - it('hides the panel without touching openIds or activeLayerId', () => { + it('hides the panel without touching openIds, combinedView, or activeLayerId', () => { const state = { openIds: ['layer1', 'layer2'], + combinedView: true, isPanelVisible: true, activeLayerId: 'layer1', } @@ -42,9 +45,10 @@ describe('dataTable reducer', () => { }) describe('DATA_TABLE_OPEN', () => { - it('shows the panel without touching openIds or activeLayerId', () => { + it('shows the panel without touching openIds, combinedView, or activeLayerId', () => { const state = { openIds: ['layer1'], + combinedView: false, isPanelVisible: false, activeLayerId: 'layer1', } @@ -94,6 +98,22 @@ describe('dataTable reducer', () => { expect(state.openIds).toEqual(['layer2']) }) + it('leaves combinedView untouched even when closing the last open tab', () => { + const prevState = { + ...initialState, + openIds: ['layer1'], + combinedView: true, + } + + const state = dataTable(prevState, { + type: types.DATA_TABLE_TOGGLE, + id: 'layer1', + }) + + expect(state.openIds).toEqual([]) + expect(state.combinedView).toBe(true) + }) + it('makes the panel visible when opening a tab, even from a hidden state', () => { const state = dataTable( { ...initialState, isPanelVisible: false }, @@ -129,6 +149,18 @@ describe('dataTable reducer', () => { expect(state.openIds).toEqual(['layer2']) }) + it('leaves combinedView untouched', () => { + const prevState = { ...initialState, combinedView: true } + + const state = dataTable(prevState, { + type: types.LAYER_REMOVE, + id: 'layer1', + combinedLayerKey: 'layer1Key', + }) + + expect(state.combinedView).toBe(true) + }) + it('clears activeLayerId when the removed layer was the active one', () => { const prevState = { ...initialState, @@ -160,6 +192,48 @@ describe('dataTable reducer', () => { }) }) + describe('DATA_TABLE_COMBINED_VIEW_TOGGLE', () => { + it('turns combinedView on', () => { + const state = dataTable(initialState, { + type: types.DATA_TABLE_COMBINED_VIEW_TOGGLE, + }) + + expect(state.combinedView).toBe(true) + }) + + it('turns combinedView off', () => { + const state = dataTable( + { ...initialState, combinedView: true }, + { type: types.DATA_TABLE_COMBINED_VIEW_TOGGLE } + ) + + expect(state.combinedView).toBe(false) + }) + + it('makes the panel visible when turning combinedView on, even from a hidden state', () => { + const state = dataTable( + { ...initialState, isPanelVisible: false }, + { type: types.DATA_TABLE_COMBINED_VIEW_TOGGLE } + ) + + expect(state.isPanelVisible).toBe(true) + }) + + it('does not forcibly clear panel visibility when turning combinedView off', () => { + const prevState = { + ...initialState, + combinedView: true, + isPanelVisible: true, + } + + const state = dataTable(prevState, { + type: types.DATA_TABLE_COMBINED_VIEW_TOGGLE, + }) + + expect(state.isPanelVisible).toBe(true) + }) + }) + it('returns the current state for unknown actions', () => { const state = { ...initialState, openIds: ['layer1'] } diff --git a/src/reducers/__tests__/map.spec.js b/src/reducers/__tests__/map.spec.js index 24087e96df..54976adc1f 100644 --- a/src/reducers/__tests__/map.spec.js +++ b/src/reducers/__tests__/map.spec.js @@ -1,4 +1,5 @@ import * as types from '../../constants/actionTypes.js' +import { COMBINED_TABLE_REF_LAYER } from '../../constants/layers.js' import { isValidUid } from '../../util/uid.js' import map, { defaultBasemapState } from '../map.js' @@ -269,6 +270,55 @@ describe('map reducer - LAYER_REMOVE / LAYER_DUPLICATE', () => { expect(result.mapViews).toEqual([{ id: 'layer2' }]) }) + it("prunes the removed layer's entry from the reference layer's combinedJoinConfig, keyed by combinedLayerKey not id", () => { + const state = { + ...defaultState, + mapViews: [ + { id: 'layer1', combinedLayerKey: 'layer1Key' }, + { + id: 'ref1', + layer: COMBINED_TABLE_REF_LAYER, + combinedJoinConfig: { + layer1Key: { type: 'orgUnit', aggregation: {} }, + layer2Key: { type: 'spatial', aggregation: {} }, + }, + }, + ], + } + + const result = map(state, { + type: types.LAYER_REMOVE, + id: 'layer1', + combinedLayerKey: 'layer1Key', + }) + + expect(result.mapViews[0].combinedJoinConfig).toEqual({ + layer2Key: { type: 'spatial', aggregation: {} }, + }) + }) + + it('is a no-op when the removed layer was never a join participant', () => { + const referenceLayer = { + id: 'ref1', + layer: COMBINED_TABLE_REF_LAYER, + combinedJoinConfig: { + layer2Key: { type: 'orgUnit', aggregation: {} }, + }, + } + const state = { + ...defaultState, + mapViews: [{ id: 'layer1' }, referenceLayer], + } + + const result = map(state, { + type: types.LAYER_REMOVE, + id: 'layer1', + combinedLayerKey: 'layer1Key', + }) + + expect(result.mapViews[0]).toBe(referenceLayer) + }) + it('returns state unchanged when duplicating a layer that is not found', () => { const state = { ...defaultState, mapViews: [{ id: 'layer1' }] } @@ -384,6 +434,37 @@ describe('map reducer - per-layer delegation', () => { visibleKeys: ['id'], }) }) + + it("keeps the live combinedJoinConfig/combinedColumnConfig instead of an async loader payload's stale snapshot", () => { + const state = { + ...defaultState, + mapViews: [ + { + id: 'ref1', + combinedJoinConfig: { + layer1Key: { type: 'orgUnit', aggregation: {} }, + }, + combinedColumnConfig: { pinnedKeys: ['name'] }, + }, + ], + } + + const result = map(state, { + type: types.LAYER_UPDATE, + payload: { + id: 'ref1', + combinedJoinConfig: undefined, + combinedColumnConfig: undefined, + }, + }) + + expect(result.mapViews[0].combinedJoinConfig).toEqual({ + layer1Key: { type: 'orgUnit', aggregation: {} }, + }) + expect(result.mapViews[0].combinedColumnConfig).toEqual({ + pinnedKeys: ['name'], + }) + }) }) describe('LAYER_EDIT', () => { @@ -539,6 +620,42 @@ describe('map reducer - per-layer delegation', () => { }) }) + describe('DATA_TABLE_JOIN_CONFIG_SET', () => { + it('sets combinedJoinConfig on the matching layer only', () => { + const other = { id: 'layer2' } + const state = { ...defaultState, mapViews: [{ id: 'ref1' }, other] } + + const result = map(state, { + type: types.DATA_TABLE_JOIN_CONFIG_SET, + layerId: 'ref1', + layers: { layer1Key: { type: 'orgUnit', aggregation: {} } }, + }) + + expect(result.mapViews[0].combinedJoinConfig).toEqual({ + layer1Key: { type: 'orgUnit', aggregation: {} }, + }) + expect(result.mapViews[1]).toBe(other) + }) + }) + + describe('DATA_TABLE_COMBINED_COLUMN_CONFIG_SET', () => { + it('sets combinedColumnConfig on the matching layer only', () => { + const other = { id: 'layer2' } + const state = { ...defaultState, mapViews: [{ id: 'ref1' }, other] } + + const result = map(state, { + type: types.DATA_TABLE_COMBINED_COLUMN_CONFIG_SET, + layerId: 'ref1', + config: { pinnedKeys: ['name'] }, + }) + + expect(result.mapViews[0].combinedColumnConfig).toEqual({ + pinnedKeys: ['name'], + }) + expect(result.mapViews[1]).toBe(other) + }) + }) + describe('MAP_EARTH_ENGINE_VALUE_SHOW', () => { it('sets the coordinate on the matching layer only', () => { const other = { id: 'layer2' } diff --git a/src/reducers/__tests__/selection.spec.js b/src/reducers/__tests__/selection.spec.js index 2a0b589664..ff69b2a3aa 100644 --- a/src/reducers/__tests__/selection.spec.js +++ b/src/reducers/__tests__/selection.spec.js @@ -146,6 +146,15 @@ describe('selection reducer', () => { expect(state).toBe(prevState) }) + it('keeps the selection when the combined view is toggled', () => { + const prevState = { layerId: 'layer-1', ids: ['a', 'b'] } + const state = selection(prevState, { + type: types.DATA_TABLE_COMBINED_VIEW_TOGGLE, + }) + + expect(state).toBe(prevState) + }) + it('resets to default state when the selected layer is removed', () => { const state = selection( { layerId: 'layer-1', ids: ['a', 'b'] }, @@ -165,6 +174,65 @@ describe('selection reducer', () => { expect(state).toBe(prevState) }) + it('sets a cross-layer selection, clearing any single-layer selection', () => { + const state = selection( + { layerId: 'layer-1', ids: ['a'] }, + { + type: types.SELECTION_SET_CROSS_LAYER, + crossLayerIds: { layerA: ['a1'], layerB: ['b1', 'b2'] }, + } + ) + + expect(state).toEqual({ + layerId: null, + ids: [], + crossLayerIds: { layerA: ['a1'], layerB: ['b1', 'b2'] }, + }) + }) + + it('resets to default state when setting an empty cross-layer selection', () => { + const state = selection( + { + layerId: null, + ids: [], + crossLayerIds: { layerA: ['a1'] }, + }, + { type: types.SELECTION_SET_CROSS_LAYER, crossLayerIds: {} } + ) + + expect(state).toEqual({ layerId: null, ids: [] }) + }) + + it("prunes a removed layer's entry from a cross-layer selection", () => { + const state = selection( + { + layerId: null, + ids: [], + crossLayerIds: { layerA: ['a1'], layerB: ['b1'] }, + }, + { type: types.LAYER_REMOVE, id: 'layerA' } + ) + + expect(state).toEqual({ + layerId: null, + ids: [], + crossLayerIds: { layerB: ['b1'] }, + }) + }) + + it('resets to default state when removing the last layer in a cross-layer selection', () => { + const state = selection( + { + layerId: null, + ids: [], + crossLayerIds: { layerA: ['a1'] }, + }, + { type: types.LAYER_REMOVE, id: 'layerA' } + ) + + expect(state).toEqual({ layerId: null, ids: [] }) + }) + it('ignores unrelated actions', () => { const prevState = { layerId: 'layer-1', ids: ['a'] } diff --git a/src/reducers/dataTable.js b/src/reducers/dataTable.js index e138686c3b..fb1450e5dc 100644 --- a/src/reducers/dataTable.js +++ b/src/reducers/dataTable.js @@ -2,6 +2,7 @@ import * as types from '../constants/actionTypes.js' const initialState = { openIds: [], + combinedView: false, isPanelVisible: false, activeLayerId: null, } @@ -45,6 +46,15 @@ const dataTable = (state = initialState, action) => { : state.activeLayerId, } + case types.DATA_TABLE_COMBINED_VIEW_TOGGLE: { + const combinedView = !state.combinedView + return { + ...state, + combinedView, + isPanelVisible: combinedView ? true : state.isPanelVisible, + } + } + default: return state } diff --git a/src/reducers/map.js b/src/reducers/map.js index a4167a9314..a0b37f5b08 100644 --- a/src/reducers/map.js +++ b/src/reducers/map.js @@ -94,6 +94,14 @@ const layer = (state, action) => { state.dataTableColumnConfig ?? action.payload.dataTableColumnConfig, dataFilters: state.dataFilters ?? action.payload.dataFilters, + combinedLayerKey: + state.combinedLayerKey ?? action.payload.combinedLayerKey, + combinedJoinConfig: + state.combinedJoinConfig ?? + action.payload.combinedJoinConfig, + combinedColumnConfig: + state.combinedColumnConfig ?? + action.payload.combinedColumnConfig, } case types.LAYER_CHANGE_OPACITY: @@ -195,6 +203,37 @@ const layer = (state, action) => { dataTableColumnConfig: action.config, } + case types.DATA_TABLE_JOIN_CONFIG_SET: + if (state.id !== action.layerId) { + return state + } + + return { + ...state, + combinedJoinConfig: action.layers, + } + + case types.DATA_TABLE_COMBINED_COLUMN_CONFIG_SET: + if (state.id !== action.layerId) { + return state + } + + return { + ...state, + combinedColumnConfig: action.config, + } + + case types.LAYER_REMOVE: { + if (!state.combinedJoinConfig?.[action.combinedLayerKey]) { + return state + } + + const combinedJoinConfig = { ...state.combinedJoinConfig } + delete combinedJoinConfig[action.combinedLayerKey] + + return { ...state, combinedJoinConfig } + } + case types.MAP_ALERTS_CLEAR: return { ...state, @@ -279,6 +318,8 @@ const map = (state = defaultState, action) => { ...action.payload, id: generateUid(), isVisible: action.payload.isVisible ?? true, + combinedLayerKey: + action.payload.combinedLayerKey ?? generateUid(), }, ], } @@ -286,9 +327,9 @@ const map = (state = defaultState, action) => { case types.LAYER_REMOVE: return { ...state, - mapViews: state.mapViews.filter( - (layer) => layer.id !== action.id - ), + mapViews: state.mapViews + .filter((mv) => mv.id !== action.id) + .map((mv) => layer(mv, action)), } case types.LAYER_DUPLICATE: { @@ -301,6 +342,7 @@ const map = (state = defaultState, action) => { const duplicate = { ...state.mapViews[sourceIndex], id: generateUid(), + combinedLayerKey: generateUid(), } delete duplicate.isLoading delete duplicate.coordinate @@ -337,6 +379,8 @@ const map = (state = defaultState, action) => { case types.DATA_FILTER_CLEAR: case types.DATA_FILTERS_CLEAR_ALL: case types.DATA_TABLE_COLUMN_CONFIG_SET: + case types.DATA_TABLE_JOIN_CONFIG_SET: + case types.DATA_TABLE_COMBINED_COLUMN_CONFIG_SET: case types.MAP_EARTH_ENGINE_VALUE_SHOW: return { ...state, diff --git a/src/reducers/selection.js b/src/reducers/selection.js index e8b84f0d66..6b79378580 100644 --- a/src/reducers/selection.js +++ b/src/reducers/selection.js @@ -2,6 +2,15 @@ import * as types from '../constants/actionTypes.js' const defaultState = { layerId: null, ids: [] } +const removeCrossLayerId = (crossLayerIds, layerId) => { + if (!crossLayerIds?.[layerId]) { + return crossLayerIds + } + return Object.fromEntries( + Object.entries(crossLayerIds).filter(([id]) => id !== layerId) + ) +} + const toggleFeatureSelection = (state, action) => { if (state.layerId !== action.layerId) { return { layerId: action.layerId, ids: [action.id] } @@ -26,6 +35,24 @@ const addSelectionRange = (state, action) => { } } +const setCrossLayerSelection = (action) => + Object.keys(action.crossLayerIds).length + ? { layerId: null, ids: [], crossLayerIds: action.crossLayerIds } + : defaultState + +const removeLayerFromSelection = (state, action) => { + if (state.layerId === action.id) { + return defaultState + } + const crossLayerIds = removeCrossLayerId(state.crossLayerIds, action.id) + if (crossLayerIds === state.crossLayerIds) { + return state + } + return Object.keys(crossLayerIds).length + ? { ...state, crossLayerIds } + : defaultState +} + const selection = (state = defaultState, action) => { switch (action.type) { case types.FEATURE_TOGGLE_SELECTION: @@ -37,13 +64,16 @@ const selection = (state = defaultState, action) => { case types.SELECTION_ADD_RANGE: return addSelectionRange(state, action) + case types.SELECTION_SET_CROSS_LAYER: + return setCrossLayerSelection(action) + case types.SELECTION_CLEAR: case types.MAP_NEW: case types.MAP_SET: return defaultState case types.LAYER_REMOVE: - return state.layerId === action.id ? defaultState : state + return removeLayerFromSelection(state, action) default: return state diff --git a/src/reducers/ui.js b/src/reducers/ui.js index 5789ed6e57..8662d5d669 100644 --- a/src/reducers/ui.js +++ b/src/reducers/ui.js @@ -11,6 +11,7 @@ const defaultState = { layersSorting: false, mapBounds: null, showOnlyFeaturesInView: false, + combinedVisibleIds: null, selectionFilter: [], highlightColor: null, lastClickedFeature: null, @@ -54,6 +55,7 @@ const ui = (state = defaultState, action) => { rightPanelOpen: false, selectionFilter: [], lastClickedFeature: null, + combinedVisibleIds: null, } case types.DATA_TABLE_CLOSE: @@ -61,6 +63,7 @@ const ui = (state = defaultState, action) => { return { ...state, selectionFilter: [], + combinedVisibleIds: null, } case types.DOWNLOAD_MODE_OPEN: @@ -128,6 +131,12 @@ const ui = (state = defaultState, action) => { activeTimelinePeriod: action.period, } + case types.COMBINED_VISIBLE_IDS_SET: + return { + ...state, + combinedVisibleIds: action.idsByLayer, + } + default: return state } From 815ed76abbd4861b780cebd152e641042c24ddca Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Wed, 9 Sep 2026 10:51:16 +0200 Subject: [PATCH 2/9] feat: add spatial/org-unit join utilities and the combinedTableRef layer [DHIS2-20543] Ports the join-matching utilities from feat/datatable-pr7-multilayer: spatialJoin.js (point-in-polygon matching for the spatial join type) and combinedJoinMatch.js (org unit/parent-org-unit path matching, plus join-quality helpers used by the join controls UI later). Adds getDefaultReferenceRows to util/dataTable.js, which auto-selects the best candidate layer's org units to seed the Combined view's hidden reference layer (preferring finer org unit levels, then a fixed type priority order). This unblocks reducers/map.js's DATA_TABLE_COMBINED_VIEW_TOGGLE case (added in the previous commit's state scaffolding but left unwired pending this dependency), which creates that hidden combinedTableRef mapView on first toggle. Co-Authored-By: Claude Sonnet 5 --- src/reducers/__tests__/map.spec.js | 90 +++++++- src/reducers/map.js | 25 +++ src/util/__tests__/combinedJoinMatch.spec.js | 218 +++++++++++++++++++ src/util/__tests__/dataTable.spec.js | 91 +++++++- src/util/__tests__/spatialJoin.spec.js | 114 ++++++++++ src/util/combinedJoinMatch.js | 107 +++++++++ src/util/dataTable.js | 75 ++++++- src/util/spatialJoin.js | 39 ++++ 8 files changed, 756 insertions(+), 3 deletions(-) create mode 100644 src/util/__tests__/combinedJoinMatch.spec.js create mode 100644 src/util/__tests__/spatialJoin.spec.js create mode 100644 src/util/combinedJoinMatch.js create mode 100644 src/util/spatialJoin.js diff --git a/src/reducers/__tests__/map.spec.js b/src/reducers/__tests__/map.spec.js index 54976adc1f..2c17122159 100644 --- a/src/reducers/__tests__/map.spec.js +++ b/src/reducers/__tests__/map.spec.js @@ -1,5 +1,10 @@ import * as types from '../../constants/actionTypes.js' -import { COMBINED_TABLE_REF_LAYER } from '../../constants/layers.js' +import { + COMBINED_TABLE_REF_LAYER, + THEMATIC_LAYER, + ORG_UNIT_LAYER, + EARTH_ENGINE_LAYER, +} from '../../constants/layers.js' import { isValidUid } from '../../util/uid.js' import map, { defaultBasemapState } from '../map.js' @@ -258,6 +263,89 @@ describe('map reducer - LAYER_ADD', () => { }) }) +describe('map reducer - DATA_TABLE_COMBINED_VIEW_TOGGLE', () => { + it('adds a placeholder combinedTableRef mapView when none exists yet', () => { + const state = { ...defaultState, mapViews: [{ id: 'layer1' }] } + + const result = map(state, { + type: types.DATA_TABLE_COMBINED_VIEW_TOGGLE, + }) + + expect(result.mapViews).toHaveLength(2) + const placeholder = result.mapViews[1] + expect(placeholder.layer).toBe(COMBINED_TABLE_REF_LAYER) + expect(isValidUid(placeholder.id)).toBe(true) + expect(isValidUid(placeholder.combinedLayerKey)).toBe(true) + expect(placeholder.isVisible).toBe(false) + expect(placeholder.rows).toEqual([]) + }) + + it('is a no-op once a reference layer already exists, regardless of toggle direction', () => { + const state = { + ...defaultState, + mapViews: [ + { id: 'layer1' }, + { id: 'ref1', layer: COMBINED_TABLE_REF_LAYER }, + ], + } + + const result = map(state, { + type: types.DATA_TABLE_COMBINED_VIEW_TOGGLE, + }) + + expect(result).toBe(state) + }) + + it("seeds the placeholder's rows from the first eligible layer in priority order (Thematic over Org unit)", () => { + const orgUnitRows = [ + { dimension: 'ou', items: [{ id: 'ou1', name: 'Org unit 1' }] }, + ] + const thematicRows = [ + { dimension: 'ou', items: [{ id: 'ou2', name: 'Org unit 2' }] }, + ] + const state = { + ...defaultState, + mapViews: [ + { + id: 'orgUnitLayer', + layer: ORG_UNIT_LAYER, + rows: orgUnitRows, + }, + { + id: 'thematicLayer', + layer: THEMATIC_LAYER, + rows: thematicRows, + }, + ], + } + + const result = map(state, { + type: types.DATA_TABLE_COMBINED_VIEW_TOGGLE, + }) + + expect(result.mapViews[2].rows).toBe(thematicRows) + }) + + it('skips a higher-priority layer with no org units selected and falls through to the next type', () => { + const eeRows = [ + { dimension: 'ou', items: [{ id: 'ou3', name: 'Org unit 3' }] }, + ] + const state = { + ...defaultState, + mapViews: [ + { id: 'thematicLayer', layer: THEMATIC_LAYER, rows: [] }, + { id: 'eeLayer', layer: EARTH_ENGINE_LAYER, rows: eeRows }, + ], + } + + const result = map(state, { + type: types.DATA_TABLE_COMBINED_VIEW_TOGGLE, + }) + + expect(result.mapViews[2].rows).toBe(eeRows) + }) +}) + describe('map reducer - LAYER_REMOVE / LAYER_DUPLICATE', () => { it('removes a layer by id', () => { const state = { diff --git a/src/reducers/map.js b/src/reducers/map.js index a0b37f5b08..a5a194beb5 100644 --- a/src/reducers/map.js +++ b/src/reducers/map.js @@ -1,5 +1,7 @@ import { arrayMoveImmutable } from 'array-move' import * as types from '../constants/actionTypes.js' +import { COMBINED_TABLE_REF_LAYER } from '../constants/layers.js' +import { getDefaultReferenceRows } from '../util/dataTable.js' import { generateUid } from '../util/uid.js' export const defaultBasemapState = { @@ -332,6 +334,29 @@ const map = (state = defaultState, action) => { .map((mv) => layer(mv, action)), } + case types.DATA_TABLE_COMBINED_VIEW_TOGGLE: + if ( + state.mapViews.some( + (mv) => mv.layer === COMBINED_TABLE_REF_LAYER + ) + ) { + return state + } + + return { + ...state, + mapViews: [ + ...state.mapViews, + { + layer: COMBINED_TABLE_REF_LAYER, + id: generateUid(), + combinedLayerKey: generateUid(), + isVisible: false, + rows: getDefaultReferenceRows(state.mapViews), + }, + ], + } + case types.LAYER_DUPLICATE: { const sourceIndex = state.mapViews.findIndex( (l) => l.id === action.id diff --git a/src/util/__tests__/combinedJoinMatch.spec.js b/src/util/__tests__/combinedJoinMatch.spec.js new file mode 100644 index 0000000000..e393e48eaf --- /dev/null +++ b/src/util/__tests__/combinedJoinMatch.spec.js @@ -0,0 +1,218 @@ +import { + getUnmatchedFeatureCount, + hasCombinedRollup, +} from '../combinedJoinMatch.js' + +const referenceFeature = (id, path) => ({ + properties: { id, name: id, orgUnitPath: path, level: 2 }, +}) + +const orgUnitFeature = (path) => ({ + properties: { orgUnitPath: path }, +}) + +const pointFeature = (coordinates) => ({ + type: 'Feature', + properties: {}, + geometry: { type: 'Point', coordinates }, +}) + +const polygonReferenceFeature = (id, coordinates) => ({ + type: 'Feature', + properties: { id, name: id, level: 2 }, + geometry: { type: 'Polygon', coordinates: [coordinates] }, +}) + +describe('hasCombinedRollup', () => { + test('is false when the reference layer has no org units at all', () => { + expect( + hasCombinedRollup( + { data: [orgUnitFeature('/country1/ou1')] }, + { data: [] }, + 'orgUnit' + ) + ).toBe(false) + }) + + test('org unit join: is false when every reference org unit matches at most one feature', () => { + const referenceLayer = { + data: [ + referenceFeature('ref1', '/country1/ref1'), + referenceFeature('ref2', '/country1/ref2'), + ], + } + const layer = { + data: [ + orgUnitFeature('/country1/ref1'), + orgUnitFeature('/country1/ref2'), + ], + } + expect(hasCombinedRollup(layer, referenceLayer, 'orgUnit')).toBe(false) + }) + + test('org unit join: is true when a reference org unit matches more than one feature (a rollup)', () => { + const referenceLayer = { + data: [referenceFeature('ref1', '/country1/ref1')], + } + const layer = { + data: [ + orgUnitFeature('/country1/ref1/child1'), + orgUnitFeature('/country1/ref1/child2'), + ], + } + expect(hasCombinedRollup(layer, referenceLayer, 'orgUnit')).toBe(true) + }) + + test('spatial join: is true when a reference polygon contains more than one point feature', () => { + const referenceLayer = { + data: [ + polygonReferenceFeature('ref1', [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + [0, 0], + ]), + ], + } + const layer = { + data: [pointFeature([1, 1]), pointFeature([1.5, 1.5])], + } + expect(hasCombinedRollup(layer, referenceLayer, 'spatial')).toBe(true) + }) + + test('spatial join: is false when each reference polygon contains at most one point feature', () => { + const referenceLayer = { + data: [ + polygonReferenceFeature('ref1', [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + [0, 0], + ]), + polygonReferenceFeature('ref2', [ + [10, 10], + [12, 10], + [12, 12], + [10, 12], + [10, 10], + ]), + ], + } + const layer = { + data: [pointFeature([1, 1]), pointFeature([11, 11])], + } + expect(hasCombinedRollup(layer, referenceLayer, 'spatial')).toBe(false) + }) +}) + +describe('getUnmatchedFeatureCount', () => { + test('is 0 when the reference layer has no org units at all', () => { + expect( + getUnmatchedFeatureCount( + { data: [orgUnitFeature('/country1/ou1')] }, + { data: [] }, + 'orgUnit' + ) + ).toBe(0) + }) + + test('org unit join: is 0 when every feature matches a reference org unit', () => { + const referenceLayer = { + data: [referenceFeature('ref1', '/country1/ref1')], + } + const layer = { + data: [orgUnitFeature('/country1/ref1/child1')], + } + expect(getUnmatchedFeatureCount(layer, referenceLayer, 'orgUnit')).toBe( + 0 + ) + }) + + test('org unit join: counts features with no matching reference ancestor (wrong branch or higher level)', () => { + const referenceLayer = { + data: [referenceFeature('ref1', '/country1/ref1')], + } + const layer = { + data: [ + orgUnitFeature('/country1/ref1/child1'), + orgUnitFeature('/country2/other/child2'), + orgUnitFeature('/country1'), + ], + } + expect(getUnmatchedFeatureCount(layer, referenceLayer, 'orgUnit')).toBe( + 2 + ) + }) + + test('spatial join: counts points that fall outside every reference polygon', () => { + const referenceLayer = { + data: [ + polygonReferenceFeature('ref1', [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + [0, 0], + ]), + ], + } + const layer = { + data: [pointFeature([1, 1]), pointFeature([10, 10])], + } + expect(getUnmatchedFeatureCount(layer, referenceLayer, 'spatial')).toBe( + 1 + ) + }) +}) + +describe('layer.dataFilters applied before hasCombinedRollup/getUnmatchedFeatureCount', () => { + const referenceLayer = { + data: [referenceFeature('ref1', '/country1/ref1')], + } + + test('hasCombinedRollup no longer sees a rollup once dataFilters excludes the second feature', () => { + const layer = { + data: [ + { + properties: { + orgUnitPath: '/country1/ref1/child1', + status: 'open', + }, + }, + { + properties: { + orgUnitPath: '/country1/ref1/child2', + status: 'closed', + }, + }, + ], + dataFilters: { status: 'open' }, + } + expect(hasCombinedRollup(layer, referenceLayer, 'orgUnit')).toBe(false) + }) + + test('getUnmatchedFeatureCount excludes a feature already removed by dataFilters, rather than counting it as unmatched', () => { + const layer = { + data: [ + { + properties: { + orgUnitPath: '/country1/ref1/child1', + status: 'open', + }, + }, + { + properties: { + orgUnitPath: '/country2/other', + status: 'closed', + }, + }, + ], + dataFilters: { status: 'open' }, + } + expect(getUnmatchedFeatureCount(layer, referenceLayer, 'orgUnit')).toBe( + 0 + ) + }) +}) diff --git a/src/util/__tests__/dataTable.spec.js b/src/util/__tests__/dataTable.spec.js index 8459978a7b..9e5fbf0f31 100644 --- a/src/util/__tests__/dataTable.spec.js +++ b/src/util/__tests__/dataTable.spec.js @@ -1,6 +1,15 @@ -import { EXTERNAL_LAYER, THEMATIC_LAYER } from '../../constants/layers.js' +import { + EXTERNAL_LAYER, + THEMATIC_LAYER, + ORG_UNIT_LAYER, + EARTH_ENGINE_LAYER, + FACILITY_LAYER, + EVENT_LAYER, + TRACKED_ENTITY_LAYER, +} from '../../constants/layers.js' import { buildFeatureIndex, + getDefaultReferenceRows, getEligibleDataTableLayers, getLayerSelectedIds, getNextSorting, @@ -361,3 +370,83 @@ describe('getPanelHeights', () => { expect(result.displayHeight).toBe(32) }) }) + +const withOrgUnitRows = (layer, id) => ({ + layer, + rows: [{ dimension: 'ou', items: [{ id, name: id }] }], +}) + +describe('getDefaultReferenceRows', () => { + test('returns an empty array when no map view has an org unit selection', () => { + expect( + getDefaultReferenceRows([{ layer: THEMATIC_LAYER, rows: [] }]) + ).toEqual([]) + }) + + test('prefers a Thematic layer over every other type', () => { + const thematic = withOrgUnitRows(THEMATIC_LAYER, 'thematicOu') + expect( + getDefaultReferenceRows([ + withOrgUnitRows(TRACKED_ENTITY_LAYER, 'teOu'), + withOrgUnitRows(ORG_UNIT_LAYER, 'orgUnitOu'), + thematic, + withOrgUnitRows(EARTH_ENGINE_LAYER, 'eeOu'), + ]) + ).toBe(thematic.rows) + }) + + test('falls through to the next type in priority order when a higher-priority layer has no org units selected', () => { + const facility = withOrgUnitRows(FACILITY_LAYER, 'facilityOu') + expect( + getDefaultReferenceRows([ + { layer: THEMATIC_LAYER, rows: [] }, + { layer: ORG_UNIT_LAYER, rows: [] }, + { layer: EARTH_ENGINE_LAYER, rows: [] }, + facility, + withOrgUnitRows(EVENT_LAYER, 'eventOu'), + ]) + ).toBe(facility.rows) + }) + + test('defaults to an empty array when no map views are given', () => { + expect(getDefaultReferenceRows()).toEqual([]) + }) + + const withLevel = (layer, id, level) => ({ + ...withOrgUnitRows(layer, id), + data: [{ properties: { id, level } }], + }) + + test('prefers the coarser (lower) org unit level over the type priority order', () => { + const facility = withLevel(FACILITY_LAYER, 'facilityOu', 1) + const thematic = withLevel(THEMATIC_LAYER, 'thematicOu', 3) + expect(getDefaultReferenceRows([thematic, facility])).toBe( + facility.rows + ) + }) + + test('breaks a level tie using the type priority order', () => { + const orgUnit = withLevel(ORG_UNIT_LAYER, 'orgUnitOu', 2) + const thematic = withLevel(THEMATIC_LAYER, 'thematicOu', 2) + expect(getDefaultReferenceRows([orgUnit, thematic])).toBe(thematic.rows) + }) + + test('prefers the coarser of two layers of the same type', () => { + const fineThematic = withLevel(THEMATIC_LAYER, 'fine', 3) + const coarseThematic = withLevel(THEMATIC_LAYER, 'coarse', 1) + expect(getDefaultReferenceRows([fineThematic, coarseThematic])).toBe( + coarseThematic.rows + ) + }) + + test('falls back to type priority when a candidate has no loaded data to compare a level from yet', () => { + const thematicNotYetLoaded = withOrgUnitRows( + THEMATIC_LAYER, + 'thematicOu' + ) + const facility = withLevel(FACILITY_LAYER, 'facilityOu', 1) + expect(getDefaultReferenceRows([facility, thematicNotYetLoaded])).toBe( + thematicNotYetLoaded.rows + ) + }) +}) diff --git a/src/util/__tests__/spatialJoin.spec.js b/src/util/__tests__/spatialJoin.spec.js new file mode 100644 index 0000000000..470c2edc2d --- /dev/null +++ b/src/util/__tests__/spatialJoin.spec.js @@ -0,0 +1,114 @@ +import { matchFeaturesToReferenceOrgUnits } from '../spatialJoin.js' + +const square = (id, [minX, minY, maxX, maxY]) => ({ + id, + type: 'Feature', + properties: { id, name: `Polygon ${id}` }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [minX, minY], + [maxX, minY], + [maxX, maxY], + [minX, maxY], + [minX, minY], + ], + ], + }, +}) + +const point = (id, [x, y]) => ({ + id, + type: 'Feature', + properties: { id, name: `Point ${id}` }, + geometry: { type: 'Point', coordinates: [x, y] }, +}) + +describe('matchFeaturesToReferenceOrgUnits', () => { + test('matches a point to the reference org unit whose polygon contains it', () => { + const features = [point('p1', [1, 1])] + const referenceOrgUnits = [square('a', [0, 0, 2, 2])] + + expect( + matchFeaturesToReferenceOrgUnits(features, referenceOrgUnits) + ).toEqual([ + { featureProps: { id: 'p1', name: 'Point p1' }, referenceId: 'a' }, + ]) + }) + + test('leaves referenceId null for a point outside every reference polygon', () => { + const features = [point('p1', [10, 10])] + const referenceOrgUnits = [square('a', [0, 0, 2, 2])] + + expect( + matchFeaturesToReferenceOrgUnits(features, referenceOrgUnits) + ).toEqual([ + { featureProps: { id: 'p1', name: 'Point p1' }, referenceId: null }, + ]) + }) + + test('matches each feature independently against multiple reference org units', () => { + const features = [point('p1', [1, 1]), point('p2', [11, 11])] + const referenceOrgUnits = [ + square('a', [0, 0, 2, 2]), + square('b', [10, 10, 12, 12]), + ] + + expect( + matchFeaturesToReferenceOrgUnits(features, referenceOrgUnits) + ).toEqual([ + { featureProps: { id: 'p1', name: 'Point p1' }, referenceId: 'a' }, + { featureProps: { id: 'p2', name: 'Point p2' }, referenceId: 'b' }, + ]) + }) + + test('ignores non-polygon features among the reference org units', () => { + const features = [point('p1', [1, 1])] + const referenceOrgUnits = [ + point('notAPolygon', [1, 1]), + square('a', [0, 0, 2, 2]), + ] + + expect( + matchFeaturesToReferenceOrgUnits(features, referenceOrgUnits)[0] + .referenceId + ).toBe('a') + }) + + test('leaves a non-point feature untestable (referenceId null) without useCentroid', () => { + const features = [square('poly1', [0.5, 0.5, 1.5, 1.5])] + const referenceOrgUnits = [square('a', [0, 0, 2, 2])] + + expect( + matchFeaturesToReferenceOrgUnits(features, referenceOrgUnits) + ).toEqual([ + { + featureProps: { id: 'poly1', name: 'Polygon poly1' }, + referenceId: null, + }, + ]) + }) + + test('matches a non-point feature via its centroid when useCentroid is set', () => { + const features = [square('poly1', [0.5, 0.5, 1.5, 1.5])] + const referenceOrgUnits = [square('a', [0, 0, 2, 2])] + + expect( + matchFeaturesToReferenceOrgUnits(features, referenceOrgUnits, { + useCentroid: true, + }) + ).toEqual([ + { + featureProps: { id: 'poly1', name: 'Polygon poly1' }, + referenceId: 'a', + }, + ]) + }) + + test('returns an empty array when there are no features', () => { + expect( + matchFeaturesToReferenceOrgUnits([], [square('a', [0, 0, 2, 2])]) + ).toEqual([]) + }) +}) diff --git a/src/util/combinedJoinMatch.js b/src/util/combinedJoinMatch.js new file mode 100644 index 0000000000..312d2a8a4d --- /dev/null +++ b/src/util/combinedJoinMatch.js @@ -0,0 +1,107 @@ +import { ORG_UNIT_PATH_DATA_KEY } from '../constants/dataTable.js' +import { filterData } from './filter.js' +import { matchFeaturesToReferenceOrgUnits } from './spatialJoin.js' + +export const getJoinableFeatures = (layer) => + [...(layer?.data ?? []), ...(layer?.dataWithoutCoords ?? [])].filter( + (d) => !d.properties?.hasAdditionalGeometry + ) + +export const getFilteredJoinableFeatures = (layer) => + filterData(getJoinableFeatures(layer), layer?.dataFilters) + +export const getProps = (feature) => feature.properties || feature + +export const matchOrgUnitReference = ( + features, + referenceOrgUnits, + referenceByPath +) => { + const byReferenceId = new Map() + features.forEach((feature) => { + const props = getProps(feature) + const path = props[ORG_UNIT_PATH_DATA_KEY] + if (!path) { + return + } + const reference = + referenceByPath.get(path) ?? + referenceOrgUnits.find((ref) => + path.startsWith(`${getProps(ref)[ORG_UNIT_PATH_DATA_KEY]}/`) + ) + if (!reference) { + return + } + const referenceId = getProps(reference).id + if (!byReferenceId.has(referenceId)) { + byReferenceId.set(referenceId, []) + } + byReferenceId.get(referenceId).push(props) + }) + return byReferenceId +} + +export const matchSpatialReference = (features, referenceOrgUnits) => { + const byReferenceId = new Map() + const matched = matchFeaturesToReferenceOrgUnits( + features, + referenceOrgUnits, + { useCentroid: true } + ) + matched.forEach(({ featureProps, referenceId }) => { + if (referenceId == null) { + return + } + if (!byReferenceId.has(referenceId)) { + byReferenceId.set(referenceId, []) + } + byReferenceId.get(referenceId).push(featureProps) + }) + return byReferenceId +} + +export const getByReferenceId = (features, referenceOrgUnits, joinType) => { + if (joinType === 'spatial') { + return matchSpatialReference(features, referenceOrgUnits) + } + const referenceByPath = new Map( + referenceOrgUnits.map((ref) => [ + getProps(ref)[ORG_UNIT_PATH_DATA_KEY], + ref, + ]) + ) + return matchOrgUnitReference(features, referenceOrgUnits, referenceByPath) +} + +export const hasCombinedRollup = (layer, referenceLayer, joinType) => { + const referenceOrgUnits = getJoinableFeatures(referenceLayer) + if (!referenceOrgUnits.length) { + return false + } + const byReferenceId = getByReferenceId( + getFilteredJoinableFeatures(layer), + referenceOrgUnits, + joinType + ) + return Array.from(byReferenceId.values()).some( + (matches) => matches.length > 1 + ) +} + +export const getUnmatchedFeatureCount = (layer, referenceLayer, joinType) => { + const referenceOrgUnits = getJoinableFeatures(referenceLayer) + const features = getFilteredJoinableFeatures(layer) + if (!referenceOrgUnits.length || !features.length) { + return 0 + } + const byReferenceId = getByReferenceId( + features, + referenceOrgUnits, + joinType + ) + const matched = Array.from(byReferenceId.values()).reduce( + (sum, matches) => sum + matches.length, + 0 + ) + return features.length - matched +} diff --git a/src/util/dataTable.js b/src/util/dataTable.js index b6cf507a09..e55b52bcaa 100644 --- a/src/util/dataTable.js +++ b/src/util/dataTable.js @@ -1,5 +1,15 @@ import { SORT_ASCENDING, SORT_DESCENDING } from '../constants/dataTable.js' -import { DATA_TABLE_LAYER_TYPES } from '../constants/layers.js' +import { + DATA_TABLE_LAYER_TYPES, + THEMATIC_LAYER, + ORG_UNIT_LAYER, + EARTH_ENGINE_LAYER, + FACILITY_LAYER, + EVENT_LAYER, + TRACKED_ENTITY_LAYER, +} from '../constants/layers.js' +import { getOrgUnitsFromRows } from './analytics.js' +import { getJoinableFeatures } from './combinedJoinMatch.js' export const isFilterable = (dataKey, type) => !!type @@ -81,6 +91,69 @@ export const buildFeatureIndex = (data) => { return index } +const REFERENCE_ROWS_LEVEL_COMPARABLE_TYPES = [ + THEMATIC_LAYER, + ORG_UNIT_LAYER, + EARTH_ENGINE_LAYER, + FACILITY_LAYER, +] + +const REFERENCE_ROWS_FALLBACK_TYPES = [EVENT_LAYER, TRACKED_ENTITY_LAYER] + +const getMinFeatureLevel = (mapView) => { + const levels = getJoinableFeatures(mapView) + .map((f) => (f.properties ?? f).level) + .filter((level) => typeof level === 'number') + return levels.length ? Math.min(...levels) : null +} + +const isBetterReferenceCandidate = (a, b) => { + if (a.level !== null && b.level !== null && a.level !== b.level) { + return a.level < b.level + } + if (a.priority !== b.priority) { + return a.priority < b.priority + } + return a.index < b.index +} + +export const getDefaultReferenceRows = (mapViews = []) => { + const candidates = mapViews + .map((mapView, index) => ({ mapView, index })) + .filter( + ({ mapView }) => + REFERENCE_ROWS_LEVEL_COMPARABLE_TYPES.includes(mapView.layer) && + getOrgUnitsFromRows(mapView.rows).length + ) + .map(({ mapView, index }) => ({ + mapView, + index, + priority: REFERENCE_ROWS_LEVEL_COMPARABLE_TYPES.indexOf( + mapView.layer + ), + level: getMinFeatureLevel(mapView), + })) + + if (candidates.length) { + return candidates.reduce( + (best, candidate) => + isBetterReferenceCandidate(candidate, best) ? candidate : best, + candidates[0] + ).mapView.rows + } + + for (const layerType of REFERENCE_ROWS_FALLBACK_TYPES) { + const layer = mapViews.find( + (mv) => + mv.layer === layerType && getOrgUnitsFromRows(mv.rows).length + ) + if (layer) { + return layer.rows + } + } + return [] +} + export const getPanelHeights = ({ windowHeight, dataTableHeight, diff --git a/src/util/spatialJoin.js b/src/util/spatialJoin.js new file mode 100644 index 0000000000..def0baa60d --- /dev/null +++ b/src/util/spatialJoin.js @@ -0,0 +1,39 @@ +import { booleanPointInPolygon } from '@turf/boolean-point-in-polygon' +import turfCentroid from '@turf/centroid' +import { + GEO_TYPE_POINT, + GEO_TYPE_POLYGON, + GEO_TYPE_MULTIPOLYGON, +} from './geojson.js' + +const isPolygon = (geometry) => + [GEO_TYPE_POLYGON, GEO_TYPE_MULTIPOLYGON].includes(geometry?.type) + +const getTestPoint = (feature, useCentroid) => { + if (feature.geometry?.type === GEO_TYPE_POINT) { + return feature + } + return useCentroid && feature.geometry + ? turfCentroid(feature.geometry) + : null +} + +export const matchFeaturesToReferenceOrgUnits = ( + features, + referenceOrgUnits, + { useCentroid = false } = {} +) => { + const polygons = referenceOrgUnits.filter((ref) => isPolygon(ref.geometry)) + + return features.map((feature) => { + const testPoint = getTestPoint(feature, useCentroid) + const matched = testPoint + ? polygons.find((ref) => booleanPointInPolygon(testPoint, ref)) + : null + + return { + featureProps: feature.properties || feature, + referenceId: matched ? (matched.properties || matched).id : null, + } + }) +} From 0e42f3ced965e74e0fe3637073eab3bc261dd8d1 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Wed, 9 Sep 2026 16:24:23 +0200 Subject: [PATCH 3/9] feat: wire the combinedTableRef layer type end to end [DHIS2-20543] Ports the reference org-unit layer editing/loading/exclusion pieces from feat/datatable-pr7-multilayer: OrgUnitDialog.jsx/LayerEdit.jsx reuse the normal org-unit edit dialog for it (hiding the Style tab, swapping the modal title/submit label), a new ReferenceOrgUnitControl opens its editor, orgUnitLoader.js special-cases loading it (always counting org units without coordinates, suppressing the "no coordinates" warning, persisting combinedJoinConfig/ combinedColumnConfig/combinedLayerKey), and MapContainer.jsx/ plugin/Map.jsx/LayersPanel.jsx exclude the hidden layer from actual map rendering and the layers panel list. Also fixes two gaps found by review that a straight file-by-file port missed: - useLayersLoader.js had no loaders['combinedTableRef'] entry, so the reference layer would never actually load, permanently blocking the Interpretations panel and surfacing a "Could not load layer" alert. - RenderingStrategy.jsx iterates mapViews to gate the Split rendering strategy and wasn't excluding the reference layer either, unlike every other mapViews-iterating call site touched in this commit - this one doesn't exist on the source branch, so it's a fix beyond the straight port. Co-Authored-By: Claude Sonnet 5 --- .../controls/ReferenceOrgUnitControl.jsx | 51 +++++++ .../ReferenceOrgUnitControl.spec.jsx | 46 ++++++ src/components/edit/LayerEdit.jsx | 30 +++- .../edit/__tests__/LayerEdit.spec.jsx | 103 +++++++++++++ src/components/edit/orgUnit/OrgUnitDialog.jsx | 6 +- .../orgUnit/__tests__/OrgUnitDialog.spec.jsx | 57 ++++++++ src/components/layers/LayersPanel.jsx | 20 ++- .../layers/__tests__/LayersPanel.spec.jsx | 86 +++++++++++ src/components/map/MapContainer.jsx | 27 +++- src/components/periods/RenderingStrategy.jsx | 9 +- .../__tests__/RenderingStrategy.spec.jsx | 37 +++++ src/components/plugin/Map.jsx | 22 ++- src/hooks/useLayersLoader.js | 1 + src/loaders/__tests__/orgUnitLoader.spec.js | 138 +++++++++++++++++- src/loaders/orgUnitLoader.js | 23 ++- 15 files changed, 626 insertions(+), 30 deletions(-) create mode 100644 src/components/datatable/controls/ReferenceOrgUnitControl.jsx create mode 100644 src/components/datatable/controls/__tests__/ReferenceOrgUnitControl.spec.jsx create mode 100644 src/components/edit/__tests__/LayerEdit.spec.jsx create mode 100644 src/components/edit/orgUnit/__tests__/OrgUnitDialog.spec.jsx create mode 100644 src/components/layers/__tests__/LayersPanel.spec.jsx create mode 100644 src/components/periods/__tests__/RenderingStrategy.spec.jsx diff --git a/src/components/datatable/controls/ReferenceOrgUnitControl.jsx b/src/components/datatable/controls/ReferenceOrgUnitControl.jsx new file mode 100644 index 0000000000..c9e96d02b4 --- /dev/null +++ b/src/components/datatable/controls/ReferenceOrgUnitControl.jsx @@ -0,0 +1,51 @@ +import i18n from '@dhis2/d2-i18n' +import { IconDimensionOrgUnit16 } from '@dhis2/ui' +import React from 'react' +import { useDispatch, useSelector, useStore } from 'react-redux' +import { editLayer } from '../../../actions/layers.js' +import { COMBINED_TABLE_REF_LAYER } from '../../../constants/layers.js' +import { getDefaultReferenceRows } from '../../../util/dataTable.js' +import ToolbarIconButton from './ToolbarIconButton.jsx' + +const findReferenceLayer = (mapViews) => + mapViews.find((l) => l.layer === COMBINED_TABLE_REF_LAYER) + +export const useReferenceLayer = () => { + const dispatch = useDispatch() + const store = useStore() + const referenceLayer = useSelector((state) => + findReferenceLayer(state.map.mapViews) + ) + + const openReferenceLayerEditor = () => { + const mapViews = store.getState().map.mapViews + dispatch( + editLayer( + findReferenceLayer(mapViews) ?? { + layer: COMBINED_TABLE_REF_LAYER, + isVisible: false, + rows: getDefaultReferenceRows(mapViews), + } + ) + ) + } + + return { referenceLayer, openReferenceLayerEditor } +} + +const ReferenceOrgUnitControl = () => { + const { openReferenceLayerEditor } = useReferenceLayer() + + return ( + + + + ) +} + +export default ReferenceOrgUnitControl diff --git a/src/components/datatable/controls/__tests__/ReferenceOrgUnitControl.spec.jsx b/src/components/datatable/controls/__tests__/ReferenceOrgUnitControl.spec.jsx new file mode 100644 index 0000000000..113645f1ea --- /dev/null +++ b/src/components/datatable/controls/__tests__/ReferenceOrgUnitControl.spec.jsx @@ -0,0 +1,46 @@ +import { render, fireEvent, screen } from '@testing-library/react' +import React from 'react' +import { Provider, useDispatch } from 'react-redux' +import { createStore } from 'redux' +import { toggleCombinedView } from '../../../../actions/dataTable.js' +import { COMBINED_TABLE_REF_LAYER } from '../../../../constants/layers.js' +import rootReducer from '../../../../reducers/index.js' +import { useReferenceLayer } from '../ReferenceOrgUnitControl.jsx' + +const SelectCombinedHarness = () => { + const dispatch = useDispatch() + const { openReferenceLayerEditor } = useReferenceLayer() + + return ( + + ) +} + +describe('useReferenceLayer', () => { + test('opens the editor for the placeholder reference layer just created by toggleCombinedView, not a second orphaned one', () => { + const store = createStore(rootReducer) + + render( + + + + ) + + fireEvent.click(screen.getByText('Select Combined')) + + const referenceLayers = store + .getState() + .map.mapViews.filter((l) => l.layer === COMBINED_TABLE_REF_LAYER) + + expect(referenceLayers).toHaveLength(1) + expect(referenceLayers[0].id).toBeDefined() + expect(store.getState().layerEdit.id).toBe(referenceLayers[0].id) + }) +}) diff --git a/src/components/edit/LayerEdit.jsx b/src/components/edit/LayerEdit.jsx index 046b1b81e0..171f32404e 100644 --- a/src/components/edit/LayerEdit.jsx +++ b/src/components/edit/LayerEdit.jsx @@ -11,7 +11,10 @@ import PropTypes from 'prop-types' import React, { useState } from 'react' import { connect } from 'react-redux' import { addLayer, updateLayer, cancelLayer } from '../../actions/layers.js' -import { EARTH_ENGINE_LAYER } from '../../constants/layers.js' +import { + COMBINED_TABLE_REF_LAYER, + EARTH_ENGINE_LAYER, +} from '../../constants/layers.js' import useKeyDown from '../../hooks/useKeyDown.js' import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import { useOrgUnits } from '../OrgUnitsProvider.jsx' @@ -32,6 +35,7 @@ const layerDialogs = { orgUnit: OrgUnitDialog, earthEngine: EarthEngineDialog, geoJsonUrl: GeoJsonDialog, + combinedTableRef: OrgUnitDialog, } const getLayerNames = () => ({ @@ -42,6 +46,7 @@ const getLayerNames = () => ({ orgUnit: i18n.t('org unit'), earthEngine: i18n.t('Earth Engine'), geoJsonUrl: i18n.t('feature'), + combinedTableRef: i18n.t('reference org units'), }) const LayerEdit = ({ layer, addLayer, updateLayer, cancelLayer }) => { @@ -93,10 +98,24 @@ const LayerEdit = ({ layer, addLayer, updateLayer, cancelLayer }) => { name = layer.name.toLowerCase() } - const title = layer.id + const isReferenceLayer = type === COMBINED_TABLE_REF_LAYER + + // The reference org unit layer isn't really "a layer" from the user's perspective + const editOrAddTitle = layer.id ? i18n.t('Edit {{name}} layer', { name }) : i18n.t('Add new {{name}} layer', { name }) + const title = isReferenceLayer + ? i18n.t('Configure reference org units') + : editOrAddTitle + + const addOrUpdateLabel = layer.id + ? i18n.t('Update layer') + : i18n.t('Add layer') + const submitButtonLabel = isReferenceLayer + ? i18n.t('Update reference') + : addOrUpdateLabel + return ( {title} @@ -110,6 +129,7 @@ const LayerEdit = ({ layer, addLayer, updateLayer, cancelLayer }) => { orgUnits={orgUnits} validateLayer={isValidLayer} onLayerValidation={onLayerValidation} + hideStyleTab={isReferenceLayer} /> @@ -123,11 +143,7 @@ const LayerEdit = ({ layer, addLayer, updateLayer, cancelLayer }) => { onClick={onValidateLayer} dataTest="layeredit-addbtn" > - {i18n.t( - layer.id - ? i18n.t('Update layer') - : i18n.t('Add layer') - )} + {submitButtonLabel} diff --git a/src/components/edit/__tests__/LayerEdit.spec.jsx b/src/components/edit/__tests__/LayerEdit.spec.jsx new file mode 100644 index 0000000000..352ca883a6 --- /dev/null +++ b/src/components/edit/__tests__/LayerEdit.spec.jsx @@ -0,0 +1,103 @@ +import { render, screen } from '@testing-library/react' +import PropTypes from 'prop-types' +import React from 'react' +import { Provider } from 'react-redux' +import configureMockStore from 'redux-mock-store' +import LayerEdit from '../LayerEdit.jsx' + +jest.mock('../../cachedDataProvider/CachedDataProvider.jsx', () => ({ + useCachedData: () => ({ systemSettings: {}, periodsSettings: {} }), +})) + +jest.mock('../../OrgUnitsProvider.jsx', () => ({ + useOrgUnits: () => ({}), +})) + +function mockDialog(testId) { + const Mock = ({ hideStyleTab }) => ( +
{String(!!hideStyleTab)}
+ ) + Mock.propTypes = { + hideStyleTab: PropTypes.bool, + } + Mock.displayName = testId + return Mock +} + +jest.mock('../orgUnit/OrgUnitDialog.jsx', () => + mockDialog('orgunitdialog-mock') +) +jest.mock('../event/EventDialog.jsx', () => mockDialog('eventdialog-mock')) +jest.mock('../trackedEntity/TrackedEntityDialog.jsx', () => + mockDialog('trackedentitydialog-mock') +) +jest.mock('../FacilityDialog.jsx', () => mockDialog('facilitydialog-mock')) +jest.mock('../thematic/ThematicDialog.jsx', () => + mockDialog('thematicdialog-mock') +) +jest.mock('../earthEngine/EarthEngineDialog.jsx', () => + mockDialog('earthenginedialog-mock') +) +jest.mock('../geoJson/GeoJsonDialog.jsx', () => + mockDialog('geojsondialog-mock') +) + +const mockStore = configureMockStore() + +const renderLayerEdit = (layer) => { + const store = mockStore({ layerEdit: layer }) + return render( + + + + ) +} + +describe('LayerEdit — reference org unit layer', () => { + test('shows a state-agnostic "Configure reference org units" title, with no id (new)', () => { + renderLayerEdit({ layer: 'combinedTableRef', rows: [] }) + expect( + screen.getByText('Configure reference org units') + ).toBeInTheDocument() + }) + + test('shows the same title once it has an id (already saved/editing)', () => { + renderLayerEdit({ id: 'ref1', layer: 'combinedTableRef', rows: [] }) + expect( + screen.getByText('Configure reference org units') + ).toBeInTheDocument() + }) + + test('passes hideStyleTab=true to OrgUnitDialog for the reference layer type', () => { + renderLayerEdit({ layer: 'combinedTableRef', rows: [] }) + expect(screen.getByTestId('orgunitdialog-mock')).toHaveTextContent( + 'true' + ) + }) + + test('passes hideStyleTab=false to OrgUnitDialog for a real org unit layer', () => { + renderLayerEdit({ layer: 'orgUnit', rows: [] }) + expect(screen.getByTestId('orgunitdialog-mock')).toHaveTextContent( + 'false' + ) + expect(screen.getByText('Add new org unit layer')).toBeInTheDocument() + }) + + test('shows a state-agnostic "Update reference" submit button, with no id (new)', () => { + renderLayerEdit({ layer: 'combinedTableRef', rows: [] }) + expect(screen.getByText('Update reference')).toBeInTheDocument() + }) + + test('shows the same "Update reference" submit button once it has an id (already saved/editing)', () => { + renderLayerEdit({ id: 'ref1', layer: 'combinedTableRef', rows: [] }) + expect(screen.getByText('Update reference')).toBeInTheDocument() + }) + + test('a real org unit layer still shows "Add layer"/"Update layer" depending on id', () => { + renderLayerEdit({ layer: 'orgUnit', rows: [] }) + expect(screen.getByText('Add layer')).toBeInTheDocument() + + renderLayerEdit({ id: 'ou1', layer: 'orgUnit', rows: [] }) + expect(screen.getAllByText('Update layer')[0]).toBeInTheDocument() + }) +}) diff --git a/src/components/edit/orgUnit/OrgUnitDialog.jsx b/src/components/edit/orgUnit/OrgUnitDialog.jsx index 02c2326dcc..294062aca2 100644 --- a/src/components/edit/orgUnit/OrgUnitDialog.jsx +++ b/src/components/edit/orgUnit/OrgUnitDialog.jsx @@ -40,6 +40,7 @@ const OrgUnitDialog = ({ rows, validateLayer, onLayerValidation, + hideStyleTab, }) => { const dispatch = useDispatch() const countFeaturesWithoutCoordinates = useSelector( @@ -70,13 +71,13 @@ const OrgUnitDialog = ({
{i18n.t('Organisation Units')} - {i18n.t('Style')} + {!hideStyleTab && {i18n.t('Style')}}
{tab === ORGUNITS_TAB && ( )} - {tab === 'style' && ( + {!hideStyleTab && tab === 'style' && (
{ + const OrgUnitSelectMock = () =>
+ OrgUnitSelectMock.displayName = 'OrgUnitSelectMock' + return OrgUnitSelectMock +}) + +jest.mock('../../../groupSet/StyleByGroupSet.jsx', () => { + const StyleByGroupSetMock = () =>
+ StyleByGroupSetMock.displayName = 'StyleByGroupSetMock' + return StyleByGroupSetMock +}) + +jest.mock('../../shared/Labels.jsx', () => { + const LabelsMock = () =>
+ LabelsMock.displayName = 'LabelsMock' + return LabelsMock +}) + +const mockStore = configureMockStore() + +const renderDialog = (props) => { + const store = mockStore({ layerEdit: {} }) + return render( + + + + ) +} + +describe('OrgUnitDialog — hideStyleTab', () => { + test('shows the Style tab by default (a real org unit layer)', () => { + renderDialog() + expect(screen.getByText('Style')).toBeInTheDocument() + }) + + test('omits the Style tab entirely when hideStyleTab is set (the Combined reference layer)', () => { + renderDialog({ hideStyleTab: true }) + expect(screen.queryByText('Style')).not.toBeInTheDocument() + }) + + test('still shows the Organisation Units tab and its content when hideStyleTab is set', () => { + renderDialog({ hideStyleTab: true }) + expect(screen.getByText('Organisation Units')).toBeInTheDocument() + expect(screen.getByTestId('orgunitselect-mock')).toBeInTheDocument() + }) +}) diff --git a/src/components/layers/LayersPanel.jsx b/src/components/layers/LayersPanel.jsx index 9ebbeb01d0..81e0d319f4 100644 --- a/src/components/layers/LayersPanel.jsx +++ b/src/components/layers/LayersPanel.jsx @@ -22,6 +22,7 @@ import React, { useState } from 'react' import { useSelector, useDispatch } from 'react-redux' import { sortLayers } from '../../actions/layers.js' import { layersSortingEnd, layersSortingStart } from '../../actions/ui.js' +import { COMBINED_TABLE_REF_LAYER } from '../../constants/layers.js' import BasemapCard from '../layers/basemaps/BasemapCard.jsx' import LayersToggle from '../layers/LayersToggle.jsx' import { DragHandleCtx } from './dragHandleContext.js' @@ -60,10 +61,20 @@ SortableLayer.propTypes = { layer: PropTypes.object.isRequired, } +export const getSortIndices = (reversedMapViews, activeId, overId) => ({ + oldIndex: reversedMapViews.findIndex((l) => l.id === activeId), + newIndex: reversedMapViews.findIndex((l) => l.id === overId), +}) + const LayersPanel = () => { const layersPanelOpen = useSelector((state) => state.ui.layersPanelOpen) // Reversed so the last map view (top layer) is shown first - const layers = useSelector((state) => [...state.map.mapViews].reverse()) + const reversedMapViews = useSelector((state) => + [...state.map.mapViews].reverse() + ) + const layers = reversedMapViews.filter( + (l) => l.layer !== COMBINED_TABLE_REF_LAYER + ) const dispatch = useDispatch() @@ -101,8 +112,11 @@ const LayersPanel = () => { stopSorting() if (over && active.id !== over.id) { - const oldIndex = layers.findIndex((l) => l.id === active.id) - const newIndex = layers.findIndex((l) => l.id === over.id) + const { oldIndex, newIndex } = getSortIndices( + reversedMapViews, + active.id, + over.id + ) if (oldIndex !== -1 && newIndex !== -1) { dispatch(sortLayers({ oldIndex, newIndex })) diff --git a/src/components/layers/__tests__/LayersPanel.spec.jsx b/src/components/layers/__tests__/LayersPanel.spec.jsx new file mode 100644 index 0000000000..0e7623bdfe --- /dev/null +++ b/src/components/layers/__tests__/LayersPanel.spec.jsx @@ -0,0 +1,86 @@ +import { render, screen } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import configureMockStore from 'redux-mock-store' +import { + COMBINED_TABLE_REF_LAYER, + THEMATIC_LAYER, +} from '../../../constants/layers.js' +import LayersPanel, { getSortIndices } from '../LayersPanel.jsx' + +jest.mock('../overlays/OverlayCard.jsx', () => { + const PropTypes = jest.requireActual('prop-types') + const OverlayCardMock = ({ layer }) => ( +
{layer.name}
+ ) + OverlayCardMock.displayName = 'OverlayCardMock' + OverlayCardMock.propTypes = { layer: PropTypes.object.isRequired } + return OverlayCardMock +}) + +jest.mock('../basemaps/BasemapCard.jsx', () => { + const BasemapCardMock = () =>
+ BasemapCardMock.displayName = 'BasemapCardMock' + return BasemapCardMock +}) + +jest.mock('../LayersToggle.jsx', () => { + const LayersToggleMock = () =>
+ LayersToggleMock.displayName = 'LayersToggleMock' + return LayersToggleMock +}) + +const mockStore = configureMockStore() + +const renderLayersPanel = (mapViews) => { + const store = mockStore({ + ui: { layersPanelOpen: true }, + map: { mapViews }, + }) + return render( + + + + ) +} + +describe('LayersPanel — reference org unit layer exclusion', () => { + test('never renders a card for the Combined data table reference layer', () => { + renderLayersPanel([ + { id: 'layer1', name: 'Layer 1', layer: THEMATIC_LAYER }, + { + id: 'ref1', + name: 'Reference', + layer: COMBINED_TABLE_REF_LAYER, + }, + { id: 'layer2', name: 'Layer 2', layer: THEMATIC_LAYER }, + ]) + + expect(screen.getByText('Layer 1')).toBeInTheDocument() + expect(screen.getByText('Layer 2')).toBeInTheDocument() + expect(screen.queryByText('Reference')).not.toBeInTheDocument() + expect(screen.getAllByTestId('overlaycard-mock')).toHaveLength(2) + }) +}) + +describe('getSortIndices', () => { + const reversedMapViews = [ + { id: 'layer2' }, + { id: 'ref1' }, + { id: 'layer1' }, + ] + + test('finds indices in the unfiltered reversed list, not a filtered display list', () => { + expect(getSortIndices(reversedMapViews, 'layer1', 'layer2')).toEqual({ + oldIndex: 2, + newIndex: 0, + }) + }) + + test('returns -1 for an id not present in the list', () => { + expect(getSortIndices(reversedMapViews, 'missing', 'layer2')).toEqual({ + oldIndex: -1, + newIndex: 0, + }) + }) +}) diff --git a/src/components/map/MapContainer.jsx b/src/components/map/MapContainer.jsx index 3e0b018baf..dbff78fcda 100644 --- a/src/components/map/MapContainer.jsx +++ b/src/components/map/MapContainer.jsx @@ -10,6 +10,7 @@ import { } from '../../actions/feature.js' import { openContextMenu, closeCoordinatePopup } from '../../actions/map.js' import { toggleFeatureSelection } from '../../actions/selection.js' +import { COMBINED_TABLE_REF_LAYER } from '../../constants/layers.js' import useBasemapConfig from '../../hooks/useBasemapConfig.js' import useDebouncedHighlightFeature from '../../hooks/useDebouncedHighlightFeature.js' import MapLoadingMask from './MapLoadingMask.jsx' @@ -25,9 +26,13 @@ const MapContainer = ({ resizeCount, setMap }) => { ) const feature = useSelector((state) => state.feature) const selection = useSelector((state) => state.selection) - const { layersSorting, highlightColor, selectionFilter } = useSelector( - (state) => state.ui - ) + const combinedView = useSelector((state) => state.dataTable.combinedView) + const { + layersSorting, + highlightColor, + selectionFilter, + combinedVisibleIds, + } = useSelector((state) => state.ui) const basemapConfig = useBasemapConfig(basemap) const dispatch = useDispatch() @@ -39,8 +44,11 @@ const MapContainer = ({ resizeCount, setMap }) => { dispatchHighlightFeature ) - const loadedMapViews = mapViews.filter((layer) => layer.isLoaded) - const isLoading = loadedMapViews.length !== mapViews.length + const renderableMapViews = mapViews.filter( + (layer) => layer.layer !== COMBINED_TABLE_REF_LAYER + ) + const loadedMapViews = renderableMapViews.filter((layer) => layer.isLoaded) + const isLoading = loadedMapViews.length !== renderableMapViews.length return ( <> @@ -54,11 +62,14 @@ const MapContainer = ({ resizeCount, setMap }) => { selection={selection} highlightColor={highlightColor} selectionFilter={selectionFilter} + combinedVisibleIds={combinedVisibleIds} highlightFeature={debouncedHighlightFeature} clickFeature={(payload) => dispatch(clickFeature(payload))} - toggleFeatureSelection={(id, layerId) => - dispatch(toggleFeatureSelection(id, layerId)) - } + toggleFeatureSelection={(id, layerId) => { + if (!combinedView) { + dispatch(toggleFeatureSelection(id, layerId)) + } + }} openContextMenu={(config) => dispatch(openContextMenu(config))} coordinatePopup={coordinatePopup} interpretationModalOpen={interpretationModalOpen} diff --git a/src/components/periods/RenderingStrategy.jsx b/src/components/periods/RenderingStrategy.jsx index eba6e36b3f..d59a1de493 100644 --- a/src/components/periods/RenderingStrategy.jsx +++ b/src/components/periods/RenderingStrategy.jsx @@ -4,6 +4,7 @@ import PropTypes from 'prop-types' import React, { useMemo } from 'react' import { useSelector } from 'react-redux' import { + COMBINED_TABLE_REF_LAYER, RENDERING_STRATEGY_SINGLE, RENDERING_STRATEGY_TIMELINE, RENDERING_STRATEGY_SPLIT_BY_PERIOD, @@ -32,15 +33,19 @@ const RenderingStrategy = ({ const hasOtherSplitLayers = useSelector(({ map }) => map.mapViews.some( (layer) => + layer.layer !== COMBINED_TABLE_REF_LAYER && layer.renderingStrategy === - RENDERING_STRATEGY_SPLIT_BY_PERIOD && layer.id !== layerId + RENDERING_STRATEGY_SPLIT_BY_PERIOD && + layer.id !== layerId ) ) const hasOtherNonSplitLayers = useSelector(({ map }) => map.mapViews.some( (layer) => + layer.layer !== COMBINED_TABLE_REF_LAYER && layer.renderingStrategy !== - RENDERING_STRATEGY_SPLIT_BY_PERIOD && layer.id !== layerId + RENDERING_STRATEGY_SPLIT_BY_PERIOD && + layer.id !== layerId ) ) diff --git a/src/components/periods/__tests__/RenderingStrategy.spec.jsx b/src/components/periods/__tests__/RenderingStrategy.spec.jsx new file mode 100644 index 0000000000..2119f46c53 --- /dev/null +++ b/src/components/periods/__tests__/RenderingStrategy.spec.jsx @@ -0,0 +1,37 @@ +import { render, screen } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import configureMockStore from 'redux-mock-store' +import RenderingStrategy from '../RenderingStrategy.jsx' + +const mockStore = configureMockStore() + +const renderStrategy = (mapViews) => { + const store = mockStore({ map: { mapViews } }) + return render( + + + + ) +} + +describe('RenderingStrategy — reference org unit layer exclusion', () => { + test('does not disable Split when the only other map view is the Combined reference layer', () => { + renderStrategy([ + { id: 'layer1' }, + { id: 'ref1', layer: 'combinedTableRef' }, + ]) + + expect(screen.getByRole('radio', { name: /Split/i })).not.toBeDisabled() + }) + + test('still disables Split when a real other layer is present', () => { + renderStrategy([{ id: 'layer1' }, { id: 'layer2' }]) + + expect(screen.getByRole('radio', { name: /Split/i })).toBeDisabled() + }) +}) diff --git a/src/components/plugin/Map.jsx b/src/components/plugin/Map.jsx index 97b9c87fe0..9c69423cb8 100644 --- a/src/components/plugin/Map.jsx +++ b/src/components/plugin/Map.jsx @@ -10,8 +10,10 @@ import React, { useState, useCallback, useEffect, + useMemo, useRef, } from 'react' +import { COMBINED_TABLE_REF_LAYER } from '../../constants/layers.js' import useDebouncedHighlightFeature from '../../hooks/useDebouncedHighlightFeature.js' import { drillUpDown } from '../../util/map.js' import { didViewsChange } from '../../util/pluginHelper.js' @@ -37,19 +39,29 @@ const getFullscreenDoc = () => { const Map = forwardRef((props, ref) => { const { basemap, mapViews, controls, getResizeFunction } = props + const renderableMapViews = useMemo( + () => mapViews.filter((v) => v.layer !== COMBINED_TABLE_REF_LAYER), + [mapViews] + ) + const layers = useRef( - mapViews.map((config) => ({ ...config, isLoaded: false })) + renderableMapViews.map((config) => ({ ...config, isLoaded: false })) ) useEffect(() => { - if (didViewsChange(layers.current, mapViews)) { - layers.current = mapViews.map((v) => ({ ...v, isLoaded: false })) + if (didViewsChange(layers.current, renderableMapViews)) { + layers.current = renderableMapViews.map((v) => ({ + ...v, + isLoaded: false, + })) setVisibilityOverrides({}) setMapIsLoaded(false) } - }, [mapViews]) + }, [renderableMapViews]) - const [mapIsLoaded, setMapIsLoaded] = useState(mapViews.length === 0) + const [mapIsLoaded, setMapIsLoaded] = useState( + renderableMapViews.length === 0 + ) const [contextMenu, setContextMenu] = useState() const [visibilityOverrides, setVisibilityOverrides] = useState({}) const [resizeCount, setResizeCount] = useState(0) diff --git a/src/hooks/useLayersLoader.js b/src/hooks/useLayersLoader.js index ee16701a85..ca051cf14c 100644 --- a/src/hooks/useLayersLoader.js +++ b/src/hooks/useLayersLoader.js @@ -22,6 +22,7 @@ const loaders = { external: externalLoader, facility: facilityLoader, orgUnit: orgUnitLoader, + combinedTableRef: orgUnitLoader, thematic: thematicLoader, geoJsonUrl: geoJsonUrlLoader, trackedEntity: trackedEntityLoader, diff --git a/src/loaders/__tests__/orgUnitLoader.spec.js b/src/loaders/__tests__/orgUnitLoader.spec.js index a2082f58be..8cca296a83 100644 --- a/src/loaders/__tests__/orgUnitLoader.spec.js +++ b/src/loaders/__tests__/orgUnitLoader.spec.js @@ -1,9 +1,11 @@ +import { WARNING_NO_OU_COORD } from '../../constants/alerts.js' import { FIRST_DATA_ELEMENT_QUERY, + GEOFEATURES_QUERY, ORG_UNITS_COUNT_QUERY, ORG_UNITS_PATHS_QUERY, } from '../../util/requests.js' -import { applyMissingCoordsCount } from '../orgUnitLoader.js' +import orgUnitLoader, { applyMissingCoordsCount } from '../orgUnitLoader.js' const makeEngine = ({ missingOuIds = [], @@ -123,3 +125,137 @@ describe('applyMissingCoordsCount', () => { ]) }) }) + +// A minimal engine covering every query the full orgUnitLoader issues - +// GEOFEATURES_QUERY, the org unit levels lookup, and (when +// countFeaturesWithoutCoordinates is in effect) the missing-org-units +// count/path queries already exercised above via applyMissingCoordsCount. +const makeFullLoaderEngine = ({ + missingOuIds = [], + ouNamesById = {}, + orgUnitPathsById = {}, +} = {}) => ({ + query: jest.fn((query) => { + if (query === GEOFEATURES_QUERY) { + return Promise.resolve({ geoFeatures: [] }) + } + if (query?.orgUnitLevels) { + return Promise.resolve({ + orgUnitLevels: { organisationUnitLevels: [] }, + }) + } + if (query === FIRST_DATA_ELEMENT_QUERY) { + return Promise.resolve({ + dataElements: { dataElements: [{ id: 'de1' }] }, + }) + } + if (query === ORG_UNITS_COUNT_QUERY) { + return Promise.resolve({ + orgUnitsCount: { + metaData: { + dimensions: { ou: missingOuIds }, + items: Object.fromEntries( + missingOuIds.map((id) => [ + id, + { name: ouNamesById[id] ?? id }, + ]) + ), + }, + }, + }) + } + if (query === ORG_UNITS_PATHS_QUERY) { + return Promise.resolve({ + organisationUnits: { + organisationUnits: Object.entries(orgUnitPathsById).map( + ([id, path]) => ({ id, path }) + ), + }, + }) + } + throw new Error('Unexpected query') + }), +}) + +const referenceLayerConfig = () => ({ + id: 'ref1', + layer: 'combinedTableRef', + rows: [{ dimension: 'ou', items: [{ id: 'ou1' }] }], +}) + +const orgUnitLayerConfig = () => ({ + id: 'orgunit1', + layer: 'orgUnit', + rows: [{ dimension: 'ou', items: [{ id: 'ou1' }] }], +}) + +const loadArgs = (config, engine) => ({ + config, + engine, + keyAnalysisDisplayProperty: 'name', + userId: 'user1', + baseUrl: '', +}) + +describe('orgUnitLoader - reference layer org units without coordinates', () => { + test('always loads org units without coordinates for the reference layer, even though it is never explicitly configured to', async () => { + const engine = makeFullLoaderEngine({ + missingOuIds: ['ou1'], + ouNamesById: { ou1: 'Country 1' }, + orgUnitPathsById: { ou1: '/ou1' }, + }) + + const result = await orgUnitLoader( + loadArgs(referenceLayerConfig(), engine) + ) + + expect(result.countFeaturesWithoutCoordinates).toBe(true) + expect(result.dataWithoutCoords).toEqual([ + { + id: 'ou1', + properties: { + id: 'ou1', + name: 'Country 1', + orgUnitId: 'ou1', + orgUnitPath: '/ou1', + orgUnitOwn: '/ou1', + level: 1, + }, + }, + ]) + }) + + test('does not push the generic "no coordinates" warning for the reference layer, even when every org unit lacks geometry', async () => { + const engine = makeFullLoaderEngine({ + missingOuIds: ['ou1'], + ouNamesById: { ou1: 'Country 1' }, + orgUnitPathsById: { ou1: '/ou1' }, + }) + + const result = await orgUnitLoader( + loadArgs(referenceLayerConfig(), engine) + ) + + expect(result.alerts).not.toContainEqual( + expect.objectContaining({ code: WARNING_NO_OU_COORD }) + ) + }) + + test('a regular org unit layer is unaffected - still needs the checkbox-derived flag to load org units without coordinates, and still warns when none have coordinates', async () => { + const engine = makeFullLoaderEngine({ + missingOuIds: ['ou1'], + ouNamesById: { ou1: 'Country 1' }, + orgUnitPathsById: { ou1: '/ou1' }, + }) + + const result = await orgUnitLoader( + loadArgs(orgUnitLayerConfig(), engine) + ) + + expect(result.countFeaturesWithoutCoordinates).toBeUndefined() + expect(result.dataWithoutCoords).toBeUndefined() + expect(result.alerts).toContainEqual( + expect.objectContaining({ code: WARNING_NO_OU_COORD }) + ) + }) +}) diff --git a/src/loaders/orgUnitLoader.js b/src/loaders/orgUnitLoader.js index 9d32ea932c..f8e97234e6 100644 --- a/src/loaders/orgUnitLoader.js +++ b/src/loaders/orgUnitLoader.js @@ -6,6 +6,7 @@ import { ERROR_CRITICAL, CUSTOM_ALERT, } from '../constants/alerts.js' +import { COMBINED_TABLE_REF_LAYER } from '../constants/layers.js' import { getOrgUnitsFromRows } from '../util/analytics.js' import { parseJsonConfig } from '../util/config.js' import { toGeoJson } from '../util/map.js' @@ -22,6 +23,7 @@ import { fetchAssociatedGeometries, } from '../util/orgUnits.js' import { GEOFEATURES_QUERY } from '../util/requests.js' +import { generateUid } from '../util/uid.js' export const applyMissingCoordsCount = async ( config, @@ -74,8 +76,14 @@ const orgUnitLoader = async ({ countFeaturesWithoutCoordinates, unclassifiedLegend, dataTableColumnConfig, + combinedJoinConfig, + combinedColumnConfig, + combinedLayerKey, } = parseJsonConfig(config.config) - if (countFeaturesWithoutCoordinates) { + if ( + countFeaturesWithoutCoordinates || + config.layer === COMBINED_TABLE_REF_LAYER + ) { config.countFeaturesWithoutCoordinates = true } if (unclassifiedLegend) { @@ -84,6 +92,13 @@ const orgUnitLoader = async ({ if (dataTableColumnConfig) { config.dataTableColumnConfig = dataTableColumnConfig } + if (combinedJoinConfig) { + config.combinedJoinConfig = combinedJoinConfig + } + if (combinedColumnConfig) { + config.combinedColumnConfig = combinedColumnConfig + } + config.combinedLayerKey = combinedLayerKey ?? generateUid() delete config.config // Data loading @@ -111,7 +126,11 @@ const orgUnitLoader = async ({ ) const mainFeatures = data?.geoFeatures ? toGeoJson(data.geoFeatures) : [] - if (!mainFeatures.length && !alerts.length) { + if ( + !mainFeatures.length && + !alerts.length && + config.layer !== COMBINED_TABLE_REF_LAYER + ) { alerts.push({ code: WARNING_NO_OU_COORD, message: i18n.t('Org unit layer'), From 2575ced0a20ce55eb43080b0f3116683dc46daa6 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Wed, 9 Sep 2026 18:30:38 +0200 Subject: [PATCH 4/9] feat: add the Combined table's row-building engine [DHIS2-20543] Ports the value-column and aggregation infrastructure from feat/datatable-pr7-multilayer: util/aggregation.js (generic SUM/AVERAGE/COUNT/MIN/MAX/STDDEV/VARIANCE plus DHIS2/Earth-Engine aggregation-type defaulting), the DATA_KEY_KIND_* constants, and a large block of new util/dataTable.js exports (getCombinedValueDataKeys, getCombinedLegendConfig, getDefaultCombinedAggregation, getFeatureCategoryKey, mergeCrossLayerIds, getUnionBounds) that decide, per layer type, which value/count/category columns a Combined-view join produces. isDataTableOpen/getLayerSelectedIds gain combinedView/ crossLayerIds awareness without changing their existing single-layer behavior. useCombinedTableData.js is the hook that ties it together: one row per reference org unit, joined layer values/counts/categories computed per row, reusing the same filterData/compareRows/ buildRowCells/getColumnDistinctValues pipeline the single-layer table already uses. getRowClickAction and friends in util/dataTable.js are deliberately untouched - this branch's row-selection anchor fix must not be overwritten by the source branch's older behavior. Also ports DataTableButton.jsx's combinedView-aware open/close/reopen logic (a real gap a straight per-file port would have missed, since it wasn't in this step's original file list - found via review). Note for follow-up (inherited from the source branch, not introduced here, not fixed to keep this a faithful port): a few latent correctness/efficiency issues survive unchanged from feat/datatable-pr7-multilayer - MIN/MAX aggregation spreads arrays into Math.min/max (RangeError risk on very large joins), multi-stat Earth Engine layers default every stat's aggregation type from stat[0], GEOJSON_URL_LAYER has no case in getCombinedValueDataKeys (falls back to an always-null generic value column), and getDefaultCombinedAggregation/ getFeatureCategoryKey are recomputed per-row instead of once per layer in useCombinedTableData's hot path. Worth a dedicated cleanup pass once the whole feature is ported and working end-to-end. Co-Authored-By: Claude Sonnet 5 --- src/components/datatable/DataTableButton.jsx | 12 +- .../__tests__/DataTableButton.spec.jsx | 76 +- .../__tests__/useCombinedTableData.spec.js | 1356 +++++++++++++++++ .../datatable/useCombinedTableData.js | 489 ++++++ src/constants/dataTable.js | 6 + src/util/__tests__/aggregation.spec.js | 125 ++ src/util/__tests__/dataTable.spec.js | 881 ++++++++++- src/util/aggregation.js | 65 + src/util/dataTable.js | 334 +++- 9 files changed, 3327 insertions(+), 17 deletions(-) create mode 100644 src/components/datatable/__tests__/useCombinedTableData.spec.js create mode 100644 src/components/datatable/useCombinedTableData.js create mode 100644 src/util/__tests__/aggregation.spec.js create mode 100644 src/util/aggregation.js diff --git a/src/components/datatable/DataTableButton.jsx b/src/components/datatable/DataTableButton.jsx index 39232a5c88..1750cba278 100644 --- a/src/components/datatable/DataTableButton.jsx +++ b/src/components/datatable/DataTableButton.jsx @@ -5,11 +5,14 @@ import { closeDataTable, openDataTable, toggleDataTable, + toggleCombinedView, } from '../../actions/dataTable.js' +import { getOrgUnitsFromRows } from '../../util/analytics.js' import { getEligibleDataTableLayers, isDataTableOpen, } from '../../util/dataTable.js' +import { useReferenceLayer } from './controls/ReferenceOrgUnitControl.jsx' import styles from './styles/DataTableButton.module.css' const DataTableButton = () => { @@ -17,17 +20,22 @@ const DataTableButton = () => { const dataTable = useSelector((state) => state.dataTable) const mapViews = useSelector((state) => state.map.mapViews) const eligibleLayers = getEligibleDataTableLayers(mapViews) + const { referenceLayer } = useReferenceLayer() + const combinedEnabled = + !!referenceLayer && getOrgUnitsFromRows(referenceLayer.rows).length > 0 const onClick = () => { if (isDataTableOpen(dataTable)) { dispatch(closeDataTable()) return } - if (dataTable.openIds.length > 0) { + if (dataTable.openIds.length > 0 || dataTable.combinedView) { dispatch(openDataTable()) return } - if (eligibleLayers.length >= 1) { + if (combinedEnabled) { + dispatch(toggleCombinedView()) + } else if (eligibleLayers.length >= 1) { dispatch(toggleDataTable(eligibleLayers[0].id)) } } diff --git a/src/components/datatable/__tests__/DataTableButton.spec.jsx b/src/components/datatable/__tests__/DataTableButton.spec.jsx index e77d3677ec..c311cce093 100644 --- a/src/components/datatable/__tests__/DataTableButton.spec.jsx +++ b/src/components/datatable/__tests__/DataTableButton.spec.jsx @@ -16,6 +16,14 @@ const layer = (id, overrides = {}) => ({ ...overrides, }) +const referenceLayer = ( + rows = [{ dimension: 'ou', items: [{ id: 'country1' }] }] +) => ({ + id: 'ref1', + layer: 'combinedTableRef', + rows, +}) + const renderButton = ({ dataTable, mapViews }) => { const store = mockStore({ dataTable, @@ -29,7 +37,7 @@ const renderButton = ({ dataTable, mapViews }) => { return { ...result, store } } -const CLOSED = { openIds: [] } +const CLOSED = { openIds: [], combinedView: false } describe('DataTableButton', () => { test('is disabled when the map has no eligible layers', () => { @@ -40,7 +48,18 @@ describe('DataTableButton', () => { expect(screen.getByText('Data table')).toBeDisabled() }) - test('opens the first eligible layer when none is open yet', () => { + test('opens the single layer directly when only one is eligible', () => { + const { store } = renderButton({ + dataTable: CLOSED, + mapViews: [layer('a')], + }) + fireEvent.click(screen.getByText('Data table')) + expect(store.getActions()).toEqual([ + { type: 'DATA_TABLE_TOGGLE', id: 'a' }, + ]) + }) + + test('opens the first eligible layer, not Combined, when 2+ are eligible but no reference is configured yet', () => { const { store } = renderButton({ dataTable: CLOSED, mapViews: [layer('a'), layer('b')], @@ -51,18 +70,63 @@ describe('DataTableButton', () => { ]) }) - test('closes the panel when a table is already open', () => { + test('opens Combined directly when a reference org unit set has already been configured', () => { const { store } = renderButton({ - dataTable: { openIds: ['a'], isPanelVisible: true }, + dataTable: CLOSED, + mapViews: [layer('a'), layer('b'), referenceLayer()], + }) + fireEvent.click(screen.getByText('Data table')) + expect(store.getActions()).toEqual([ + { type: 'DATA_TABLE_COMBINED_VIEW_TOGGLE' }, + ]) + }) + + test('closes the panel when a single-layer table is already open', () => { + const { store } = renderButton({ + dataTable: { + openIds: ['a'], + combinedView: false, + isPanelVisible: true, + }, + mapViews: [layer('a'), layer('b')], + }) + fireEvent.click(screen.getByText('Data table')) + expect(store.getActions()).toEqual([{ type: 'DATA_TABLE_CLOSE' }]) + }) + + test('closes the panel when Combined is already open', () => { + const { store } = renderButton({ + dataTable: { + openIds: [], + combinedView: true, + isPanelVisible: true, + }, mapViews: [layer('a'), layer('b')], }) fireEvent.click(screen.getByText('Data table')) expect(store.getActions()).toEqual([{ type: 'DATA_TABLE_CLOSE' }]) }) - test('reopens (without changing what is open) when a table was open but the panel is hidden', () => { + test('reopens (without changing what is open) when a single-layer table was open but the panel is hidden', () => { + const { store } = renderButton({ + dataTable: { + openIds: ['a'], + combinedView: false, + isPanelVisible: false, + }, + mapViews: [layer('a'), layer('b')], + }) + fireEvent.click(screen.getByText('Data table')) + expect(store.getActions()).toEqual([{ type: 'DATA_TABLE_OPEN' }]) + }) + + test('reopens (without changing what is open) when Combined was open but the panel is hidden', () => { const { store } = renderButton({ - dataTable: { openIds: ['a'], isPanelVisible: false }, + dataTable: { + openIds: [], + combinedView: true, + isPanelVisible: false, + }, mapViews: [layer('a'), layer('b')], }) fireEvent.click(screen.getByText('Data table')) diff --git a/src/components/datatable/__tests__/useCombinedTableData.spec.js b/src/components/datatable/__tests__/useCombinedTableData.spec.js new file mode 100644 index 0000000000..ea9b22da2b --- /dev/null +++ b/src/components/datatable/__tests__/useCombinedTableData.spec.js @@ -0,0 +1,1356 @@ +import { renderHook } from '@testing-library/react' +import { + EARTH_ENGINE_LAYER, + EVENT_LAYER, + FACILITY_LAYER, + THEMATIC_LAYER, +} from '../../../constants/layers.js' +import { useCombinedTableData } from '../useCombinedTableData.js' + +const feature = (props) => ({ properties: props }) + +const findCell = (row, dataKey) => row.find((c) => c.dataKey === dataKey) + +const referenceLayer = { + id: 'ref1', + data: [ + feature({ + id: 'ou1', + name: 'Ou One', + orgUnitPath: '/country1/ou1', + level: 2, + }), + feature({ + id: 'ou2', + name: 'Ou Two', + orgUnitPath: '/country1/ou2', + level: 2, + }), + ], +} + +describe('useCombinedTableData - org unit join', () => { + test('joins a direct match by exact org unit path, filling blanks for unmatched reference rows', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [ + feature({ + id: 'ou1', + orgUnitPath: '/country1/ou1', + rawValue: 10, + legend: 'Low', + }), + ], + }, + ] + const joinConfig = { + layers: { + layerA: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + } + + const { result } = renderHook(() => + useCombinedTableData({ layers, referenceLayer, joinConfig }) + ) + + expect(result.current.headers.map((h) => h.dataKey)).toEqual([ + 'id', + 'name', + 'level', + 'orgUnitPath', + 'layerA_rawValue', + 'layerA_legend', + ]) + expect(result.current.headers[0].name).toBe('Org unit id') + expect(result.current.headers[1].name).toBe('Org unit') + expect(result.current.rows).toHaveLength(2) + + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'name').value).toBe('Ou One') + expect(findCell(row1, 'layerA_rawValue').value).toBe(10) + expect(findCell(row1, 'layerA_legend').value).toBe('Low') + + const row2 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou2' + ) + expect(findCell(row2, 'layerA_rawValue').value).toBe(null) + }) + + test('takes the row name/level directly from the reference layer, not from the participating layer', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [ + feature({ + id: 'ou1', + name: 'Some other name', + orgUnitPath: '/country1/ou1', + level: 99, + rawValue: 10, + }), + ], + }, + ] + const joinConfig = { + layers: { layerA: { type: 'orgUnit', aggregation: {} } }, + } + + const { result } = renderHook(() => + useCombinedTableData({ layers, referenceLayer, joinConfig }) + ) + + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'name').value).toBe('Ou One') + expect(findCell(row1, 'level').value).toBe(2) + }) + + test('aggregates several descendant features under the reference org unit using the chosen aggregation type', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [ + feature({ + id: 'evt1', + orgUnitPath: '/country1/ou1/facility1', + rawValue: 10, + }), + feature({ + id: 'evt2', + orgUnitPath: '/country1/ou1/facility2', + rawValue: 20, + }), + ], + }, + ] + const joinConfig = { + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'AVERAGE' }, + }, + }, + } + + const { result } = renderHook(() => + useCombinedTableData({ layers, referenceLayer, joinConfig }) + ) + + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'layerA_rawValue').value).toBe(15) + }) + + test("excludes a feature already removed by the layer's own dataFilters from the aggregation", () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + dataFilters: { rawValue: '>15' }, + data: [ + feature({ + id: 'evt1', + orgUnitPath: '/country1/ou1/facility1', + rawValue: 10, + }), + feature({ + id: 'evt2', + orgUnitPath: '/country1/ou1/facility2', + rawValue: 20, + }), + ], + }, + ] + const joinConfig = { + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'AVERAGE' }, + }, + }, + } + + const { result } = renderHook(() => + useCombinedTableData({ layers, referenceLayer, joinConfig }) + ) + + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'layerA_rawValue').value).toBe(20) + }) + + test('shows blank for a feature whose org unit is an ancestor of the reference, not a descendant', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [ + feature({ + id: 'country1', + orgUnitPath: '/country1', + rawValue: 10, + }), + ], + }, + ] + const joinConfig = { + layers: { layerA: { type: 'orgUnit', aggregation: {} } }, + } + + const { result } = renderHook(() => + useCombinedTableData({ layers, referenceLayer, joinConfig }) + ) + + result.current.rows.forEach((row) => { + expect(findCell(row, 'layerA_rawValue').value).toBe(null) + }) + }) + + test('resolves legend to the shared value when every match agrees, otherwise blank', () => { + const agreeingLayer = { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [ + feature({ + id: 'evt1', + orgUnitPath: '/country1/ou1/f1', + legend: 'Low', + }), + feature({ + id: 'evt2', + orgUnitPath: '/country1/ou1/f2', + legend: 'Low', + }), + ], + } + const disagreeingLayer = { + id: 'layerB', + name: 'Layer B', + combinedLayerKey: 'layerB', + data: [ + feature({ + id: 'evt3', + orgUnitPath: '/country1/ou2/f1', + legend: 'Low', + }), + feature({ + id: 'evt4', + orgUnitPath: '/country1/ou2/f2', + legend: 'High', + }), + ], + } + const joinConfig = { + layers: { + layerA: { type: 'orgUnit', aggregation: {} }, + layerB: { type: 'orgUnit', aggregation: {} }, + }, + } + + const { result } = renderHook(() => + useCombinedTableData({ + layers: [agreeingLayer, disagreeingLayer], + referenceLayer, + joinConfig, + }) + ) + + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'layerA_legend').value).toBe('Low') + + const row2 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou2' + ) + expect(findCell(row2, 'layerB_legend').value).toBe(null) + }) + + test('includes rows from layer.dataWithoutCoords, matching the single-layer table', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [], + dataWithoutCoords: [ + feature({ + id: 'ou1', + orgUnitPath: '/country1/ou1', + rawValue: 10, + }), + ], + }, + ] + const joinConfig = { + layers: { layerA: { type: 'orgUnit', aggregation: {} } }, + } + + const { result } = renderHook(() => + useCombinedTableData({ layers, referenceLayer, joinConfig }) + ) + + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'layerA_rawValue').value).toBe(10) + }) + + test('excludes features with hasAdditionalGeometry set', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [ + feature({ + id: 'extra', + orgUnitPath: '/country1/ou1', + rawValue: 999, + hasAdditionalGeometry: true, + }), + ], + }, + ] + const joinConfig = { + layers: { layerA: { type: 'orgUnit', aggregation: {} } }, + } + + const { result } = renderHook(() => + useCombinedTableData({ layers, referenceLayer, joinConfig }) + ) + + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'layerA_rawValue').value).toBe(null) + }) + + test('rowFeatureIds always includes the reference layer itself, plus every matching participating feature', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [ + feature({ + id: 'evt1', + orgUnitPath: '/country1/ou1/f1', + rawValue: 10, + }), + feature({ + id: 'evt2', + orgUnitPath: '/country1/ou1/f2', + rawValue: 20, + }), + ], + }, + ] + const joinConfig = { + layers: { layerA: { type: 'orgUnit', aggregation: {} } }, + } + + const { result } = renderHook(() => + useCombinedTableData({ layers, referenceLayer, joinConfig }) + ) + + expect(result.current.rowFeatureIds.get('ou1')).toEqual({ + ref1: ['ou1'], + layerA: ['evt1', 'evt2'], + }) + // ou2 has no participating match, but the reference feature id is + // still present so "zoom to feature" always has real bounds. + expect(result.current.rowFeatureIds.get('ou2')).toEqual({ + ref1: ['ou2'], + }) + }) +}) + +describe('useCombinedTableData - spatial join', () => { + const referenceOrgUnitsAsPolygons = { + id: 'ref1', + data: [ + { + type: 'Feature', + properties: { id: 'ou1', name: 'Ou One', level: 2 }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + [0, 0], + ], + ], + }, + }, + ], + } + + test('joins a point feature to the reference org unit whose polygon contains it', () => { + const layers = [ + { + id: 'points', + name: 'Points', + combinedLayerKey: 'points', + layer: 'geoJsonUrl', + data: [ + { + type: 'Feature', + properties: { id: 'p1', rawValue: 42, legend: 'High' }, + geometry: { type: 'Point', coordinates: [1, 1] }, + }, + ], + }, + ] + const joinConfig = { + layers: { + points: { + type: 'spatial', + aggregation: { rawValue: 'SUM' }, + }, + }, + } + + const { result } = renderHook(() => + useCombinedTableData({ + layers, + referenceLayer: referenceOrgUnitsAsPolygons, + joinConfig, + }) + ) + + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'points_rawValue').value).toBe(42) + expect(result.current.rowFeatureIds.get('ou1')).toEqual({ + ref1: ['ou1'], + points: ['p1'], + }) + }) + + test('matches an event feature via its centroid when its geometry is a polygon', () => { + const layers = [ + { + id: 'events', + name: 'Events', + combinedLayerKey: 'events', + layer: EVENT_LAYER, + data: [ + { + type: 'Feature', + properties: { id: 'e1' }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0.5, 0.5], + [1.5, 0.5], + [1.5, 1.5], + [0.5, 1.5], + [0.5, 0.5], + ], + ], + }, + }, + ], + }, + ] + const joinConfig = { + layers: { + events: { type: 'spatial', aggregation: {} }, + }, + } + + const { result } = renderHook(() => + useCombinedTableData({ + layers, + referenceLayer: referenceOrgUnitsAsPolygons, + joinConfig, + }) + ) + + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + // An unstyled Event layer is count-only (see getCombinedValueDataKeys) + expect(findCell(row1, 'events_count').value).toBe(1) + }) + + test('matches via centroid regardless of layer type - not just Event/TrackedEntity', () => { + const layers = [ + { + id: 'zones', + name: 'Zones', + combinedLayerKey: 'zones', + layer: 'geoJsonUrl', + data: [ + { + type: 'Feature', + properties: { id: 'z1', rawValue: 3 }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0.5, 0.5], + [1.5, 0.5], + [1.5, 1.5], + [0.5, 1.5], + [0.5, 0.5], + ], + ], + }, + }, + ], + }, + ] + const joinConfig = { + layers: { + zones: { type: 'spatial', aggregation: { rawValue: 'SUM' } }, + }, + } + + const { result } = renderHook(() => + useCombinedTableData({ + layers, + referenceLayer: referenceOrgUnitsAsPolygons, + joinConfig, + }) + ) + + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'zones_rawValue').value).toBe(3) + }) + + test('averages several points falling inside the same reference polygon', () => { + const layers = [ + { + id: 'points', + name: 'Points', + combinedLayerKey: 'points', + layer: 'geoJsonUrl', + data: [ + { + type: 'Feature', + properties: { id: 'p1', rawValue: 10 }, + geometry: { type: 'Point', coordinates: [0.5, 0.5] }, + }, + { + type: 'Feature', + properties: { id: 'p2', rawValue: 20 }, + geometry: { type: 'Point', coordinates: [1.5, 1.5] }, + }, + ], + }, + ] + const joinConfig = { + layers: { + points: { + type: 'spatial', + aggregation: { rawValue: 'AVERAGE' }, + }, + }, + } + + const { result } = renderHook(() => + useCombinedTableData({ + layers, + referenceLayer: referenceOrgUnitsAsPolygons, + joinConfig, + }) + ) + + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'points_rawValue').value).toBe(15) + }) + + test('sets spatialWarning when a spatially-joined layer exceeds the large-feature threshold', () => { + const layers = [ + { + id: 'points', + name: 'Points', + combinedLayerKey: 'points', + layer: 'event', + data: Array.from({ length: 10001 }, (_, i) => ({ + type: 'Feature', + properties: { id: `p${i}` }, + geometry: { type: 'Point', coordinates: [1, 1] }, + })), + }, + ] + const joinConfig = { + layers: { points: { type: 'spatial', aggregation: {} } }, + } + + const { result } = renderHook(() => + useCombinedTableData({ + layers, + referenceLayer: referenceOrgUnitsAsPolygons, + joinConfig, + }) + ) + + expect(result.current.spatialWarning).toBe(true) + }) +}) + +describe('useCombinedTableData - sorting and filtering', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [ + feature({ + id: 'ou1', + orgUnitPath: '/country1/ou1', + rawValue: 30, + }), + feature({ + id: 'ou2', + orgUnitPath: '/country1/ou2', + rawValue: 10, + }), + ], + }, + ] + const joinConfig = { + layers: { layerA: { type: 'orgUnit', aggregation: {} } }, + } + + test('sorts rows by a numeric column ascending', () => { + const { result } = renderHook(() => + useCombinedTableData({ + layers, + referenceLayer, + joinConfig, + sortField: 'layerA_rawValue', + sortDirection: 'asc', + }) + ) + + expect(result.current.rows.map((r) => findCell(r, 'id').value)).toEqual( + ['ou2', 'ou1'] + ) + }) + + test('sorts rows by a numeric column descending', () => { + const { result } = renderHook(() => + useCombinedTableData({ + layers, + referenceLayer, + joinConfig, + sortField: 'layerA_rawValue', + sortDirection: 'desc', + }) + ) + + expect(result.current.rows.map((r) => findCell(r, 'id').value)).toEqual( + ['ou1', 'ou2'] + ) + }) + + test('applies a per-column filter', () => { + const { result } = renderHook(() => + useCombinedTableData({ + layers, + referenceLayer, + joinConfig, + filters: { layerA_rawValue: '>15' }, + }) + ) + + expect(result.current.rows.map((r) => findCell(r, 'id').value)).toEqual( + ['ou1'] + ) + }) + + test('applies global search across string columns', () => { + const { result } = renderHook(() => + useCombinedTableData({ + layers, + referenceLayer, + joinConfig, + globalSearch: 'Ou Two', + }) + ) + + expect(result.current.rows.map((r) => findCell(r, 'id').value)).toEqual( + ['ou2'] + ) + }) + + test('exposes distinct column values for the filter popover, sorted ascending by default', () => { + const { result } = renderHook(() => + useCombinedTableData({ layers, referenceLayer, joinConfig }) + ) + + expect(result.current.columnOptions.layerA_rawValue).toEqual([ + { value: '10' }, + { value: '30' }, + ]) + }) + + test('keeps the same headers array reference across filter/sort/selection-only changes - a changed reference makes useColumnWidths.js reset and re-measure column widths on every keystroke', () => { + const { result, rerender } = renderHook( + (props) => useCombinedTableData(props), + { + initialProps: { + layers, + referenceLayer, + joinConfig, + sortField: null, + sortDirection: 'asc', + filters: {}, + globalSearch: '', + selectedIdSet: new Set(), + }, + } + ) + const firstHeaders = result.current.headers + + rerender({ + layers, + referenceLayer, + joinConfig, + sortField: 'layerA_rawValue', + sortDirection: 'desc', + filters: { layerA_rawValue: '>15' }, + globalSearch: 'ou', + selectedIdSet: new Set(['ou1']), + }) + + expect(result.current.headers).toBe(firstHeaders) + }) +}) + +describe('useCombinedTableData - empty input', () => { + test('returns an empty result when the reference layer has no org units', () => { + const joinConfig = { layers: {} } + + const { result } = renderHook(() => + useCombinedTableData({ + layers: [], + referenceLayer: { id: 'ref1', data: [] }, + joinConfig, + }) + ) + + expect(result.current).toEqual({ + headers: [], + rows: [], + rowFeatureIds: new Map(), + columnOptions: {}, + spatialWarning: false, + orgUnitIdToName: new Map(), + }) + }) + + test('still returns one row per reference org unit when there are no participating layers', () => { + const joinConfig = { layers: {} } + + const { result } = renderHook(() => + useCombinedTableData({ layers: [], referenceLayer, joinConfig }) + ) + + expect(result.current.rows).toHaveLength(2) + expect(result.current.headers.map((h) => h.dataKey)).toEqual([ + 'id', + 'name', + 'level', + 'orgUnitPath', + ]) + }) +}) + +describe('useCombinedTableData - Earth Engine value columns', () => { + // Earth Engine layers never carry their value(s) directly on feature + // properties they're computed client-side into state.aggregations + test('merges aggregation stats in and generates one joinable column per stat, with no generic legend column', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + layer: EARTH_ENGINE_LAYER, + aggregationType: ['mean', 'max'], + legend: { title: 'NDVI' }, + data: [feature({ id: 'f1', orgUnitPath: '/country1/ou1' })], + }, + ] + const joinConfig = { + layers: { + layerA: { + type: 'orgUnit', + aggregation: { mean: 'SUM', max: 'SUM' }, + }, + }, + } + + const { result } = renderHook(() => + useCombinedTableData({ + layers, + referenceLayer, + joinConfig, + aggregations: { layerA: { f1: { mean: 12.3, max: 20 } } }, + }) + ) + + expect(result.current.headers.map((h) => h.dataKey)).toEqual([ + 'id', + 'name', + 'level', + 'orgUnitPath', + 'layerA_mean', + 'layerA_max', + ]) + expect( + result.current.headers.find((h) => h.dataKey === 'layerA_mean').name + ).toBe('Mean Ndvi (Layer A)') + + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'layerA_mean').value).toBe(12.3) + expect(findCell(row1, 'layerA_max').value).toBe(20) + }) + + test('merges classified aggregation values in and generates one column per legend class', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + layer: EARTH_ENGINE_LAYER, + aggregationType: 'percentage', + legend: { + items: [ + { value: 1, name: 'Forest' }, + { value: 2, name: 'Water' }, + ], + }, + data: [feature({ id: 'f1', orgUnitPath: '/country1/ou1' })], + }, + ] + const joinConfig = { + layers: { + layerA: { + type: 'orgUnit', + aggregation: { 1: 'SUM', 2: 'SUM' }, + }, + }, + } + + const { result } = renderHook(() => + useCombinedTableData({ + layers, + referenceLayer, + joinConfig, + aggregations: { layerA: { f1: { 1: 45.2, 2: 12.1 } } }, + }) + ) + + expect(result.current.headers.map((h) => h.dataKey)).toEqual([ + 'id', + 'name', + 'level', + 'orgUnitPath', + 'layerA_1', + 'layerA_2', + ]) + expect( + result.current.headers.find((h) => h.dataKey === 'layerA_1').name + ).toBe('Forest (Layer A)') + + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'layerA_1').value).toBe(45.2) + expect(findCell(row1, 'layerA_2').value).toBe(12.1) + }) +}) + +describe('useCombinedTableData - thematic timeline/split-by-period value columns', () => { + const periods = [ + { id: 'p1', name: 'Jan 2023' }, + { id: 'p2', name: 'Feb 2023' }, + ] + const valuesByPeriod = { + p1: { f1: { value: 10, legend: 'Low' } }, + p2: { f1: { value: 20, legend: 'High' } }, + } + + test('TIMELINE: current column resolves from the active period, plus one fixed column per period', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + layer: THEMATIC_LAYER, + renderingStrategy: 'TIMELINE', + periods, + valuesByPeriod, + data: [feature({ id: 'f1', orgUnitPath: '/country1/ou1' })], + }, + ] + const joinConfig = { + layers: { layerA: { type: 'orgUnit', aggregation: {} } }, + } + + const { result } = renderHook(() => + useCombinedTableData({ + layers, + referenceLayer, + joinConfig, + externalPeriod: periods[0], + }) + ) + + expect(result.current.headers.map((h) => h.dataKey)).toEqual([ + 'id', + 'name', + 'level', + 'orgUnitPath', + 'layerA_rawValue', + 'layerA_period_p1_rawValue', + 'layerA_period_p2_rawValue', + 'layerA_legend', + ]) + + const currentHeader = result.current.headers.find( + (h) => h.dataKey === 'layerA_rawValue' + ) + expect(currentHeader.name).toBe('Value (Layer A, Jan 2023)') + expect(currentHeader.configName).toBe('Value (Layer A, Current period)') + + const legendHeader = result.current.headers.find( + (h) => h.dataKey === 'layerA_legend' + ) + expect(legendHeader.name).toBe('Legend (Layer A, Jan 2023)') + expect(legendHeader.configName).toBe('Legend (Layer A, Current period)') + + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'layerA_rawValue').value).toBe(10) + expect(findCell(row1, 'layerA_period_p1_rawValue').value).toBe(10) + expect(findCell(row1, 'layerA_period_p2_rawValue').value).toBe(20) + expect(findCell(row1, 'layerA_legend').value).toBe('Low') + }) + + test('SPLIT_BY_PERIOD: only fixed per-period columns, no current column, no legend column', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + layer: THEMATIC_LAYER, + renderingStrategy: 'SPLIT_BY_PERIOD', + periods, + valuesByPeriod, + data: [feature({ id: 'f1', orgUnitPath: '/country1/ou1' })], + }, + ] + const joinConfig = { + layers: { layerA: { type: 'orgUnit', aggregation: {} } }, + } + + const { result } = renderHook(() => + useCombinedTableData({ layers, referenceLayer, joinConfig }) + ) + + expect(result.current.headers.map((h) => h.dataKey)).toEqual([ + 'id', + 'name', + 'level', + 'orgUnitPath', + 'layerA_period_p1_rawValue', + 'layerA_period_p2_rawValue', + ]) + + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'layerA_period_p1_rawValue').value).toBe(10) + expect(findCell(row1, 'layerA_period_p2_rawValue').value).toBe(20) + }) + + test('aggregates several matched features per period using one shared aggregation type', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + layer: THEMATIC_LAYER, + renderingStrategy: 'TIMELINE', + periods, + valuesByPeriod: { + p1: { f1: { value: 10 }, f2: { value: 30 } }, + p2: { f1: { value: 20 }, f2: { value: 40 } }, + }, + data: [ + feature({ id: 'f1', orgUnitPath: '/country1/ou1' }), + feature({ id: 'f2', orgUnitPath: '/country1/ou1' }), + ], + }, + ] + const joinConfig = { + layers: { + layerA: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + } + + const { result } = renderHook(() => + useCombinedTableData({ + layers, + referenceLayer, + joinConfig, + externalPeriod: periods[0], + }) + ) + + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'layerA_rawValue').value).toBe(40) + expect(findCell(row1, 'layerA_period_p1_rawValue').value).toBe(40) + expect(findCell(row1, 'layerA_period_p2_rawValue').value).toBe(60) + }) + + test('dead-reference regression: value/legend are read from valuesByPeriod, never from raw feature properties', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + layer: THEMATIC_LAYER, + renderingStrategy: 'TIMELINE', + periods, + valuesByPeriod, + data: [ + feature({ + id: 'f1', + orgUnitPath: '/country1/ou1', + rawValue: 9999, + legend: 'Bogus', + }), + ], + }, + ] + const joinConfig = { + layers: { layerA: { type: 'orgUnit', aggregation: {} } }, + } + + const { result } = renderHook(() => + useCombinedTableData({ + layers, + referenceLayer, + joinConfig, + externalPeriod: periods[0], + }) + ) + + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'layerA_rawValue').value).toBe(10) + expect(findCell(row1, 'layerA_legend').value).toBe('Low') + }) +}) + +describe('useCombinedTableData - show only features in view', () => { + const referenceFeature = ({ id, name, path, coordinates }) => ({ + type: 'Feature', + properties: { id, name, orgUnitPath: path, level: 2 }, + geometry: { type: 'Point', coordinates }, + }) + + const referenceLayerWithGeometry = { + id: 'ref1', + data: [ + referenceFeature({ + id: 'ou1', + name: 'Ou One', + path: '/country1/ou1', + coordinates: [1, 1], + }), + referenceFeature({ + id: 'ou2', + name: 'Ou Two', + path: '/country1/ou2', + coordinates: [10, 10], + }), + ], + } + + test('includes every reference org unit when showOnlyFeaturesInView is false, regardless of mapBounds', () => { + const { result } = renderHook(() => + useCombinedTableData({ + layers: [], + referenceLayer: referenceLayerWithGeometry, + joinConfig: { layers: {} }, + showOnlyFeaturesInView: false, + mapBounds: [0, 0, 2, 2], + }) + ) + + expect(result.current.rows).toHaveLength(2) + }) + + test('only includes reference org units within mapBounds when showOnlyFeaturesInView is true', () => { + const { result } = renderHook(() => + useCombinedTableData({ + layers: [], + referenceLayer: referenceLayerWithGeometry, + joinConfig: { layers: {} }, + showOnlyFeaturesInView: true, + mapBounds: [0, 0, 2, 2], + }) + ) + + expect(result.current.rows.map((r) => findCell(r, 'id').value)).toEqual( + ['ou1'] + ) + }) +}) + +describe('useCombinedTableData - categorical/count value columns', () => { + const facilityLayer = (data) => ({ + id: 'facility1', + name: 'Facilities', + combinedLayerKey: 'facility1', + layer: FACILITY_LAYER, + organisationUnitGroupSet: { id: 'groupSet1' }, + legend: { + items: [ + { id: 'group1', name: 'Hospital' }, + { id: 'group2', name: 'Clinic' }, + ], + }, + data, + }) + + test('a category column reflects the matched-feature count for that category, and the shared categoryDisplayType setting switches every category column to a percentage together', () => { + const layer = facilityLayer([ + feature({ + id: 'f1', + orgUnitPath: '/country1/ou1', + dimensions: { groupSet1: 'group1' }, + }), + feature({ + id: 'f2', + orgUnitPath: '/country1/ou1', + dimensions: { groupSet1: 'group2' }, + }), + ]) + + const { result } = renderHook(() => + useCombinedTableData({ + layers: [layer], + referenceLayer, + joinConfig: { + layers: { facility1: { type: 'orgUnit', aggregation: {} } }, + }, + }) + ) + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'facility1_group1').value).toBe(1) + + const { result: percentResult } = renderHook(() => + useCombinedTableData({ + layers: [layer], + referenceLayer, + joinConfig: { + layers: { + facility1: { + type: 'orgUnit', + aggregation: { categoryDisplayType: 'PERCENTAGE' }, + }, + }, + }, + }) + ) + const percentRow1 = percentResult.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(percentRow1, 'facility1_group1').value).toBe(50) + expect(findCell(percentRow1, 'facility1_group2').value).toBe(50) + }) + + test('a row with 0 matched features: both category columns are null, not NaN/0', () => { + const layer = facilityLayer([]) + + const { result } = renderHook(() => + useCombinedTableData({ + layers: [layer], + referenceLayer, + joinConfig: { + layers: { facility1: { type: 'orgUnit', aggregation: {} } }, + }, + }) + ) + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'facility1_group1').value).toBe(null) + expect(findCell(row1, 'facility1_group2').value).toBe(null) + }) + + test('an Event count-only column reflects raw matches.length for a matched row, and null for an unmatched row', () => { + const layer = { + id: 'events1', + name: 'Events', + combinedLayerKey: 'events1', + layer: EVENT_LAYER, + legend: { items: [{ name: 'Event' }] }, + data: [ + feature({ id: 'e1', orgUnitPath: '/country1/ou1' }), + feature({ id: 'e2', orgUnitPath: '/country1/ou1' }), + ], + } + + const { result } = renderHook(() => + useCombinedTableData({ + layers: [layer], + referenceLayer, + joinConfig: { + layers: { events1: { type: 'orgUnit', aggregation: {} } }, + }, + }) + ) + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + const row2 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou2' + ) + expect(findCell(row1, 'events1_count').value).toBe(2) + expect(findCell(row2, 'events1_count').value).toBe(null) + }) + + test("a numeric Event value column ignores 'Not set' features instead of corrupting the aggregation - styleByNumeric (styleByDataItem.js) stamps that literal string on no-data features when 'No data' styling is enabled", () => { + const layer = { + id: 'events1', + name: 'Events', + combinedLayerKey: 'events1', + layer: EVENT_LAYER, + styleDataItem: { id: 'de1', valueType: 'NUMBER' }, + legend: { items: [{ name: 'Low' }, { name: 'High' }] }, + data: [ + feature({ id: 'e1', orgUnitPath: '/country1/ou1', value: 10 }), + feature({ + id: 'e2', + orgUnitPath: '/country1/ou1', + value: 'Not set', + }), + ], + } + + const { result } = renderHook(() => + useCombinedTableData({ + layers: [layer], + referenceLayer, + joinConfig: { + layers: { + events1: { + type: 'orgUnit', + aggregation: { value: 'SUM' }, + }, + }, + }, + }) + ) + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'events1_value').value).toBe(10) + }) + + test('header name for a count-only Facility column', () => { + const layer = { + id: 'facility1', + name: 'My Facilities', + combinedLayerKey: 'facility1', + layer: FACILITY_LAYER, + legend: { items: [{ name: 'Facility' }] }, + data: [], + } + + const { result } = renderHook(() => + useCombinedTableData({ + layers: [layer], + referenceLayer, + joinConfig: { + layers: { facility1: { type: 'orgUnit', aggregation: {} } }, + }, + }) + ) + const header = result.current.headers.find( + (h) => h.dataKey === 'facility1_count' + ) + expect(header.name).toBe('Count (My Facilities)') + }) + + test('header name for a category column: count mode vs percentage mode, including rounding', () => { + const layer = facilityLayer([]) + + const { result: countResult } = renderHook(() => + useCombinedTableData({ + layers: [layer], + referenceLayer, + joinConfig: { + layers: { facility1: { type: 'orgUnit', aggregation: {} } }, + }, + }) + ) + const countHeader = countResult.current.headers.find( + (h) => h.dataKey === 'facility1_group1' + ) + expect(countHeader.name).toBe('Hospital (count) (Facilities)') + expect(countHeader.roundFn).toBeUndefined() + + const { result: percentResult } = renderHook(() => + useCombinedTableData({ + layers: [layer], + referenceLayer, + joinConfig: { + layers: { + facility1: { + type: 'orgUnit', + aggregation: { categoryDisplayType: 'PERCENTAGE' }, + }, + }, + }, + }) + ) + const percentHeader = percentResult.current.headers.find( + (h) => h.dataKey === 'facility1_group1' + ) + expect(percentHeader.name).toBe('Hospital (%) (Facilities)') + expect(percentHeader.roundFn(33.456)).toBe(33.5) + }) +}) diff --git a/src/components/datatable/useCombinedTableData.js b/src/components/datatable/useCombinedTableData.js new file mode 100644 index 0000000000..ade47a68ba --- /dev/null +++ b/src/components/datatable/useCombinedTableData.js @@ -0,0 +1,489 @@ +import i18n from '@dhis2/d2-i18n' +import { useMemo } from 'react' +import { + DATA_KEY_KIND_CATEGORY, + DATA_KEY_KIND_COUNT, + ORG_UNIT_LEVEL_DATA_KEY, + ORG_UNIT_PATH_DATA_KEY, + RENDERER_ORG_UNIT, + SORT_ASCENDING, + TYPE_NUMBER, + TYPE_ORG_UNIT, + TYPE_STRING, +} from '../../constants/dataTable.js' +import { EARTH_ENGINE_LAYER } from '../../constants/layers.js' +import { + SELECTION_FILTER_SELECTED, + SELECTION_FILTER_NOT_SELECTED, +} from '../../constants/selection.js' +import useOrgUnitAncestorNames from '../../hooks/useOrgUnitAncestorNames.js' +import { applyAggregation } from '../../util/aggregation.js' +import { + getByReferenceId, + getFilteredJoinableFeatures, + getJoinableFeatures, + getProps, +} from '../../util/combinedJoinMatch.js' +import { + CATEGORY_DISPLAY_TYPE_KEY, + getCombinedLegendConfig, + getCombinedValueDataKeys, + getDefaultCombinedAggregation, + getFeatureCategoryKey, +} from '../../util/dataTable.js' +import { filterByGlobalSearch, filterData } from '../../util/filter.js' +import { isFeatureInBounds } from '../../util/geojson.js' +import { getRoundToPrecisionFn } from '../../util/numbers.js' +import { + buildRowCells, + getColumnDistinctValues, + sortColumnOptions, +} from '../../util/tableColumns.js' +import { compareRows } from '../../util/tableSort.js' + +const LEGEND_KEY = 'legend' +const LARGE_FEATURE_THRESHOLD = 10000 +const EMPTY_AGGREGATIONS = {} + +// Number(null) is 0, not NaN - must exclude nullish before coercing. +const toFiniteNumber = (value) => { + if (value == null) { + return undefined + } + const num = Number(value) + return Number.isFinite(num) ? num : undefined +} + +const mergeAggregations = (layer, aggregationsForLayer) => { + if (layer.layer !== EARTH_ENGINE_LAYER || !aggregationsForLayer) { + return layer + } + const mergeFeature = (feature) => ({ + ...feature, + properties: { + ...getProps(feature), + ...aggregationsForLayer[feature.id ?? getProps(feature).id], + }, + }) + return { + ...layer, + data: layer.data?.map(mergeFeature), + dataWithoutCoords: layer.dataWithoutCoords?.map(mergeFeature), + } +} + +const finalizeRows = ( + flatRows, + headers, + { + filters, + globalSearch, + sortField, + sortDirection, + selectionFilter, + selectedIdSet, + keyAnalysisDigitGroupSeparator, + orgUnitRenderer, + orgUnitIdToName, + } +) => { + let data = filterData(flatRows, filters) + + if (globalSearch?.trim()) { + data = filterByGlobalSearch(data, globalSearch, { + headers, + keyAnalysisDigitGroupSeparator, + }) + } + + if (selectionFilter?.length) { + const wantSelected = selectionFilter.includes(SELECTION_FILTER_SELECTED) + const wantNotSelected = selectionFilter.includes( + SELECTION_FILTER_NOT_SELECTED + ) + if (wantSelected !== wantNotSelected) { + data = data.filter( + (item) => !!selectedIdSet?.has(item.id) === wantSelected + ) + } + } + + data = [...data].sort((a, b) => + compareRows(a, b, { + sortField, + sortDirection, + orgUnitRenderer, + idToName: orgUnitIdToName, + }) + ) + + return data.map((row) => buildRowCells(row, headers)) +} + +const applyLayerMatchToRow = ({ row, featureIds, refProps }, layerMatch) => { + const { layer, settings, byReferenceId, valueDataKeys, legendConfig } = + layerMatch + const matches = byReferenceId.get(refProps.id) ?? [] + + valueDataKeys.forEach(({ dataKey, kind, periodId, settingsKey }) => { + const rowKey = `${layer.combinedLayerKey}_${dataKey}` + + if (kind === DATA_KEY_KIND_COUNT) { + row[rowKey] = matches.length || null + return + } + + if (kind === DATA_KEY_KIND_CATEGORY) { + if (!matches.length) { + row[rowKey] = null + return + } + const displayType = + settings.aggregation?.[CATEGORY_DISPLAY_TYPE_KEY] ?? + getDefaultCombinedAggregation(layer)[CATEGORY_DISPLAY_TYPE_KEY] + const inCategory = matches.filter( + (p) => getFeatureCategoryKey(layer, p) === dataKey + ).length + row[rowKey] = + displayType === 'PERCENTAGE' + ? (inCategory / matches.length) * 100 + : inCategory + return + } + + const effectiveType = + settings.aggregation?.[settingsKey ?? dataKey] ?? + getDefaultCombinedAggregation(layer)[dataKey] + const values = ( + periodId != null + ? matches.map( + (p) => layer.valuesByPeriod?.[periodId]?.[p.id]?.value + ) + : matches.map((p) => p[dataKey]) + ) + .map(toFiniteNumber) + .filter((v) => v !== undefined) + row[rowKey] = applyAggregation(effectiveType, values) + }) + + if (legendConfig) { + const legends = matches + .map((p) => + legendConfig.periodId != null + ? layer.valuesByPeriod?.[legendConfig.periodId]?.[p.id] + ?.legend + : p[LEGEND_KEY] + ) + .filter((v) => v != null) + row[`${layer.combinedLayerKey}_${LEGEND_KEY}`] = + legends.length && legends.every((l) => l === legends[0]) + ? legends[0] + : null + } + + const ids = matches.map((p) => p.id).filter((id) => id != null) + if (ids.length) { + featureIds[layer.id] = ids + } +} + +const getValueDataKeyHeader = ( + layer, + settings, + { dataKey, name, kind, defaultHidden, periodName, isCurrentPeriod } +) => { + const rowKey = `${layer.combinedLayerKey}_${dataKey}` + + if (kind === DATA_KEY_KIND_COUNT) { + return { + name: i18n.t('Count ({{layer}})', { layer: layer.name }), + dataKey: rowKey, + type: TYPE_NUMBER, + defaultHidden, + } + } + + if (kind === DATA_KEY_KIND_CATEGORY) { + const displayType = + settings.aggregation?.[CATEGORY_DISPLAY_TYPE_KEY] ?? + getDefaultCombinedAggregation(layer)[CATEGORY_DISPLAY_TYPE_KEY] + const isPercentage = displayType === 'PERCENTAGE' + return { + name: i18n.t('{{name}} ({{unit}}) ({{layer}})', { + name, + unit: isPercentage ? i18n.t('%') : i18n.t('count'), + layer: layer.name, + }), + dataKey: rowKey, + type: TYPE_NUMBER, + defaultHidden, + ...(isPercentage ? { roundFn: getRoundToPrecisionFn(1) } : {}), + } + } + + if (periodName !== undefined || isCurrentPeriod) { + return { + name: i18n.t('Value ({{layer}}, {{period}})', { + layer: layer.name, + period: periodName ?? i18n.t('Current period'), + }), + dataKey: rowKey, + type: TYPE_NUMBER, + defaultHidden, + ...(isCurrentPeriod && { + configName: i18n.t('Value ({{layer}}, {{period}})', { + layer: layer.name, + period: i18n.t('Current period'), + }), + }), + } + } + + return { + name: name + ? i18n.t('{{name}} ({{layer}})', { name, layer: layer.name }) + : i18n.t('Value ({{layer}})', { layer: layer.name }), + dataKey: rowKey, + type: TYPE_NUMBER, + defaultHidden, + } +} + +const getLegendHeader = (layer, { periodName, isCurrentPeriod }) => { + const dataKey = `${layer.combinedLayerKey}_${LEGEND_KEY}` + if (!isCurrentPeriod) { + return { + name: i18n.t('Legend ({{layer}})', { layer: layer.name }), + dataKey, + type: TYPE_STRING, + defaultHidden: true, + } + } + return { + name: i18n.t('Legend ({{layer}}, {{period}})', { + layer: layer.name, + period: periodName ?? i18n.t('Current period'), + }), + configName: i18n.t('Legend ({{layer}}, {{period}})', { + layer: layer.name, + period: i18n.t('Current period'), + }), + dataKey, + type: TYPE_STRING, + defaultHidden: true, + } +} + +const EMPTY_COLUMN_OPTIONS = {} +const EMPTY_HEADERS = [] +const EMPTY_ORG_UNIT_ID_TO_NAME = new Map() + +const EMPTY_RESULT = { + headers: EMPTY_HEADERS, + rows: [], + rowFeatureIds: new Map(), + columnOptions: EMPTY_COLUMN_OPTIONS, + spatialWarning: false, + orgUnitIdToName: EMPTY_ORG_UNIT_ID_TO_NAME, +} + +export const useCombinedTableData = ({ + layers, + referenceLayer, + joinConfig, + sortField = null, + sortDirection = SORT_ASCENDING, + filters, + globalSearch, + aggregations: allAggregations = EMPTY_AGGREGATIONS, + showOnlyFeaturesInView = false, + mapBounds, + selectionFilter, + selectedIdSet, + keyAnalysisDigitGroupSeparator, + externalPeriod, +}) => { + const referenceOrgUnits = useMemo( + () => getJoinableFeatures(referenceLayer), + [referenceLayer] + ) + + const visibleReferenceOrgUnits = useMemo( + () => + showOnlyFeaturesInView && mapBounds + ? referenceOrgUnits.filter((f) => + isFeatureInBounds(f, mapBounds) + ) + : referenceOrgUnits, + [referenceOrgUnits, showOnlyFeaturesInView, mapBounds] + ) + + const orgUnitPathValues = useMemo( + () => + visibleReferenceOrgUnits.map( + (f) => getProps(f)[ORG_UNIT_PATH_DATA_KEY] + ), + [visibleReferenceOrgUnits] + ) + const { idToName: orgUnitIdToName } = + useOrgUnitAncestorNames(orgUnitPathValues) + + const layerMatches = useMemo( + () => + layers.map((layer) => { + const settings = joinConfig.layers[layer.combinedLayerKey] ?? { + type: 'orgUnit', + aggregation: {}, + } + const mergedLayer = mergeAggregations( + layer, + allAggregations[layer.id] ?? EMPTY_AGGREGATIONS + ) + const features = getFilteredJoinableFeatures(mergedLayer) + const valueDataKeys = getCombinedValueDataKeys( + layer, + externalPeriod + ) + const legendConfig = getCombinedLegendConfig( + layer, + externalPeriod + ) + const byReferenceId = getByReferenceId( + features, + referenceOrgUnits, + settings.type + ) + return { + layer, + settings, + byReferenceId, + valueDataKeys, + legendConfig, + } + }), + [layers, joinConfig, referenceOrgUnits, allAggregations, externalPeriod] + ) + + const headers = useMemo(() => { + if (!referenceOrgUnits.length) { + return EMPTY_HEADERS + } + return [ + { + name: i18n.t('Org unit id'), + dataKey: 'id', + type: TYPE_STRING, + defaultHidden: true, + }, + { name: i18n.t('Org unit'), dataKey: 'name', type: TYPE_STRING }, + { + name: i18n.t('Org unit level'), + dataKey: 'level', + type: TYPE_NUMBER, + defaultHidden: true, + }, + { + name: i18n.t('Org unit hierarchy'), + dataKey: ORG_UNIT_PATH_DATA_KEY, + type: TYPE_ORG_UNIT, + renderer: RENDERER_ORG_UNIT, + }, + ...layerMatches.flatMap( + ({ layer, settings, valueDataKeys, legendConfig }) => [ + ...valueDataKeys.map((valueDataKey) => + getValueDataKeyHeader(layer, settings, valueDataKey) + ), + ...(legendConfig + ? [getLegendHeader(layer, legendConfig)] + : []), + ] + ), + ] + }, [referenceOrgUnits, layerMatches]) + + return useMemo(() => { + if (!referenceOrgUnits.length) { + return EMPTY_RESULT + } + + const spatialWarning = + referenceOrgUnits.length > LARGE_FEATURE_THRESHOLD || + layerMatches.some( + ({ layer, settings }) => + settings.type === 'spatial' && + (layer.data?.length ?? 0) > LARGE_FEATURE_THRESHOLD + ) + + const rowFeatureIds = new Map() + + const flatRows = visibleReferenceOrgUnits.map( + (referenceFeature, index) => { + const refProps = getProps(referenceFeature) + const row = { + id: refProps.id, + name: refProps.name ?? null, + level: refProps[ORG_UNIT_LEVEL_DATA_KEY] ?? null, + [ORG_UNIT_PATH_DATA_KEY]: + refProps[ORG_UNIT_PATH_DATA_KEY] ?? null, + index, + } + + const featureIds = { [referenceLayer.id]: [refProps.id] } + + layerMatches.forEach((layerMatch) => + applyLayerMatchToRow( + { row, featureIds, refProps }, + layerMatch + ) + ) + + rowFeatureIds.set(refProps.id, featureIds) + return row + } + ) + + const sortFieldRenderer = headers.find( + (h) => h.dataKey === sortField + )?.renderer + + const rows = finalizeRows(flatRows, headers, { + filters, + globalSearch, + sortField, + sortDirection, + selectionFilter, + selectedIdSet, + keyAnalysisDigitGroupSeparator, + orgUnitRenderer: sortFieldRenderer, + orgUnitIdToName, + }) + const columnOptions = + sortColumnOptions(getColumnDistinctValues(headers, flatRows), { + sortField, + sortDirection, + }) ?? EMPTY_COLUMN_OPTIONS + + return { + headers, + rows, + rowFeatureIds, + columnOptions, + spatialWarning, + orgUnitIdToName, + } + }, [ + referenceOrgUnits, + visibleReferenceOrgUnits, + referenceLayer, + layerMatches, + headers, + filters, + globalSearch, + sortField, + sortDirection, + selectionFilter, + selectedIdSet, + keyAnalysisDigitGroupSeparator, + orgUnitIdToName, + ]) +} diff --git a/src/constants/dataTable.js b/src/constants/dataTable.js index ea8e695587..ae2251a0ed 100644 --- a/src/constants/dataTable.js +++ b/src/constants/dataTable.js @@ -26,3 +26,9 @@ export const ORG_UNIT_PATH_DATA_KEY = 'orgUnitPath' export const ORG_UNIT_DATA_KEY = 'orgUnitOwn' export const ORG_UNIT_ID_DATA_KEY = 'orgUnitId' export const ORG_UNIT_LEVEL_DATA_KEY = 'level' + +export const COMBINED_HEADERS_KEY = '__combined__' + +export const DATA_KEY_KIND_VALUE = 'value' +export const DATA_KEY_KIND_COUNT = 'count' +export const DATA_KEY_KIND_CATEGORY = 'category' diff --git a/src/util/__tests__/aggregation.spec.js b/src/util/__tests__/aggregation.spec.js new file mode 100644 index 0000000000..6c99375624 --- /dev/null +++ b/src/util/__tests__/aggregation.spec.js @@ -0,0 +1,125 @@ +import { + applyAggregation, + getDefaultCombinedAggregationType, + getDefaultCombinedAggregationTypeFromEarthEngineStat, +} from '../aggregation.js' + +describe('applyAggregation', () => { + test('returns null for an empty input (no matching feature)', () => { + expect(applyAggregation('SUM', [])).toBeNull() + }) + + test('SUM', () => { + expect(applyAggregation('SUM', [1, 2, 3])).toBe(6) + }) + + test('AVERAGE', () => { + expect(applyAggregation('AVERAGE', [1, 2, 3])).toBe(2) + }) + + test('COUNT', () => { + expect(applyAggregation('COUNT', [10, 20, 30, 40])).toBe(4) + }) + + test('MIN', () => { + expect(applyAggregation('MIN', [5, 1, 9])).toBe(1) + }) + + test('MAX', () => { + expect(applyAggregation('MAX', [5, 1, 9])).toBe(9) + }) + + test('VARIANCE', () => { + expect(applyAggregation('VARIANCE', [2, 4, 4, 4, 5, 5, 7, 9])).toBe(4) + }) + + test('STDDEV', () => { + expect(applyAggregation('STDDEV', [2, 4, 4, 4, 5, 5, 7, 9])).toBe(2) + }) + + test('a single value aggregates to itself regardless of type', () => { + expect(applyAggregation('SUM', [42])).toBe(42) + expect(applyAggregation('AVERAGE', [42])).toBe(42) + expect(applyAggregation('MIN', [42])).toBe(42) + expect(applyAggregation('MAX', [42])).toBe(42) + }) + + test('returns null for an unknown aggregation type', () => { + expect(applyAggregation('NOT_A_TYPE', [1, 2, 3])).toBeNull() + }) +}) + +describe('getDefaultCombinedAggregationType', () => { + test('maps directly for types with a 1:1 equivalent', () => { + expect(getDefaultCombinedAggregationType('SUM')).toBe('SUM') + expect(getDefaultCombinedAggregationType('AVERAGE')).toBe('AVERAGE') + expect(getDefaultCombinedAggregationType('COUNT')).toBe('COUNT') + expect(getDefaultCombinedAggregationType('MIN')).toBe('MIN') + expect(getDefaultCombinedAggregationType('MAX')).toBe('MAX') + expect(getDefaultCombinedAggregationType('STDDEV')).toBe('STDDEV') + expect(getDefaultCombinedAggregationType('VARIANCE')).toBe('VARIANCE') + }) + + test('maps AVERAGE_SUM_ORG_UNIT to SUM - Combined only ever rolls up across org units', () => { + expect(getDefaultCombinedAggregationType('AVERAGE_SUM_ORG_UNIT')).toBe( + 'SUM' + ) + }) + + test('falls back to SUM for NONE, CUSTOM, unrecognized, or missing types', () => { + expect(getDefaultCombinedAggregationType('NONE')).toBe('SUM') + expect(getDefaultCombinedAggregationType('CUSTOM')).toBe('SUM') + expect(getDefaultCombinedAggregationType('NOT_A_TYPE')).toBe('SUM') + expect(getDefaultCombinedAggregationType(undefined)).toBe('SUM') + }) + + test('defaults a plain Indicator to AVERAGE regardless of aggregationType - it has none of its own, and its value is a ratio not meaningfully summed across org units', () => { + expect(getDefaultCombinedAggregationType(undefined, 'INDICATOR')).toBe( + 'AVERAGE' + ) + expect(getDefaultCombinedAggregationType('SUM', 'INDICATOR')).toBe( + 'AVERAGE' + ) + }) + + test('defaults Reporting rate to AVERAGE - same reasoning as Indicators, it has no aggregationType of its own and its value is always a ratio', () => { + expect( + getDefaultCombinedAggregationType(undefined, 'REPORTING_RATE') + ).toBe('AVERAGE') + }) +}) + +describe('getDefaultCombinedAggregationTypeFromEarthEngineStat', () => { + test('maps each Earth Engine stat id to its equivalent', () => { + expect( + getDefaultCombinedAggregationTypeFromEarthEngineStat('count') + ).toBe('COUNT') + expect( + getDefaultCombinedAggregationTypeFromEarthEngineStat('min') + ).toBe('MIN') + expect( + getDefaultCombinedAggregationTypeFromEarthEngineStat('max') + ).toBe('MAX') + expect( + getDefaultCombinedAggregationTypeFromEarthEngineStat('mean') + ).toBe('AVERAGE') + expect( + getDefaultCombinedAggregationTypeFromEarthEngineStat('sum') + ).toBe('SUM') + expect( + getDefaultCombinedAggregationTypeFromEarthEngineStat('stdDev') + ).toBe('STDDEV') + expect( + getDefaultCombinedAggregationTypeFromEarthEngineStat('variance') + ).toBe('VARIANCE') + }) + + test('falls back to SUM for median (no equivalent reducer) or an unrecognized stat', () => { + expect( + getDefaultCombinedAggregationTypeFromEarthEngineStat('median') + ).toBe('SUM') + expect( + getDefaultCombinedAggregationTypeFromEarthEngineStat('not-a-stat') + ).toBe('SUM') + }) +}) diff --git a/src/util/__tests__/dataTable.spec.js b/src/util/__tests__/dataTable.spec.js index 9e5fbf0f31..41e6ca81bf 100644 --- a/src/util/__tests__/dataTable.spec.js +++ b/src/util/__tests__/dataTable.spec.js @@ -1,3 +1,8 @@ +import { + DATA_KEY_KIND_CATEGORY, + DATA_KEY_KIND_COUNT, + DATA_KEY_KIND_VALUE, +} from '../../constants/dataTable.js' import { EXTERNAL_LAYER, THEMATIC_LAYER, @@ -9,16 +14,22 @@ import { } from '../../constants/layers.js' import { buildFeatureIndex, + getCombinedLegendConfig, + getCombinedValueDataKeys, + getDefaultCombinedAggregation, getDefaultReferenceRows, getEligibleDataTableLayers, + getFeatureCategoryKey, getLayerSelectedIds, getNextSorting, getPanelHeights, getRowClickAction, getRowId, + getUnionBounds, hasActiveDataTableFilters, isDataTableOpen, isFilterable, + mergeCrossLayerIds, shouldClearFeatureHighlight, } from '../dataTable.js' @@ -281,24 +292,37 @@ describe('isDataTableOpen', () => { expect( isDataTableOpen({ openIds: ['layer1'], + combinedView: false, + isPanelVisible: true, + }) + ).toBe(true) + }) + + test('is open when Combined is active and the panel is visible, even with no open tabs', () => { + expect( + isDataTableOpen({ + openIds: [], + combinedView: true, isPanelVisible: true, }) ).toBe(true) }) - test('is closed when there are no open tabs', () => { + test('is closed when there are no open tabs and Combined is not active', () => { expect( isDataTableOpen({ openIds: [], + combinedView: false, isPanelVisible: true, }) ).toBe(false) }) - test('is closed when the panel is hidden, even with open tabs', () => { + test('is closed when the panel is hidden, even with open tabs or Combined active', () => { expect( isDataTableOpen({ openIds: ['layer1'], + combinedView: true, isPanelVisible: false, }) ).toBe(false) @@ -327,6 +351,41 @@ describe('getLayerSelectedIds', () => { ) ).toEqual([]) }) + + test('returns crossLayerIds ids when selection.layerId belongs to no single layer (Combined)', () => { + expect( + getLayerSelectedIds( + { layerId: null, ids: [], crossLayerIds: { layer1: ['x'] } }, + 'layer1' + ) + ).toEqual(['x']) + }) + + test('merges crossLayerIds with a same-layer selection, deduping', () => { + expect( + getLayerSelectedIds( + { + layerId: 'layer1', + ids: ['a'], + crossLayerIds: { layer1: ['a', 'b'] }, + }, + 'layer1' + ) + ).toEqual(['a', 'b']) + }) + + test('ignores a selection/crossLayerIds entry belonging to another layer', () => { + expect( + getLayerSelectedIds( + { + layerId: 'other-layer', + ids: ['a'], + crossLayerIds: { 'other-layer': ['a'] }, + }, + 'layer1' + ) + ).toEqual([]) + }) }) describe('getPanelHeights', () => { @@ -450,3 +509,821 @@ describe('getDefaultReferenceRows', () => { ) }) }) + +const withDataItem = (aggregationType) => ({ + layer: THEMATIC_LAYER, + columns: [ + { + dimension: 'dx', + items: [{ id: 'de1', name: 'DE 1', aggregationType }], + }, + ], +}) + +describe('getCombinedValueDataKeys', () => { + test('returns a single generic rawValue column for any non-Earth-Engine layer', () => { + expect(getCombinedValueDataKeys({ layer: THEMATIC_LAYER })).toEqual([ + { dataKey: 'rawValue', name: null, kind: DATA_KEY_KIND_VALUE }, + ]) + }) + + test('single-period thematic layer (no renderingStrategy set) still returns the single generic rawValue column', () => { + expect( + getCombinedValueDataKeys({ + layer: THEMATIC_LAYER, + renderingStrategy: 'SINGLE', + }) + ).toEqual([ + { dataKey: 'rawValue', name: null, kind: DATA_KEY_KIND_VALUE }, + ]) + }) + + test('TIMELINE thematic layer: returns a current column plus one fixed column per period', () => { + const externalPeriod = { id: 'p1', name: 'Jan' } + const periods = [ + { id: 'p1', name: 'Jan' }, + { id: 'p2', name: 'Feb' }, + ] + expect( + getCombinedValueDataKeys( + { + layer: THEMATIC_LAYER, + renderingStrategy: 'TIMELINE', + periods, + }, + externalPeriod + ) + ).toEqual([ + { + dataKey: 'rawValue', + name: null, + kind: DATA_KEY_KIND_VALUE, + periodId: 'p1', + periodName: 'Jan', + isCurrentPeriod: true, + settingsKey: 'rawValue', + }, + { + dataKey: 'period_p1_rawValue', + name: null, + kind: DATA_KEY_KIND_VALUE, + periodId: 'p1', + periodName: 'Jan', + settingsKey: 'rawValue', + defaultHidden: true, + }, + { + dataKey: 'period_p2_rawValue', + name: null, + kind: DATA_KEY_KIND_VALUE, + periodId: 'p2', + periodName: 'Feb', + settingsKey: 'rawValue', + defaultHidden: true, + }, + ]) + }) + + test('SPLIT_BY_PERIOD thematic layer: returns only fixed per-period columns, no current column', () => { + const periods = [{ id: 'p1', name: 'Jan' }] + expect( + getCombinedValueDataKeys({ + layer: THEMATIC_LAYER, + renderingStrategy: 'SPLIT_BY_PERIOD', + periods, + }) + ).toEqual([ + { + dataKey: 'period_p1_rawValue', + name: null, + kind: DATA_KEY_KIND_VALUE, + periodId: 'p1', + periodName: 'Jan', + settingsKey: 'rawValue', + defaultHidden: true, + }, + ]) + }) + + test('returns one column per aggregation stat when aggregationType is an array', () => { + expect( + getCombinedValueDataKeys({ + layer: EARTH_ENGINE_LAYER, + aggregationType: ['mean', 'max'], + legend: { title: 'NDVI' }, + }) + ).toEqual([ + { dataKey: 'mean', name: 'Mean Ndvi', kind: DATA_KEY_KIND_VALUE }, + { dataKey: 'max', name: 'Max Ndvi', kind: DATA_KEY_KIND_VALUE }, + ]) + }) + + test('returns one column per legend class when aggregationType is classified', () => { + expect( + getCombinedValueDataKeys({ + layer: EARTH_ENGINE_LAYER, + aggregationType: 'percentage', + legend: { + items: [ + { value: 1, name: 'Forest' }, + { value: 2, name: 'Water' }, + ], + }, + }) + ).toEqual([ + { dataKey: '1', name: 'Forest', kind: DATA_KEY_KIND_VALUE }, + { dataKey: '2', name: 'Water', kind: DATA_KEY_KIND_VALUE }, + ]) + }) + + test('returns no columns for an Earth Engine layer with neither shape configured yet', () => { + expect( + getCombinedValueDataKeys({ + layer: EARTH_ENGINE_LAYER, + aggregationType: null, + legend: {}, + }) + ).toEqual([]) + }) + + // Real band ids/names from src/constants/earthEngineLayers/population_age_sex_Worldpop-Global2.js + const populationBands = { + multiple: true, + list: [ + { id: 'm_00', name: 'Male 0 - 1 years' }, + { id: 'f_00', name: 'Female 0 - 1 years' }, + ], + } + + test('only 1 band selected: no extra band columns', () => { + expect( + getCombinedValueDataKeys({ + layer: EARTH_ENGINE_LAYER, + aggregationType: ['sum', 'mean'], + legend: { title: 'Population' }, + bands: populationBands, + band: ['m_00'], + }) + ).toEqual([ + { + dataKey: 'sum', + name: 'Sum Population', + kind: DATA_KEY_KIND_VALUE, + }, + { + dataKey: 'mean', + name: 'Mean Population', + kind: DATA_KEY_KIND_VALUE, + }, + ]) + }) + + test('2+ bands, exactly 1 stat: one bare-band-id column per band, hidden by default', () => { + expect( + getCombinedValueDataKeys({ + layer: EARTH_ENGINE_LAYER, + aggregationType: ['sum'], + legend: { title: 'Population' }, + bands: populationBands, + band: ['m_00', 'f_00'], + }) + ).toEqual([ + { + dataKey: 'sum', + name: 'Sum Population', + kind: DATA_KEY_KIND_VALUE, + }, + { + dataKey: 'm_00', + name: 'Male 0 - 1 years', + kind: DATA_KEY_KIND_VALUE, + defaultHidden: true, + }, + { + dataKey: 'f_00', + name: 'Female 0 - 1 years', + kind: DATA_KEY_KIND_VALUE, + defaultHidden: true, + }, + ]) + }) + + test('2+ bands, 2+ stats: one title-cased ${band}_${type} column per band per stat, hidden by default', () => { + expect( + getCombinedValueDataKeys({ + layer: EARTH_ENGINE_LAYER, + aggregationType: ['sum', 'mean'], + legend: { title: 'Population' }, + bands: populationBands, + band: ['m_00', 'f_00'], + }) + ).toEqual([ + { + dataKey: 'sum', + name: 'Sum Population', + kind: DATA_KEY_KIND_VALUE, + }, + { + dataKey: 'mean', + name: 'Mean Population', + kind: DATA_KEY_KIND_VALUE, + }, + { + dataKey: 'm_00_sum', + name: 'Sum Male 0 - 1 Years', + kind: DATA_KEY_KIND_VALUE, + defaultHidden: true, + }, + { + dataKey: 'm_00_mean', + name: 'Mean Male 0 - 1 Years', + kind: DATA_KEY_KIND_VALUE, + defaultHidden: true, + }, + { + dataKey: 'f_00_sum', + name: 'Sum Female 0 - 1 Years', + kind: DATA_KEY_KIND_VALUE, + defaultHidden: true, + }, + { + dataKey: 'f_00_mean', + name: 'Mean Female 0 - 1 Years', + kind: DATA_KEY_KIND_VALUE, + defaultHidden: true, + }, + ]) + }) + + test('no bands config at all: unaffected, same as an ordinary non-multi-band EE layer', () => { + expect( + getCombinedValueDataKeys({ + layer: EARTH_ENGINE_LAYER, + aggregationType: ['mean', 'max'], + legend: { title: 'NDVI' }, + }) + ).toEqual([ + { dataKey: 'mean', name: 'Mean Ndvi', kind: DATA_KEY_KIND_VALUE }, + { dataKey: 'max', name: 'Max Ndvi', kind: DATA_KEY_KIND_VALUE }, + ]) + }) + + describe('Facility/OrgUnit group-set categorical columns', () => { + const groupedLayer = (layer, items) => ({ + layer, + organisationUnitGroupSet: { id: 'groupSet1' }, + legend: { items }, + }) + + test('grouped Facility with 2+ groups: one category column per group, keyed by id', () => { + expect( + getCombinedValueDataKeys( + groupedLayer(FACILITY_LAYER, [ + { id: 'group1', name: 'Hospital' }, + { id: 'group2', name: 'Clinic' }, + ]) + ) + ).toEqual([ + { + dataKey: 'group1', + name: 'Hospital', + kind: DATA_KEY_KIND_CATEGORY, + }, + { + dataKey: 'group2', + name: 'Clinic', + kind: DATA_KEY_KIND_CATEGORY, + }, + ]) + }) + + test('grouped OrgUnit with an Unclassified item present: keyed as the unclassified sentinel', () => { + expect( + getCombinedValueDataKeys( + groupedLayer(ORG_UNIT_LAYER, [ + { id: 'group1', name: 'Hospital' }, + { name: 'Unclassified' }, + ]) + ) + ).toEqual([ + { + dataKey: 'group1', + name: 'Hospital', + kind: DATA_KEY_KIND_CATEGORY, + }, + { + dataKey: 'unclassified', + name: 'Unclassified', + kind: DATA_KEY_KIND_CATEGORY, + }, + ]) + }) + + test('grouped with exactly 1 group: falls back to count-only', () => { + expect( + getCombinedValueDataKeys( + groupedLayer(FACILITY_LAYER, [ + { id: 'group1', name: 'Hospital' }, + ]) + ) + ).toEqual([ + { dataKey: 'count', name: null, kind: DATA_KEY_KIND_COUNT }, + ]) + }) + + test('ungrouped Facility: count-only', () => { + expect( + getCombinedValueDataKeys({ + layer: FACILITY_LAYER, + legend: { items: [{ name: 'Facility' }] }, + }) + ).toEqual([ + { dataKey: 'count', name: null, kind: DATA_KEY_KIND_COUNT }, + ]) + }) + + test('ungrouped OrgUnit with a level-fallback legend (items.length > 1, no group set): count-only, not one column per level', () => { + expect( + getCombinedValueDataKeys({ + layer: ORG_UNIT_LAYER, + legend: { + items: [ + { name: 'Level 1' }, + { name: 'Level 2' }, + { name: 'Level 3' }, + ], + }, + }) + ).toEqual([ + { dataKey: 'count', name: null, kind: DATA_KEY_KIND_COUNT }, + ]) + }) + }) +}) + +describe('getCombinedLegendConfig', () => { + test('Earth Engine layer: no legend column', () => { + expect( + getCombinedLegendConfig({ layer: EARTH_ENGINE_LAYER }) + ).toBeNull() + }) + + test('single-period thematic layer: generic legend, read straight off feature properties', () => { + expect( + getCombinedLegendConfig({ + layer: THEMATIC_LAYER, + renderingStrategy: 'SINGLE', + }) + ).toEqual({ periodId: null, periodName: null, isCurrentPeriod: false }) + }) + + test('non-thematic layer: generic legend, unaffected by renderingStrategy', () => { + expect(getCombinedLegendConfig({ layer: FACILITY_LAYER })).toEqual({ + periodId: null, + periodName: null, + isCurrentPeriod: false, + }) + }) + + test('TIMELINE thematic layer: legend resolved from the current period', () => { + const externalPeriod = { id: 'p1', name: 'Jan' } + expect( + getCombinedLegendConfig( + { layer: THEMATIC_LAYER, renderingStrategy: 'TIMELINE' }, + externalPeriod + ) + ).toEqual({ periodId: 'p1', periodName: 'Jan', isCurrentPeriod: true }) + }) + + test('SPLIT_BY_PERIOD thematic layer: no legend column at all', () => { + expect( + getCombinedLegendConfig({ + layer: THEMATIC_LAYER, + renderingStrategy: 'SPLIT_BY_PERIOD', + }) + ).toBeNull() + }) +}) + +describe('getFeatureCategoryKey - Facility/OrgUnit', () => { + const layer = { + layer: FACILITY_LAYER, + organisationUnitGroupSet: { id: 'groupSet1' }, + } + + test("returns the feature's own group id for the layer's group set dimension", () => { + expect( + getFeatureCategoryKey(layer, { + dimensions: { groupSet1: 'group1' }, + }) + ).toBe('group1') + }) + + test('returns the unclassified sentinel when the feature has no value for that dimension', () => { + expect(getFeatureCategoryKey(layer, { dimensions: {} })).toBe( + 'unclassified' + ) + expect(getFeatureCategoryKey(layer, {})).toBe('unclassified') + }) +}) + +describe('getCombinedValueDataKeys - Event layers', () => { + test('no styleDataItem: count-only', () => { + expect( + getCombinedValueDataKeys({ + layer: EVENT_LAYER, + legend: { items: [{ name: 'Event', colorGroup: 0 }] }, + }) + ).toEqual([{ dataKey: 'count', name: null, kind: DATA_KEY_KIND_COUNT }]) + }) + + test('styleDataItem on a numeric value type: single value-kind entry, even when its own legend has 2+ classification bins', () => { + expect( + getCombinedValueDataKeys({ + layer: EVENT_LAYER, + styleDataItem: { id: 'de1', valueType: 'NUMBER' }, + legend: { + items: [ + { name: 'Low', colorGroup: 0 }, + { name: 'High', colorGroup: 1 }, + ], + }, + }) + ).toEqual([{ dataKey: 'value', name: null, kind: DATA_KEY_KIND_VALUE }]) + }) + + test('styleDataItem on a numeric value type: uses the data item name, matching the categorical/boolean cases', () => { + expect( + getCombinedValueDataKeys({ + layer: EVENT_LAYER, + styleDataItem: { + id: 'de1', + name: 'Weight (kg)', + valueType: 'NUMBER', + }, + }) + ).toEqual([ + { + dataKey: 'value', + name: 'Weight (kg)', + kind: DATA_KEY_KIND_VALUE, + }, + ]) + }) + + test('styleDataItem.optionSet with 3 options: 3 category entries keyed by colorGroup', () => { + expect( + getCombinedValueDataKeys({ + layer: EVENT_LAYER, + styleDataItem: { id: 'de1', optionSet: { id: 'os1' } }, + legend: { + items: [ + { name: 'Option A', colorGroup: 0 }, + { name: 'Option B', colorGroup: 1 }, + { name: 'Option C', colorGroup: 2 }, + ], + }, + }) + ).toEqual([ + { dataKey: '0', name: 'Option A', kind: DATA_KEY_KIND_CATEGORY }, + { dataKey: '1', name: 'Option B', kind: DATA_KEY_KIND_CATEGORY }, + { dataKey: '2', name: 'Option C', kind: DATA_KEY_KIND_CATEGORY }, + ]) + }) + + test('boolean styleDataItem (Yes/No): 2 category entries', () => { + expect( + getCombinedValueDataKeys({ + layer: EVENT_LAYER, + styleDataItem: { id: 'de1', valueType: 'BOOLEAN' }, + legend: { + items: [ + { name: 'Yes', colorGroup: 0 }, + { name: 'No', colorGroup: 1 }, + ], + }, + }) + ).toEqual([ + { dataKey: '0', name: 'Yes', kind: DATA_KEY_KIND_CATEGORY }, + { dataKey: '1', name: 'No', kind: DATA_KEY_KIND_CATEGORY }, + ]) + }) + + test('optionSet + Unclassified/No data legends configured: extra category entries keyed by their own colorGroup, not by name', () => { + expect( + getCombinedValueDataKeys({ + layer: EVENT_LAYER, + styleDataItem: { id: 'de1', optionSet: { id: 'os1' } }, + legend: { + items: [ + { name: 'Option A', colorGroup: 0 }, + { name: 'Unclassified', colorGroup: 1 }, + { name: 'No data', colorGroup: 2 }, + ], + }, + }) + ).toEqual([ + { dataKey: '0', name: 'Option A', kind: DATA_KEY_KIND_CATEGORY }, + { + dataKey: '1', + name: 'Unclassified', + kind: DATA_KEY_KIND_CATEGORY, + }, + { dataKey: '2', name: 'No data', kind: DATA_KEY_KIND_CATEGORY }, + ]) + }) + + test('styleDataItem on a TEXT value type with No data legend configured (2-item legend): 2 category columns, not count-only (resolved carve-out)', () => { + expect( + getCombinedValueDataKeys({ + layer: EVENT_LAYER, + styleDataItem: { id: 'de1', valueType: 'TEXT' }, + legend: { + items: [ + { name: 'Event', colorGroup: 0 }, + { name: 'No data', colorGroup: 1 }, + ], + }, + }) + ).toEqual([ + { dataKey: '0', name: 'Event', kind: DATA_KEY_KIND_CATEGORY }, + { dataKey: '1', name: 'No data', kind: DATA_KEY_KIND_CATEGORY }, + ]) + }) + + test('styleDataItem on a TEXT value type with no No data legend (1-item legend): count-only', () => { + expect( + getCombinedValueDataKeys({ + layer: EVENT_LAYER, + styleDataItem: { id: 'de1', valueType: 'TEXT' }, + legend: { items: [{ name: 'Event', colorGroup: 0 }] }, + }) + ).toEqual([{ dataKey: 'count', name: null, kind: DATA_KEY_KIND_COUNT }]) + }) + + test('single-option optionSet: falls back to count-only', () => { + expect( + getCombinedValueDataKeys({ + layer: EVENT_LAYER, + styleDataItem: { id: 'de1', optionSet: { id: 'os1' } }, + legend: { items: [{ name: 'Option A', colorGroup: 0 }] }, + }) + ).toEqual([{ dataKey: 'count', name: null, kind: DATA_KEY_KIND_COUNT }]) + }) +}) + +describe('getFeatureCategoryKey - Event', () => { + const layer = { layer: EVENT_LAYER } + + test("returns the feature's own colorGroup, stringified", () => { + expect(getFeatureCategoryKey(layer, { colorGroup: 1 })).toBe('1') + }) +}) + +describe('getCombinedValueDataKeys - TrackedEntity layers', () => { + test('always count-only, regardless of legend shape (TE has no classification support today)', () => { + expect( + getCombinedValueDataKeys({ + layer: TRACKED_ENTITY_LAYER, + legend: { items: [{ name: 'Person' }] }, + }) + ).toEqual([{ dataKey: 'count', name: null, kind: DATA_KEY_KIND_COUNT }]) + }) + + test('still count-only even given a multi-item legend, defending against future drift', () => { + expect( + getCombinedValueDataKeys({ + layer: TRACKED_ENTITY_LAYER, + legend: { items: [{ name: 'Type A' }, { name: 'Type B' }] }, + }) + ).toEqual([{ dataKey: 'count', name: null, kind: DATA_KEY_KIND_COUNT }]) + }) +}) +describe('getDefaultCombinedAggregation', () => { + test("defaults to the data item's own aggregation type", () => { + expect(getDefaultCombinedAggregation(withDataItem('AVERAGE'))).toEqual({ + rawValue: 'AVERAGE', + }) + }) + + test('maps AVERAGE_SUM_ORG_UNIT to SUM', () => { + expect( + getDefaultCombinedAggregation(withDataItem('AVERAGE_SUM_ORG_UNIT')) + ).toEqual({ rawValue: 'SUM' }) + }) + + test('falls back to SUM when the data item has no aggregationType', () => { + expect(getDefaultCombinedAggregation(withDataItem(undefined))).toEqual({ + rawValue: 'SUM', + }) + }) + + test('falls back to SUM for a layer with no data item at all (non-Thematic types)', () => { + expect( + getDefaultCombinedAggregation({ layer: EARTH_ENGINE_LAYER }) + ).toEqual({}) + }) + + test('defaults a plain Indicator data item to AVERAGE - it has no aggregationType of its own, and its value is a ratio not meaningfully summed across org units', () => { + expect( + getDefaultCombinedAggregation({ + layer: THEMATIC_LAYER, + columns: [ + { + dimension: 'dx', + items: [ + { + id: 'in1', + name: 'Indicator 1', + dimensionItemType: 'INDICATOR', + }, + ], + }, + ], + }) + ).toEqual({ rawValue: 'AVERAGE' }) + }) + + test("Earth Engine: defaults every stat column to the first selected stat's own equivalent", () => { + expect( + getDefaultCombinedAggregation({ + layer: EARTH_ENGINE_LAYER, + aggregationType: ['mean', 'max'], + legend: { title: 'NDVI' }, + }) + ).toEqual({ mean: 'AVERAGE', max: 'AVERAGE' }) + }) + + test('Earth Engine: classified percentage (e.g. Landcover) defaults to AVERAGE - a relative proportion is not meaningfully summed across differently-sized org units', () => { + expect( + getDefaultCombinedAggregation({ + layer: EARTH_ENGINE_LAYER, + aggregationType: 'percentage', + legend: { items: [{ value: 1, name: 'Forest' }] }, + }) + ).toEqual({ 1: 'AVERAGE' }) + }) + + test('Earth Engine: classified hectares/acres default to SUM - an absolute area is correctly additive across joined org units', () => { + expect( + getDefaultCombinedAggregation({ + layer: EARTH_ENGINE_LAYER, + aggregationType: 'hectares', + legend: { items: [{ value: 1, name: 'Forest' }] }, + }) + ).toEqual({ 1: 'SUM' }) + expect( + getDefaultCombinedAggregation({ + layer: EARTH_ENGINE_LAYER, + aggregationType: 'acres', + legend: { items: [{ value: 1, name: 'Forest' }] }, + }) + ).toEqual({ 1: 'SUM' }) + }) + + test('Earth Engine: per-band columns pick up the same default aggregation type as the main stat columns, with no extra mapping needed', () => { + expect( + getDefaultCombinedAggregation({ + layer: EARTH_ENGINE_LAYER, + aggregationType: ['sum', 'mean'], + legend: { title: 'Population' }, + bands: { + multiple: true, + list: [ + { id: 'm_00', name: 'Male 0 - 1 years' }, + { id: 'f_00', name: 'Female 0 - 1 years' }, + ], + }, + band: ['m_00', 'f_00'], + }) + ).toEqual({ + sum: 'SUM', + mean: 'SUM', + m_00_sum: 'SUM', + m_00_mean: 'SUM', + f_00_sum: 'SUM', + f_00_mean: 'SUM', + }) + }) + + test('Facility/OrgUnit: count-only defaults to COUNT, category defaults to a single shared categoryDisplayType of COUNT', () => { + expect( + getDefaultCombinedAggregation({ + layer: FACILITY_LAYER, + legend: { items: [{ name: 'Facility' }] }, + }) + ).toEqual({ count: 'COUNT' }) + + expect( + getDefaultCombinedAggregation({ + layer: ORG_UNIT_LAYER, + organisationUnitGroupSet: { id: 'groupSet1' }, + legend: { + items: [ + { id: 'group1', name: 'Hospital' }, + { id: 'group2', name: 'Clinic' }, + ], + }, + }) + ).toEqual({ categoryDisplayType: 'COUNT' }) + }) + + test('Event: a numeric styleDataItem defaults to AVERAGE - event values are raw individual records, not pre-aggregated org-unit values', () => { + expect( + getDefaultCombinedAggregation({ + layer: EVENT_LAYER, + styleDataItem: { id: 'de1', valueType: 'NUMBER' }, + legend: { items: [{ name: 'Low' }, { name: 'High' }] }, + }) + ).toEqual({ value: 'AVERAGE' }) + }) + + test('Event: category dataKeys default to a single shared categoryDisplayType of COUNT', () => { + expect( + getDefaultCombinedAggregation({ + layer: EVENT_LAYER, + styleDataItem: { id: 'de1', optionSet: { id: 'os1' } }, + legend: { + items: [ + { name: 'Option A', colorGroup: 0 }, + { name: 'Option B', colorGroup: 1 }, + ], + }, + }) + ).toEqual({ categoryDisplayType: 'COUNT' }) + }) +}) +describe('mergeCrossLayerIds', () => { + const rowFeatureIds = new Map([ + ['ou1', { layerA: ['a1'], layerB: ['b1'] }], + ['ou2', { layerA: ['a2'] }], + ]) + + test('unions per-layer id sets across every named row', () => { + expect(mergeCrossLayerIds(['ou1', 'ou2'], rowFeatureIds)).toEqual({ + layerA: ['a1', 'a2'], + layerB: ['b1'], + }) + }) + + test('dedupes ids repeated across rows for the same layer', () => { + const withOverlap = new Map([ + ['ou1', { layerA: ['a1'] }], + ['ou2', { layerA: ['a1', 'a2'] }], + ]) + expect(mergeCrossLayerIds(['ou1', 'ou2'], withOverlap)).toEqual({ + layerA: ['a1', 'a2'], + }) + }) + + test('skips row keys with no entry', () => { + expect(mergeCrossLayerIds(['ou1', 'missing'], rowFeatureIds)).toEqual({ + layerA: ['a1'], + layerB: ['b1'], + }) + }) + + test('returns an empty object for no rows', () => { + expect(mergeCrossLayerIds([], rowFeatureIds)).toEqual({}) + }) +}) + +describe('getUnionBounds', () => { + const point = (id, coordinates) => ({ + type: 'Feature', + properties: { id }, + geometry: { type: 'Point', coordinates }, + }) + + const layers = [ + { + id: 'layerA', + data: [point('a1', [0, 0]), point('a2', [10, 10])], + }, + { id: 'layerB', data: [point('b1', [5, -5])] }, + ] + + test('computes the union bbox across every named feature on every layer', () => { + expect( + getUnionBounds(layers, { layerA: ['a1', 'a2'], layerB: ['b1'] }) + ).toEqual([ + [0, -5], + [10, 10], + ]) + }) + + test('ignores layers/ids not named in idsByLayerId', () => { + expect(getUnionBounds(layers, { layerA: ['a1'] })).toEqual([ + [0, 0], + [0, 0], + ]) + }) + + test('returns null when nothing matches', () => { + expect(getUnionBounds(layers, {})).toBeNull() + }) + + test('skips ids that have no matching feature or geometry', () => { + expect(getUnionBounds(layers, { layerA: ['missing'] })).toBeNull() + }) +}) diff --git a/src/util/aggregation.js b/src/util/aggregation.js new file mode 100644 index 0000000000..1bbfa16aa6 --- /dev/null +++ b/src/util/aggregation.js @@ -0,0 +1,65 @@ +const AGGREGATIONS = { + SUM: (values) => values.reduce((a, b) => a + b, 0), + AVERAGE: (values) => values.reduce((a, b) => a + b, 0) / values.length, + COUNT: (values) => values.length, + MIN: (values) => Math.min(...values), + MAX: (values) => Math.max(...values), + STDDEV: (values) => Math.sqrt(variance(values)), + VARIANCE: (values) => variance(values), +} + +const variance = (values) => { + const mean = values.reduce((a, b) => a + b, 0) / values.length + return values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length +} + +// Returns null for an empty input (no matching feature) rather than NaN. +export const applyAggregation = (type, values) => { + if (!values.length) { + return null + } + const aggregate = AGGREGATIONS[type] + return aggregate ? aggregate(values) : null +} + +export const NON_COMPOSABLE_AGGREGATION_TYPES = new Set([ + 'AVERAGE', + 'STDDEV', + 'VARIANCE', +]) + +const DHIS2_TO_COMBINED_AGGREGATION_TYPE = { + SUM: 'SUM', + AVERAGE: 'AVERAGE', + AVERAGE_SUM_ORG_UNIT: 'SUM', + COUNT: 'COUNT', + MIN: 'MIN', + MAX: 'MAX', + STDDEV: 'STDDEV', + VARIANCE: 'VARIANCE', +} + +const RATIO_DIMENSION_ITEM_TYPES = new Set(['INDICATOR', 'REPORTING_RATE']) + +export const getDefaultCombinedAggregationType = ( + dataItemAggregationType, + dimensionItemType +) => { + if (RATIO_DIMENSION_ITEM_TYPES.has(dimensionItemType)) { + return 'AVERAGE' + } + return DHIS2_TO_COMBINED_AGGREGATION_TYPE[dataItemAggregationType] ?? 'SUM' +} + +const EARTH_ENGINE_TO_COMBINED_AGGREGATION_TYPE = { + count: 'COUNT', + min: 'MIN', + max: 'MAX', + mean: 'AVERAGE', + sum: 'SUM', + stdDev: 'STDDEV', + variance: 'VARIANCE', +} + +export const getDefaultCombinedAggregationTypeFromEarthEngineStat = (stat) => + EARTH_ENGINE_TO_COMBINED_AGGREGATION_TYPE[stat] ?? 'SUM' diff --git a/src/util/dataTable.js b/src/util/dataTable.js index e55b52bcaa..0e332edc53 100644 --- a/src/util/dataTable.js +++ b/src/util/dataTable.js @@ -1,16 +1,292 @@ -import { SORT_ASCENDING, SORT_DESCENDING } from '../constants/dataTable.js' +import { bbox } from '@turf/bbox' +import { + DATA_KEY_KIND_CATEGORY, + DATA_KEY_KIND_COUNT, + DATA_KEY_KIND_VALUE, + SORT_ASCENDING, + SORT_DESCENDING, +} from '../constants/dataTable.js' import { DATA_TABLE_LAYER_TYPES, + EARTH_ENGINE_LAYER, THEMATIC_LAYER, ORG_UNIT_LAYER, - EARTH_ENGINE_LAYER, FACILITY_LAYER, EVENT_LAYER, TRACKED_ENTITY_LAYER, + RENDERING_STRATEGY_SINGLE, + RENDERING_STRATEGY_TIMELINE, } from '../constants/layers.js' -import { getOrgUnitsFromRows } from './analytics.js' +import { numberValueTypes } from '../constants/valueTypes.js' +import { + getDefaultCombinedAggregationType, + getDefaultCombinedAggregationTypeFromEarthEngineStat, +} from './aggregation.js' +import { getDataItemFromColumns, getOrgUnitsFromRows } from './analytics.js' import { getJoinableFeatures } from './combinedJoinMatch.js' +export const COMBINED_VALUE_KEY = 'rawValue' +export const COMBINED_COUNT_KEY = 'count' +export const UNCLASSIFIED_CATEGORY_KEY = 'unclassified' +export const EVENT_STYLE_VALUE_KEY = 'value' +export const CATEGORY_DISPLAY_TYPE_KEY = 'categoryDisplayType' + +const CLASSIFIED_EARTH_ENGINE_AGGREGATION_TYPES = new Set([ + 'percentage', + 'hectares', + 'acres', +]) + +const CLASSIFIED_EARTH_ENGINE_DEFAULT_AGGREGATION_TYPE = { + percentage: 'AVERAGE', + hectares: 'SUM', + acres: 'SUM', +} + +const toTitleCase = (str) => + str.replace( + /\w\S*/g, + (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() + ) + +const getValueAggregationType = (layer) => { + if (Array.isArray(layer.aggregationType)) { + return getDefaultCombinedAggregationTypeFromEarthEngineStat( + layer.aggregationType[0] + ) + } + if (CLASSIFIED_EARTH_ENGINE_AGGREGATION_TYPES.has(layer.aggregationType)) { + return CLASSIFIED_EARTH_ENGINE_DEFAULT_AGGREGATION_TYPE[ + layer.aggregationType + ] + } + if (layer.layer === EVENT_LAYER) { + return 'AVERAGE' + } + const dataItem = getDataItemFromColumns(layer.columns) + return getDefaultCombinedAggregationType( + dataItem?.aggregationType, + dataItem?.dimensionItemType + ) +} + +export const getDefaultCombinedAggregation = (layer) => { + const valueType = getValueAggregationType(layer) + const dataKeys = getCombinedValueDataKeys(layer) + const entries = dataKeys + .filter(({ kind }) => kind !== DATA_KEY_KIND_CATEGORY) + .map(({ dataKey, kind }) => [ + dataKey, + kind === DATA_KEY_KIND_VALUE ? valueType : 'COUNT', + ]) + if (dataKeys.some(({ kind }) => kind === DATA_KEY_KIND_CATEGORY)) { + entries.push([CATEGORY_DISPLAY_TYPE_KEY, 'COUNT']) + } + return Object.fromEntries(entries) +} + +const getEarthEngineBandValueDataKeys = (layer) => { + if ( + !layer.bands?.multiple || + !Array.isArray(layer.band) || + layer.band.length < 2 + ) { + return [] + } + const selectedBands = + layer.bands.list?.filter((b) => layer.band.includes(b.id)) ?? [] + return selectedBands.flatMap(({ id: bandId, name: bandName }) => + layer.aggregationType.length === 1 + ? [ + { + dataKey: bandId, + name: bandName, + kind: DATA_KEY_KIND_VALUE, + defaultHidden: true, + }, + ] + : layer.aggregationType.map((type) => ({ + dataKey: `${bandId}_${type}`, + name: toTitleCase(`${type} ${bandName}`), + kind: DATA_KEY_KIND_VALUE, + defaultHidden: true, + })) + ) +} + +const isOrgUnitGroupSetCategorical = (layer) => + !!layer.organisationUnitGroupSet?.id && + (layer.legend?.items?.length ?? 0) > 1 + +const getOrgUnitGroupValueDataKeys = (layer) => { + if (!isOrgUnitGroupSetCategorical(layer)) { + return [ + { + dataKey: COMBINED_COUNT_KEY, + name: null, + kind: DATA_KEY_KIND_COUNT, + }, + ] + } + return layer.legend.items.map(({ id, name }) => ({ + dataKey: id ?? UNCLASSIFIED_CATEGORY_KEY, + name, + kind: DATA_KEY_KIND_CATEGORY, + })) +} + +const getEventValueDataKeys = (layer) => { + if ( + layer.styleDataItem && + numberValueTypes.includes(layer.styleDataItem.valueType) + ) { + return [ + { + dataKey: EVENT_STYLE_VALUE_KEY, + name: layer.styleDataItem.name ?? null, + kind: DATA_KEY_KIND_VALUE, + }, + ] + } + if ((layer.legend?.items?.length ?? 0) > 1) { + return layer.legend.items.map(({ colorGroup, name }) => ({ + dataKey: String(colorGroup), + name, + kind: DATA_KEY_KIND_CATEGORY, + })) + } + return [ + { dataKey: COMBINED_COUNT_KEY, name: null, kind: DATA_KEY_KIND_COUNT }, + ] +} + +export const getFeatureCategoryKey = (layer, props) => { + if (layer.layer === FACILITY_LAYER || layer.layer === ORG_UNIT_LAYER) { + return ( + props.dimensions?.[layer.organisationUnitGroupSet?.id] ?? + UNCLASSIFIED_CATEGORY_KEY + ) + } + if (layer.layer === EVENT_LAYER) { + return String(props.colorGroup) + } + return UNCLASSIFIED_CATEGORY_KEY +} + +const getThematicCombinedValueDataKeys = (layer, externalPeriod) => { + const { renderingStrategy, periods } = layer + if (!renderingStrategy || renderingStrategy === RENDERING_STRATEGY_SINGLE) { + return [ + { + dataKey: COMBINED_VALUE_KEY, + name: null, + kind: DATA_KEY_KIND_VALUE, + }, + ] + } + + const periodValueKeys = (periods ?? []).map((period) => ({ + dataKey: `period_${period.id}_${COMBINED_VALUE_KEY}`, + name: null, + kind: DATA_KEY_KIND_VALUE, + periodId: period.id, + periodName: period.name, + settingsKey: COMBINED_VALUE_KEY, + defaultHidden: true, + })) + + if (renderingStrategy !== RENDERING_STRATEGY_TIMELINE) { + return periodValueKeys + } + + return [ + { + dataKey: COMBINED_VALUE_KEY, + name: null, + kind: DATA_KEY_KIND_VALUE, + periodId: externalPeriod?.id, + periodName: externalPeriod?.name, + isCurrentPeriod: true, + settingsKey: COMBINED_VALUE_KEY, + }, + ...periodValueKeys, + ] +} + +export const getCombinedLegendConfig = (layer, externalPeriod) => { + if (layer.layer === EARTH_ENGINE_LAYER) { + return null + } + if ( + layer.layer !== THEMATIC_LAYER || + !layer.renderingStrategy || + layer.renderingStrategy === RENDERING_STRATEGY_SINGLE + ) { + return { periodId: null, periodName: null, isCurrentPeriod: false } + } + if (layer.renderingStrategy === RENDERING_STRATEGY_TIMELINE) { + return { + periodId: externalPeriod?.id ?? null, + periodName: externalPeriod?.name ?? null, + isCurrentPeriod: true, + } + } + // SPLIT_BY_PERIOD: no single period to show a legend for + return null +} + +export const getCombinedValueDataKeys = (layer, externalPeriod) => { + if (layer.layer === FACILITY_LAYER || layer.layer === ORG_UNIT_LAYER) { + return getOrgUnitGroupValueDataKeys(layer) + } + if (layer.layer === EVENT_LAYER) { + return getEventValueDataKeys(layer) + } + if (layer.layer === TRACKED_ENTITY_LAYER) { + return [ + { + dataKey: COMBINED_COUNT_KEY, + name: null, + kind: DATA_KEY_KIND_COUNT, + }, + ] + } + if (layer.layer === THEMATIC_LAYER) { + return getThematicCombinedValueDataKeys(layer, externalPeriod) + } + if (layer.layer !== EARTH_ENGINE_LAYER) { + return [ + { + dataKey: COMBINED_VALUE_KEY, + name: null, + kind: DATA_KEY_KIND_VALUE, + }, + ] + } + if ( + CLASSIFIED_EARTH_ENGINE_AGGREGATION_TYPES.has(layer.aggregationType) && + layer.legend?.items + ) { + return layer.legend.items.map(({ value, name }) => ({ + dataKey: String(value), + name, + kind: DATA_KEY_KIND_VALUE, + })) + } + if (Array.isArray(layer.aggregationType) && layer.aggregationType.length) { + return layer.aggregationType + .map((type) => ({ + dataKey: type, + name: toTitleCase( + `${type} ${layer.legend?.title ?? ''}`.trim() + ), + kind: DATA_KEY_KIND_VALUE, + })) + .concat(getEarthEngineBandValueDataKeys(layer)) + } + return [] +} + export const isFilterable = (dataKey, type) => !!type export const shouldClearFeatureHighlight = (event) => @@ -69,16 +345,19 @@ export const hasActiveDataTableFilters = ({ selectionFilter?.length > 0 || !!showOnlyFeaturesInView -export const isDataTableOpen = ({ openIds, isPanelVisible }) => - isPanelVisible && openIds.length > 0 +export const isDataTableOpen = ({ openIds, combinedView, isPanelVisible }) => + isPanelVisible && (openIds.length > 0 || combinedView) export const getEligibleDataTableLayers = (mapViews) => mapViews.filter( (l) => DATA_TABLE_LAYER_TYPES.includes(l.layer) && l.isLoaded ) -export const getLayerSelectedIds = (selection, layerId) => - selection?.layerId === layerId ? selection.ids ?? [] : [] +export const getLayerSelectedIds = (selection, layerId) => { + const ownIds = selection?.layerId === layerId ? selection.ids ?? [] : [] + const crossIds = selection?.crossLayerIds?.[layerId] ?? [] + return crossIds.length ? [...new Set([...ownIds, ...crossIds])] : ownIds +} export const buildFeatureIndex = (data) => { const index = new Map() @@ -91,6 +370,47 @@ export const buildFeatureIndex = (data) => { return index } +export const mergeCrossLayerIds = (rowKeys, rowFeatureIds) => { + const merged = {} + rowKeys.forEach((key) => { + const entry = rowFeatureIds.get(key) + if (!entry) { + return + } + Object.entries(entry).forEach(([layerId, ids]) => { + merged[layerId] = [...new Set([...(merged[layerId] ?? []), ...ids])] + }) + }) + return merged +} + +export const getUnionBounds = (layers, idsByLayerId) => { + const features = layers.flatMap((layer) => { + const ids = idsByLayerId[layer.id] + if (!ids?.length) { + return [] + } + const index = buildFeatureIndex(layer.data) + return ids.map((id) => index.get(id)).filter((f) => f?.geometry) + }) + + if (!features.length) { + return null + } + + const [minLng, minLat, maxLng, maxLat] = bbox({ + type: 'FeatureCollection', + features, + }) + + return Number.isFinite(minLng) + ? [ + [minLng, minLat], + [maxLng, maxLat], + ] + : null +} + const REFERENCE_ROWS_LEVEL_COMPARABLE_TYPES = [ THEMATIC_LAYER, ORG_UNIT_LAYER, From 34bde387f0304ec850fe6aa1260fad764168c736 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Wed, 9 Sep 2026 20:52:59 +0200 Subject: [PATCH 5/9] feat: re-add cross-layer highlight/selection/visibility to Layer.js [DHIS2-20543] The highest-risk step of the port: re-adds combinedVisibleIds and crossLayerIds-aware hover/selection/visibility support to the base Layer class, which was removed from this branch alongside an unrelated independent refactor that renamed every lifecycle method (handle*->sync*, getHoverIds->getHoverId singular) and added a reference-equality guard to syncVisibleIds. - getHoverIds (plural, replacing getHoverId) merges a layer's own hover id with any crossLayerIds entry for it. - getSelectedIds delegates to util/dataTable.js's getLayerSelectedIds (already crossLayerIds-aware from an earlier commit). - getVisibleIds/getSelectionFilterIds combine the existing selection- filter logic with a new combinedVisibleIds intersection, using a new visibleIdsEqual (null = unrestricted, [] = hide everything - these are not the same thing, unlike the old `?? []` coercion this exact branch's own row-selection session fixed for a different case). - combinedVisibleIds threaded through MapContainer.jsx -> MapView.jsx -> Map.jsx/SplitView.jsx -> Layer. - util/map.js regains getLayerFeatureHighlight (per-overlay highlight filtering, crossLayerIds-aware) and fitCrossLayerZoomBounds (union- bbox zoom for a cross-layer feature), wired into Map.jsx. - onFeatureLeftClick now reports multiSelect on the clickFeature payload (consumed by a later port step). Also fixes two real gaps found by an extensive review pass, both directly relevant to code touched in this same step: - getLayerFeatureHighlight checked crossLayerIds[layerId] for bare truthiness instead of .length, inconsistent with the .length check right next to it in getSelectionFilterIds's isReferenced. - syncVisibleIds had no reference-equality fast path, unlike every sibling sync* method in this same class - added one, matching the established pattern, so an unrelated prop change (opacity, order, hover, highlight color) no longer recomputes visible ids on every layer on every update. Everything else the review surfaced (several near-duplicate id-lookup implementations, split-view not yet filtering per-layer feature/zoom, getHoverIds not deduping against crossLayerIds, fitCrossLayerZoomBounds being currently unreachable pending the context-menu step) was confirmed to already exist unchanged on the verified source branch - left alone to keep this a faithful port, tracked in project_datatable_pr8_port_progress memory for a later cleanup pass. Co-Authored-By: Claude Sonnet 5 --- src/components/map/Map.jsx | 24 +- src/components/map/MapView.jsx | 4 + src/components/map/SplitView.jsx | 3 + src/components/map/layers/Layer.js | 86 ++++- .../map/layers/__tests__/Layer.spec.js | 307 +++++++++++++++++- src/util/__tests__/map.spec.js | 96 +++++- src/util/map.js | 19 ++ 7 files changed, 514 insertions(+), 25 deletions(-) diff --git a/src/components/map/Map.jsx b/src/components/map/Map.jsx index 8269043eb2..090554b384 100644 --- a/src/components/map/Map.jsx +++ b/src/components/map/Map.jsx @@ -2,7 +2,12 @@ import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' import React, { Component, Fragment } from 'react' import { RENDERING_STRATEGY_TIMELINE } from '../../constants/layers.js' -import { onFullscreenChange, resizeAndFitBounds } from '../../util/map.js' +import { + onFullscreenChange, + resizeAndFitBounds, + getLayerFeatureHighlight, + fitCrossLayerZoomBounds, +} from '../../util/map.js' import { sortPeriodsByLevelAndStartDate, addPeriodsDetails, @@ -42,6 +47,7 @@ class Map extends Component { bounds: PropTypes.array, clickFeature: PropTypes.func, closeCoordinatePopup: PropTypes.func, + combinedVisibleIds: PropTypes.object, controls: PropTypes.array, coordinatePopup: PropTypes.array, engine: PropTypes.object, @@ -162,6 +168,12 @@ class Map extends Component { const overlays = this.getLoadedLayers(layers) const timelineOverlay = this.getTimelineOverlay(overlays) this.initializeTimelinePeriod(timelineOverlay) + + this.handleCrossLayerZoom(prevProps) + } + + handleCrossLayerZoom(prevProps) { + fitCrossLayerZoomBounds(this.map, this.props.feature, prevProps.feature) } // Remove map @@ -184,6 +196,7 @@ class Map extends Component { layers, feature, selection, + combinedVisibleIds, highlightFeature, highlightColor, selectionFilter, @@ -227,10 +240,10 @@ class Map extends Component { )} {overlays.map((config, index) => { const Overlay = layerType[config.layer] || Layer - const highlight = - feature && feature.layerId === config.id - ? feature - : null + const highlight = getLayerFeatureHighlight( + feature, + config.id + ) return ( { controls, feature, selection, + combinedVisibleIds, highlightFeature, highlightColor, selectionFilter, @@ -64,6 +65,7 @@ const MapView = (props) => { controls={mapControls} feature={feature} selection={selection} + combinedVisibleIds={combinedVisibleIds} highlightFeature={highlightFeature} highlightColor={highlightColor} selectionFilter={selectionFilter} @@ -85,6 +87,7 @@ const MapView = (props) => { controls={mapControls} feature={feature} selection={selection} + combinedVisibleIds={combinedVisibleIds} highlightFeature={highlightFeature} highlightColor={highlightColor} selectionFilter={selectionFilter} @@ -111,6 +114,7 @@ MapView.propTypes = { basemap: PropTypes.object, bounds: PropTypes.array, clickFeature: PropTypes.func, + combinedVisibleIds: PropTypes.object, controls: PropTypes.array, coordinatePopup: PropTypes.array, feature: PropTypes.object, diff --git a/src/components/map/SplitView.jsx b/src/components/map/SplitView.jsx index 0a1f13bcdf..d226d54c63 100644 --- a/src/components/map/SplitView.jsx +++ b/src/components/map/SplitView.jsx @@ -14,6 +14,7 @@ const SplitView = ({ layers, feature, selection, + combinedVisibleIds, highlightFeature, highlightColor, selectionFilter, @@ -99,6 +100,7 @@ const SplitView = ({ externalPeriod={period} feature={feature} selection={selection} + combinedVisibleIds={combinedVisibleIds} highlightFeature={highlightFeature} highlightColor={highlightColor} selectionFilter={selectionFilter} @@ -126,6 +128,7 @@ SplitView.propTypes = { openContextMenu: PropTypes.func.isRequired, basemap: PropTypes.object, clickFeature: PropTypes.func, + combinedVisibleIds: PropTypes.object, controls: PropTypes.array, feature: PropTypes.object, highlightColor: PropTypes.string, diff --git a/src/components/map/layers/Layer.js b/src/components/map/layers/Layer.js index c5f60b9090..a58ece9477 100644 --- a/src/components/map/layers/Layer.js +++ b/src/components/map/layers/Layer.js @@ -11,10 +11,18 @@ import { SELECTION_FILTER_SELECTED, SELECTION_FILTER_NOT_SELECTED, } from '../../../constants/selection.js' +import { getLayerSelectedIds } from '../../../util/dataTable.js' export const idsEqual = (a, b) => a.length === b.length && a.every((id, i) => id === b[i]) +export const visibleIdsEqual = (a, b) => { + if (a === null || b === null) { + return a === b + } + return idsEqual(a, b) +} + class Layer extends PureComponent { static contextTypes = { map: PropTypes.object, @@ -24,6 +32,7 @@ class Layer extends PureComponent { static propTypes = { id: PropTypes.string.isRequired, clickFeature: PropTypes.func, + combinedVisibleIds: PropTypes.object, config: PropTypes.object, data: PropTypes.array, dataFilters: PropTypes.object, @@ -116,7 +125,12 @@ class Layer extends PureComponent { this.zoomToFocusedFeature(feature) - if (this.getHoverId(prevProps.feature) !== this.getHoverId(feature)) { + if ( + !idsEqual( + this.getHoverIds(prevProps.feature), + this.getHoverIds(feature) + ) + ) { this.highlightFeature() } } @@ -139,7 +153,7 @@ class Layer extends PureComponent { return } - if (this.getHoverId()) { + if (this.getHoverIds().length) { this.highlightFeature() } if (this.getSelectedIds().length) { @@ -148,14 +162,26 @@ class Layer extends PureComponent { } syncVisibleIds(prevProps) { - const { selection, selectionFilter } = this.props + const { selection, selectionFilter, combinedVisibleIds } = this.props if ( - !idsEqual( + selection === prevProps.selection && + selectionFilter === prevProps.selectionFilter && + combinedVisibleIds === prevProps.combinedVisibleIds + ) { + return + } + if ( + !visibleIdsEqual( this.getVisibleIds( prevProps.selection, - prevProps.selectionFilter - ) ?? [], - this.getVisibleIds(selection, selectionFilter) ?? [] + prevProps.selectionFilter, + prevProps.combinedVisibleIds + ), + this.getVisibleIds( + selection, + selectionFilter, + combinedVisibleIds + ) ) ) { this.updateVisibleIds() @@ -271,27 +297,31 @@ class Layer extends PureComponent { } } - getHoverId(feature = this.props.feature) { - return feature?.layerId === this.props.id ? feature.id : null + getHoverIds(feature = this.props.feature) { + const ownId = feature?.layerId === this.props.id ? feature.id : null + const crossIds = feature?.crossLayerIds?.[this.props.id] ?? [] + return ownId != null ? [ownId, ...crossIds] : crossIds } getSelectedIds(selection = this.props.selection) { - return selection?.layerId === this.props.id ? selection.ids : [] + return getLayerSelectedIds(selection, this.props.id) } highlightFeature() { - this.layer?.highlight?.(this.getHoverId(), this.props.highlightColor) + this.layer?.highlight?.(this.getHoverIds(), this.props.highlightColor) } selectFeatures() { this.layer?.select?.(this.getSelectedIds(), this.props.highlightColor) } - getVisibleIds( - selection = this.props.selection, - selectionFilter = this.props.selectionFilter - ) { - if (!selectionFilter?.length || selection?.layerId !== this.props.id) { + // null means "no restriction from this source, show everything". + getSelectionFilterIds(selection, selectionFilter) { + const isReferenced = + selection?.layerId === this.props.id || + !!selection?.crossLayerIds?.[this.props.id]?.length + + if (!selectionFilter?.length || !isReferenced) { return null } @@ -318,6 +348,25 @@ class Layer extends PureComponent { .filter((id) => id != null && !selectedIdSet.has(id)) } + getVisibleIds( + selection = this.props.selection, + selectionFilter = this.props.selectionFilter, + combinedVisibleIds = this.props.combinedVisibleIds + ) { + const selectionIds = this.getSelectionFilterIds( + selection, + selectionFilter + ) + const combinedIds = combinedVisibleIds?.[this.props.id] ?? null + + if (selectionIds && combinedIds) { + const combinedIdSet = new Set(combinedIds) + return selectionIds.filter((id) => combinedIdSet.has(id)) + } + + return selectionIds ?? combinedIds + } + updateVisibleIds() { this.layer?.setVisibleIds?.(this.getVisibleIds()) } @@ -329,9 +378,10 @@ class Layer extends PureComponent { return } - this.props.clickFeature?.({ id, layerId: this.props.id }) + const multiSelect = this.isMultiSelectClick(evt) + this.props.clickFeature?.({ id, layerId: this.props.id, multiSelect }) - if (this.isMultiSelectClick(evt)) { + if (multiSelect) { this.props.toggleFeatureSelection?.(id, this.props.id) } } diff --git a/src/components/map/layers/__tests__/Layer.spec.js b/src/components/map/layers/__tests__/Layer.spec.js index 77d7660b8f..bde3a8c8c5 100644 --- a/src/components/map/layers/__tests__/Layer.spec.js +++ b/src/components/map/layers/__tests__/Layer.spec.js @@ -1,4 +1,4 @@ -import Layer from '../Layer.js' +import Layer, { visibleIdsEqual } from '../Layer.js' const createLayer = (props) => { const instance = Object.create(Layer.prototype) @@ -62,4 +62,309 @@ describe('Layer#getVisibleIds', () => { }) expect(layer.getVisibleIds()).toBe(null) }) + + test('recognizes a crossLayerIds-only selection (no own-layer selection.layerId)', () => { + const layer = createLayer({ + id: 'layer1', + data, + selection: { + layerId: null, + ids: [], + crossLayerIds: { layer1: ['a', 'c'] }, + }, + selectionFilter: ['selected'], + }) + expect(layer.getVisibleIds()).toEqual(['a', 'c']) + }) + + test('returns null when this layer has no entry in crossLayerIds either', () => { + const layer = createLayer({ + id: 'layer1', + data, + selection: { + layerId: null, + ids: [], + crossLayerIds: { 'other-layer': ['a'] }, + }, + selectionFilter: ['selected'], + }) + expect(layer.getVisibleIds()).toBe(null) + }) + + test('returns null (show everything) when combinedVisibleIds has no entry for this layer', () => { + const layer = createLayer({ + id: 'layer1', + data, + combinedVisibleIds: null, + }) + expect(layer.getVisibleIds()).toBe(null) + }) + + test("returns only this layer's own combinedVisibleIds entry, ignoring other layers'", () => { + const layer = createLayer({ + id: 'layer1', + data, + combinedVisibleIds: { layer1: ['a', 'b'], layer2: ['c'] }, + }) + expect(layer.getVisibleIds()).toEqual(['a', 'b']) + }) + + test('returns an empty array (hide everything) when combinedVisibleIds keys this layer in with no ids', () => { + const layer = createLayer({ + id: 'layer1', + data, + combinedVisibleIds: { layer1: [] }, + }) + expect(layer.getVisibleIds()).toEqual([]) + }) + + test('intersects selectionFilter and combinedVisibleIds when both apply', () => { + const layer = createLayer({ + id: 'layer1', + data, + selection: { layerId: 'layer1', ids: ['a', 'b'] }, + selectionFilter: ['selected'], + combinedVisibleIds: { layer1: ['b', 'c'] }, + }) + expect(layer.getVisibleIds()).toEqual(['b']) + }) +}) + +describe('visibleIdsEqual', () => { + test('null and an empty array are not equal - null means "show everything", [] means "show nothing"', () => { + expect(visibleIdsEqual(null, [])).toBe(false) + expect(visibleIdsEqual([], null)).toBe(false) + }) + + test('null and null are equal', () => { + expect(visibleIdsEqual(null, null)).toBe(true) + }) + + test('two empty arrays are equal', () => { + expect(visibleIdsEqual([], [])).toBe(true) + }) + + test('two arrays with the same ids in the same order are equal', () => { + expect(visibleIdsEqual(['a', 'b'], ['a', 'b'])).toBe(true) + }) + + test('two arrays with different ids are not equal', () => { + expect(visibleIdsEqual(['a'], ['b'])).toBe(false) + }) +}) + +describe('Layer#syncVisibleIds', () => { + test('skips recomputing entirely when selection/selectionFilter/combinedVisibleIds are all referentially unchanged', () => { + const setVisibleIds = jest.fn() + const layer = createLayer({ + id: 'layer1', + data: [{ properties: { id: 'a' } }], + selection: { layerId: 'layer1', ids: ['a'] }, + selectionFilter: ['selected'], + combinedVisibleIds: null, + }) + layer.layer = { setVisibleIds } + const getVisibleIdsSpy = jest.spyOn(layer, 'getVisibleIds') + + layer.syncVisibleIds(layer.props) + + expect(getVisibleIdsSpy).not.toHaveBeenCalled() + expect(setVisibleIds).not.toHaveBeenCalled() + }) + + test('calls setVisibleIds when combinedVisibleIds clears from an all-excluded entry back to no restriction - the bug where a table filter that hid every feature left the map stuck once filters were cleared', () => { + const setVisibleIds = jest.fn() + const layer = createLayer({ + id: 'layer1', + data: [{ properties: { id: 'a' } }], + combinedVisibleIds: null, + }) + layer.layer = { setVisibleIds } + + layer.syncVisibleIds({ + combinedVisibleIds: { layer1: [] }, + }) + + expect(setVisibleIds).toHaveBeenCalledWith(null) + }) + + test('does not call setVisibleIds when the visible ids are unchanged', () => { + const setVisibleIds = jest.fn() + const layer = createLayer({ + id: 'layer1', + data: [{ properties: { id: 'a' } }], + combinedVisibleIds: { layer1: ['a'] }, + }) + layer.layer = { setVisibleIds } + + layer.syncVisibleIds({ + combinedVisibleIds: { layer1: ['a'] }, + }) + + expect(setVisibleIds).not.toHaveBeenCalled() + }) +}) + +describe('Layer#getHoverIds', () => { + test('returns an empty array when there is no feature', () => { + const layer = createLayer({ id: 'layer1', feature: null }) + expect(layer.getHoverIds()).toEqual([]) + }) + + test("returns this layer's own hover id when feature.layerId matches", () => { + const layer = createLayer({ + id: 'layer1', + feature: { id: 'a', layerId: 'layer1' }, + }) + expect(layer.getHoverIds()).toEqual(['a']) + }) + + test("returns an empty array when the feature belongs to a different layer and there's no crossLayerIds entry", () => { + const layer = createLayer({ + id: 'layer1', + feature: { id: 'a', layerId: 'other-layer' }, + }) + expect(layer.getHoverIds()).toEqual([]) + }) + + test("merges this layer's crossLayerIds entry alongside its own hover id", () => { + const layer = createLayer({ + id: 'layer1', + feature: { + id: 'a', + layerId: 'layer1', + crossLayerIds: { layer1: ['x', 'y'] }, + }, + }) + expect(layer.getHoverIds()).toEqual(['a', 'x', 'y']) + }) + + test('returns only crossLayerIds when the feature has no own-layer match', () => { + const layer = createLayer({ + id: 'layer1', + feature: { + layerId: null, + crossLayerIds: { layer1: ['x', 'y'], layer2: ['z'] }, + }, + }) + expect(layer.getHoverIds()).toEqual(['x', 'y']) + }) +}) + +describe('Layer#getSelectedIds', () => { + test('returns an empty array when there is no selection', () => { + const layer = createLayer({ id: 'layer1', selection: null }) + expect(layer.getSelectedIds()).toEqual([]) + }) + + test("returns this layer's own selected ids when selection.layerId matches", () => { + const layer = createLayer({ + id: 'layer1', + selection: { layerId: 'layer1', ids: ['a', 'b'] }, + }) + expect(layer.getSelectedIds()).toEqual(['a', 'b']) + }) + + test('merges crossLayerIds with a same-layer selection, deduping', () => { + const layer = createLayer({ + id: 'layer1', + selection: { + layerId: 'layer1', + ids: ['a'], + crossLayerIds: { layer1: ['a', 'b'] }, + }, + }) + expect(layer.getSelectedIds()).toEqual(['a', 'b']) + }) + + test('returns only crossLayerIds when the selection has no own-layer match', () => { + const layer = createLayer({ + id: 'layer1', + selection: { + layerId: null, + ids: [], + crossLayerIds: { layer1: ['x'], layer2: ['y'] }, + }, + }) + expect(layer.getSelectedIds()).toEqual(['x']) + }) +}) + +describe('Layer#onFeatureLeftClick', () => { + const clickEvent = (id, keys = {}) => ({ + feature: { properties: { id } }, + ...keys, + }) + + test('a plain click reports clickFeature with multiSelect: false and does not toggle selection', () => { + const clickFeature = jest.fn() + const toggleFeatureSelection = jest.fn() + const layer = createLayer({ + id: 'layer1', + clickFeature, + toggleFeatureSelection, + }) + + layer.onFeatureLeftClick(clickEvent('a')) + + expect(clickFeature).toHaveBeenCalledWith({ + id: 'a', + layerId: 'layer1', + multiSelect: false, + }) + expect(toggleFeatureSelection).not.toHaveBeenCalled() + }) + + test('a ctrl-click reports clickFeature with multiSelect: true and also toggles selection', () => { + const clickFeature = jest.fn() + const toggleFeatureSelection = jest.fn() + const layer = createLayer({ + id: 'layer1', + clickFeature, + toggleFeatureSelection, + }) + + layer.onFeatureLeftClick(clickEvent('a', { ctrlKey: true })) + + expect(clickFeature).toHaveBeenCalledWith({ + id: 'a', + layerId: 'layer1', + multiSelect: true, + }) + expect(toggleFeatureSelection).toHaveBeenCalledWith('a', 'layer1') + }) + + test('a meta-click (cmd on macOS) also counts as multiSelect', () => { + const clickFeature = jest.fn() + const toggleFeatureSelection = jest.fn() + const layer = createLayer({ + id: 'layer1', + clickFeature, + toggleFeatureSelection, + }) + + layer.onFeatureLeftClick(clickEvent('a', { metaKey: true })) + + expect(clickFeature).toHaveBeenCalledWith({ + id: 'a', + layerId: 'layer1', + multiSelect: true, + }) + expect(toggleFeatureSelection).toHaveBeenCalledWith('a', 'layer1') + }) + + test('does nothing when the clicked feature has no id', () => { + const clickFeature = jest.fn() + const toggleFeatureSelection = jest.fn() + const layer = createLayer({ + id: 'layer1', + clickFeature, + toggleFeatureSelection, + }) + + layer.onFeatureLeftClick({ feature: { properties: {} } }) + + expect(clickFeature).not.toHaveBeenCalled() + expect(toggleFeatureSelection).not.toHaveBeenCalled() + }) }) diff --git a/src/util/__tests__/map.spec.js b/src/util/__tests__/map.spec.js index 39fd3bdede..42258f3353 100644 --- a/src/util/__tests__/map.spec.js +++ b/src/util/__tests__/map.spec.js @@ -1,4 +1,10 @@ -import { onFullscreenChange, resizeAndFitBounds, toGeoJson } from '../map.js' +import { + fitCrossLayerZoomBounds, + getLayerFeatureHighlight, + onFullscreenChange, + resizeAndFitBounds, + toGeoJson, +} from '../map.js' const bounds = [ [0, 0], @@ -11,6 +17,7 @@ const createMockMap = (layersBounds = bounds) => ({ fitBounds: jest.fn(), toggleMultiTouch: jest.fn(), toggleScrollZoom: jest.fn(), + getMapGL: jest.fn(() => ({ getBearing: jest.fn(() => 0) })), }) describe('toGeoJson', () => { @@ -68,6 +75,93 @@ describe('toGeoJson', () => { }) }) +describe('getLayerFeatureHighlight', () => { + it('returns null when there is no active highlight', () => { + expect(getLayerFeatureHighlight(null, 'layerA')).toBeNull() + }) + + it("passes through a single-layer highlight for that layer's own id", () => { + const feature = { id: 'f1', layerId: 'layerA' } + expect(getLayerFeatureHighlight(feature, 'layerA')).toBe(feature) + }) + + it("hides a single-layer highlight from a layer that doesn't own it", () => { + const feature = { id: 'f1', layerId: 'layerA' } + expect(getLayerFeatureHighlight(feature, 'layerB')).toBeNull() + }) + + it('passes a crossLayerIds highlight through to every layer it names, despite layerId being null', () => { + const feature = { + layerId: null, + crossLayerIds: { layerA: ['f1'], layerB: ['f2'] }, + } + expect(getLayerFeatureHighlight(feature, 'layerA')).toBe(feature) + expect(getLayerFeatureHighlight(feature, 'layerB')).toBe(feature) + }) + + it('hides a crossLayerIds highlight from a layer not named in it', () => { + const feature = { + layerId: null, + crossLayerIds: { layerA: ['f1'] }, + } + expect(getLayerFeatureHighlight(feature, 'layerC')).toBeNull() + }) + + it('treats a present-but-empty crossLayerIds entry as not referenced', () => { + const feature = { + layerId: null, + crossLayerIds: { layerA: [] }, + } + expect(getLayerFeatureHighlight(feature, 'layerA')).toBeNull() + }) +}) + +describe('fitCrossLayerZoomBounds', () => { + const zoomFeature = { + layerId: null, + zoom: true, + bounds, + crossLayerIds: { layerA: ['a1'] }, + } + + it('fits the map to the precomputed bounds when the feature changes and carries zoom+bounds', () => { + const map = createMockMap() + fitCrossLayerZoomBounds(map, zoomFeature, null) + expect(map.fitBounds).toHaveBeenCalledWith( + bounds, + expect.objectContaining({ essential: true }) + ) + }) + + it('does nothing when the feature reference is unchanged', () => { + const map = createMockMap() + fitCrossLayerZoomBounds(map, zoomFeature, zoomFeature) + expect(map.fitBounds).not.toHaveBeenCalled() + }) + + it('does nothing for a highlight with no zoom flag', () => { + const map = createMockMap() + fitCrossLayerZoomBounds(map, { ...zoomFeature, zoom: false }, null) + expect(map.fitBounds).not.toHaveBeenCalled() + }) + + it('does nothing for a single-layer zoom (no precomputed bounds)', () => { + const map = createMockMap() + fitCrossLayerZoomBounds( + map, + { id: 'f1', layerId: 'layerA', zoom: true }, + null + ) + expect(map.fitBounds).not.toHaveBeenCalled() + }) + + it('does nothing when there is no active feature', () => { + const map = createMockMap() + fitCrossLayerZoomBounds(map, null, zoomFeature) + expect(map.fitBounds).not.toHaveBeenCalled() + }) +}) + describe('resizeAndFitBounds', () => { it('resizes the map and fits bounds when layer bounds exist', () => { const map = createMockMap() diff --git a/src/util/map.js b/src/util/map.js index c9305d275f..82839c5ed5 100644 --- a/src/util/map.js +++ b/src/util/map.js @@ -4,6 +4,7 @@ import { ORG_UNIT_PATH_DATA_KEY, } from '../constants/dataTable.js' import { dimConf } from '../constants/dimension.js' +import { PADDING_DEFAULT, DURATION_DEFAULT } from '../constants/layers.js' export const toGeoJson = (organisationUnits) => sortBy('le', organisationUnits) @@ -65,6 +66,24 @@ export const toGeoJson = (organisationUnits) => geometry.coordinates.flat().length ) +export const getLayerFeatureHighlight = (feature, layerId) => + feature && + (feature.layerId === layerId || feature.crossLayerIds?.[layerId]?.length) + ? feature + : null + +export const fitCrossLayerZoomBounds = (map, feature, prevFeature) => { + if (feature === prevFeature || !feature?.zoom || !feature.bounds) { + return + } + map.fitBounds(feature.bounds, { + padding: PADDING_DEFAULT, + duration: DURATION_DEFAULT, + essential: true, + bearing: map.getMapGL().getBearing(), + }) +} + //eslint-disable-next-line max-params export const drillUpDown = (layerConfig, parentId, parentGraph, level) => ({ ...layerConfig, From ed9359881ab781867554477431a6c0b8ae89246e Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 10 Sep 2026 10:18:59 +0200 Subject: [PATCH 6/9] feat: add CombinedDataTable and its context menu [DHIS2-20543] Ports the main Combined-view UI component from feat/datatable-pr7-multilayer: CombinedDataTable.jsx (reuses TableVirtuosoComponents, CellValue, SelectionCheckboxColumn, SortableColumnHeader, FilterInput, and the dispatch-agnostic useRowClickSelection/useRowSelection hooks verbatim from the single-layer table) and CombinedTableContextMenu.jsx (zoom to feature/selected/filtered features, using the union-bbox zoom already ported to util/dataTable.js/util/map.js). Rewired the checkbox/row-selection wiring to match this branch's current useRowClickSelection API (added when this branch fixed the row-selection anchor bugs): destructure {onRowClick, onCheckboxToggle, resetAnchor}, pass selectedIdSet, wire onCheckboxToggle with the click event for shift-click-on-checkbox range-select, and reset the anchor before onToggleSelectAll/onReverseSelection - mirroring DataTable.jsx exactly. An 8-angle review pass converged strongly (3 independent confirmations) on one real bug: local `selectedIds` state was seeded once from Redux and never resynced, so any Redux action that resets selection (SELECTION_CLEAR, MAP_NEW, a reference-layer change) would leave the table's checkboxes stale relative to the map. Fixed by deriving selectedIds live via useMemo + the existing getLayerSelectedIds util, exactly matching how DataTable.jsx already does it - no more local selection state at all. Also fixed three smaller gaps the review found: missing computeItemKey on TableVirtuoso (index-based fallback risks row-identity bugs across sort/filter), tableContext.layout hardcoded to 'auto' instead of following columnWidths.length (misaligns the pinned columns this file itself implements), and hasActiveFilters hand-rolled instead of reusing the existing hasActiveDataTableFilters util (was silently missing showOnlyFeaturesInView, incorrectly disabling "Zoom to filtered features"). Remaining review findings (a real O(rows) recompute on every checkbox click, an O(rows^2) risk in mergeCrossLayerIds, a stale shift-click anchor edge case, and a couple of others) were confirmed inherited unchanged from the verified source branch and are tracked in project_datatable_pr8_port_progress memory for a follow-up pass - CombinedDataTable has no caller yet (lands in the next step), so several of these can't be verified live until then. Co-Authored-By: Claude Sonnet 5 --- .../datatable/CombinedDataTable.jsx | 675 ++++++++++ .../datatable/CombinedTableContextMenu.jsx | 112 ++ .../__tests__/CombinedDataTable.spec.jsx | 1095 +++++++++++++++++ .../CombinedTableContextMenu.spec.jsx | 103 ++ .../styles/CombinedDataTable.module.css | 24 + 5 files changed, 2009 insertions(+) create mode 100644 src/components/datatable/CombinedDataTable.jsx create mode 100644 src/components/datatable/CombinedTableContextMenu.jsx create mode 100644 src/components/datatable/__tests__/CombinedDataTable.spec.jsx create mode 100644 src/components/datatable/__tests__/CombinedTableContextMenu.spec.jsx create mode 100644 src/components/datatable/styles/CombinedDataTable.module.css diff --git a/src/components/datatable/CombinedDataTable.jsx b/src/components/datatable/CombinedDataTable.jsx new file mode 100644 index 0000000000..3d0d359c15 --- /dev/null +++ b/src/components/datatable/CombinedDataTable.jsx @@ -0,0 +1,675 @@ +import i18n from '@dhis2/d2-i18n' +import { DataTableRow, DataTableCell } from '@dhis2/ui' +import cx from 'classnames' +import PropTypes from 'prop-types' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { TableVirtuoso } from 'react-virtuoso' +import { + setCombinedVisibleIds, + setSelectionFilter, +} from '../../actions/dataTable.js' +import { highlightFeature } from '../../actions/feature.js' +import { setCrossLayerSelection } from '../../actions/selection.js' +import { + COMBINED_HEADERS_KEY, + ORG_UNIT_ID_DATA_KEY, +} from '../../constants/dataTable.js' +import { + hasActiveDataTableFilters, + isFilterable, + getLayerSelectedIds, + getRowId, + getUnionBounds, + mergeCrossLayerIds, +} from '../../util/dataTable.js' +import { + getPinnedCellProps, + getPinnedCount, + getPinnedLeftOffsets, + getVisibleHeaders, +} from '../../util/tableColumns.js' +import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' +import CellValue from './CellValue.jsx' +import CombinedTableContextMenu from './CombinedTableContextMenu.jsx' +import FilterInput from './FilterInput.jsx' +import { + SelectionCheckboxHeaderCell, + SelectionCheckboxCell, +} from './SelectionCheckboxColumn.jsx' +import SelectionFilterButton from './SelectionFilterButton.jsx' +import SortableColumnHeader from './SortableColumnHeader.jsx' +import styles from './styles/CombinedDataTable.module.css' +import dataTableStyles from './styles/DataTable.module.css' +import TableComponents from './TableVirtuosoComponents.jsx' +import { useColumnWidths } from './useColumnWidths.js' +import { useCombinedTableData } from './useCombinedTableData.js' +import { useRowClickSelection } from './useRowClickSelection.js' +import { useRowContextMenuHighlight } from './useRowContextMenuHighlight.js' +import { useRowSelection } from './useRowSelection.js' +import { useSortState } from './useSortState.js' + +const TABLE_STYLE = { height: '100%', width: '100%' } +const LARGE_FEATURE_THRESHOLD_LABEL = '10,000' +const EMPTY_FILTERS = {} + +const EmptyPlaceholder = () => ( + + + +
+ {i18n.t('No matching rows')} +
+ + + +) + +const CombinedTableComponents = { + ...TableComponents, + EmptyPlaceholder, +} + +const CombinedDataTable = ({ + availableWidth, + layers, + referenceLayer, + joinConfig, + filters, + onFiltersChange, + globalSearch, + onCountChange, + onHeadersChange, + columnConfig, +}) => { + const dispatch = useDispatch() + const { + systemSettings: { keyAnalysisDigitGroupSeparator }, + } = useCachedData() + const aggregations = useSelector((state) => state.aggregations) + const showOnlyFeaturesInView = useSelector( + (state) => state.ui.showOnlyFeaturesInView + ) + const mapBounds = useSelector((state) => state.ui.mapBounds) + const externalPeriod = useSelector( + (state) => state.ui?.activeTimelinePeriod + ) + const selectionFilter = useSelector((state) => state.ui.selectionFilter) + const currentFeature = useSelector((state) => state.feature) + const lastClickedFeature = useSelector( + (state) => state.ui.lastClickedFeature + ) + + const { sortField, sortDirection, sortData } = useSortState('name') + + const selection = useSelector((state) => state.selection) + const selectedIds = useMemo( + () => getLayerSelectedIds(selection, referenceLayer.id), + [selection, referenceLayer.id] + ) + const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]) + + const { + headers, + rows, + rowFeatureIds, + columnOptions, + spatialWarning, + orgUnitIdToName, + } = useCombinedTableData({ + layers, + referenceLayer, + joinConfig, + sortField, + sortDirection, + filters, + globalSearch, + aggregations, + showOnlyFeaturesInView, + mapBounds, + selectionFilter, + selectedIdSet, + keyAnalysisDigitGroupSeparator, + externalPeriod, + }) + + useEffect(() => { + onHeadersChange?.(headers, COMBINED_HEADERS_KEY) + }, [onHeadersChange, headers]) + + const rowIdByLayerFeature = useMemo(() => { + const index = new Map() + rowFeatureIds.forEach((entry, rowId) => { + Object.entries(entry).forEach(([layerId, ids]) => { + ids.forEach((id) => index.set(`${layerId}:${id}`, rowId)) + }) + }) + return index + }, [rowFeatureIds]) + + const mapHoveredRowId = + currentFeature?.origin === 'map' && + currentFeature?.id != null && + currentFeature?.layerId != null + ? rowIdByLayerFeature.get( + `${currentFeature.layerId}:${currentFeature.id}` + ) ?? null + : null + + const pinnedKeys = useMemo( + () => columnConfig?.pinnedKeys ?? [], + [columnConfig] + ) + + const visibleHeaders = useMemo( + () => getVisibleHeaders(headers, columnConfig) ?? [], + [headers, columnConfig] + ) + + const rendererByDataKey = useMemo( + () => new Map(visibleHeaders.map((h) => [h.dataKey, h.renderer])), + [visibleHeaders] + ) + const typeByDataKey = useMemo( + () => new Map(visibleHeaders.map((h) => [h.dataKey, h.type])), + [visibleHeaders] + ) + + const { headerRowRef, columnWidths } = useColumnWidths({ + availableWidth, + headers: visibleHeaders, + }) + + const pinnedColumnCount = useMemo( + () => getPinnedCount(visibleHeaders, pinnedKeys), + [visibleHeaders, pinnedKeys] + ) + + const pinnedLeftOffsets = useMemo( + () => getPinnedLeftOffsets(visibleHeaders, pinnedKeys, columnWidths), + [visibleHeaders, pinnedKeys, columnWidths] + ) + const pinnedOffsetsReady = Object.keys(pinnedLeftOffsets).length > 0 + + const isCheckboxColumnPinned = pinnedColumnCount > 0 && pinnedOffsetsReady + + const computeItemKey = useCallback( + (index, row) => getRowId(row) ?? index, + [] + ) + + useEffect(() => { + onCountChange?.(rows.length, rows.length) + }, [onCountChange, rows.length]) + + const [hoveredRowId, setHoveredRowId] = useState(null) + + const applySelection = useCallback( + (nextIds) => { + dispatch( + setCrossLayerSelection( + mergeCrossLayerIds(nextIds, rowFeatureIds) + ) + ) + }, + [dispatch, rowFeatureIds] + ) + + const onToggleRow = useCallback( + (id) => + applySelection( + selectedIds.includes(id) + ? selectedIds.filter((i) => i !== id) + : [...selectedIds, id] + ), + [applySelection, selectedIds] + ) + const onSelectRowRange = useCallback( + (ids) => applySelection([...new Set([...selectedIds, ...ids])]), + [applySelection, selectedIds] + ) + const { onRowClick, onCheckboxToggle, resetAnchor } = useRowClickSelection({ + rows, + onToggle: onToggleRow, + onSelectRange: onSelectRowRange, + selectedIdSet, + }) + + const virtuosoRef = useRef(null) + const rowsRef = useRef(rows) + rowsRef.current = rows + const rowIdByLayerFeatureRef = useRef(rowIdByLayerFeature) + rowIdByLayerFeatureRef.current = rowIdByLayerFeature + const onToggleRowRef = useRef(onToggleRow) + onToggleRowRef.current = onToggleRow + + useEffect(() => { + if (!lastClickedFeature) { + return + } + const rowId = rowIdByLayerFeatureRef.current.get( + `${lastClickedFeature.layerId}:${lastClickedFeature.id}` + ) + if (!rowId) { + return + } + if (lastClickedFeature.multiSelect) { + onToggleRowRef.current(rowId) + } + const rowIndex = rowsRef.current.findIndex( + (row) => getRowId(row) === rowId + ) + if (rowIndex !== -1) { + virtuosoRef.current?.scrollToIndex({ + index: rowIndex, + align: 'center', + behavior: 'smooth', + }) + } + }, [lastClickedFeature]) + + const setFeatureHighlight = useCallback( + (row) => { + const id = getRowId(row) + setHoveredRowId(id ?? null) + const entry = id ? rowFeatureIds.get(id) : null + dispatch( + highlightFeature( + entry && Object.keys(entry).length + ? { + layerId: null, + origin: 'table', + crossLayerIds: entry, + } + : null + ) + ) + }, + [dispatch, rowFeatureIds] + ) + + const onClearHighlight = useCallback(() => { + setHoveredRowId(null) + dispatch(highlightFeature(null)) + }, [dispatch]) + + const { onContextMenuOpen, guardedClear, onMenuClose } = + useRowContextMenuHighlight({ + onPin: setFeatureHighlight, + onClear: onClearHighlight, + }) + + const onRowDoubleClick = useCallback( + (row) => { + const id = getRowId(row) + if (!id) { + return + } + const entry = rowFeatureIds.get(id) ?? {} + dispatch( + highlightFeature({ + layerId: null, + origin: 'table', + zoom: true, + bounds: getUnionBounds([referenceLayer, ...layers], entry), + crossLayerIds: entry, + }) + ) + }, + [dispatch, rowFeatureIds, referenceLayer, layers] + ) + + const hasColumnOrSearchFilters = + Object.keys(filters ?? EMPTY_FILTERS).length > 0 || + !!globalSearch?.trim() + + const combinedVisibleIdsByLayer = useMemo(() => { + if (!hasColumnOrSearchFilters) { + return null + } + const idsByLayer = Object.fromEntries( + [referenceLayer, ...layers].map((layer) => [layer.id, []]) + ) + rows.forEach((row) => { + const rowId = getRowId(row) + const entry = rowId ? rowFeatureIds.get(rowId) : null + if (!entry) { + return + } + Object.entries(entry).forEach(([layerId, ids]) => { + idsByLayer[layerId]?.push(...ids) + }) + }) + return idsByLayer + }, [hasColumnOrSearchFilters, rows, rowFeatureIds, referenceLayer, layers]) + + useEffect(() => { + dispatch(setCombinedVisibleIds(combinedVisibleIdsByLayer)) + }, [dispatch, combinedVisibleIdsByLayer]) + + useEffect( + () => () => { + dispatch(setCombinedVisibleIds(null)) + }, + [dispatch] + ) + + const allRowIds = useMemo(() => rows.map(getRowId).filter(Boolean), [rows]) + + const { + isAllSelected, + onToggleSelectAll: onToggleSelectAllRows, + onReverseSelection: onReverseSelectionRows, + } = useRowSelection({ + selectedIds, + selectedIdSet, + allRowIds, + onChange: applySelection, + }) + + const onToggleSelectAll = useCallback(() => { + resetAnchor() + onToggleSelectAllRows() + }, [resetAnchor, onToggleSelectAllRows]) + + const onReverseSelection = useCallback(() => { + resetAnchor() + onReverseSelectionRows() + }, [resetAnchor, onReverseSelectionRows]) + + const hasActiveFilters = hasActiveDataTableFilters({ + dataFilters: filters, + globalSearch, + selectionFilter, + showOnlyFeaturesInView, + }) + + const [tableContextMenu, setTableContextMenu] = useState(null) + + const onRowContextMenu = useCallback( + (e, row) => { + e.preventDefault() + const rowId = getRowId(row) + if (!rowId) { + return + } + onContextMenuOpen(row) + setTableContextMenu({ x: e.clientX, y: e.clientY, rowId }) + }, + [onContextMenuOpen] + ) + + const tableContext = useMemo( + () => ({ + onMouseEnter: setFeatureHighlight, + onMouseLeave: guardedClear, + onRowClick, + onContextMenu: onRowContextMenu, + onRowDoubleClick, + layout: columnWidths.length > 0 ? 'fixed' : 'auto', + }), + [ + setFeatureHighlight, + guardedClear, + onRowClick, + onRowContextMenu, + onRowDoubleClick, + columnWidths, + ] + ) + + const onFilterChange = useCallback( + (dataKey, value) => { + onFiltersChange?.({ + ...(filters ?? EMPTY_FILTERS), + [dataKey]: value, + }) + }, + [filters, onFiltersChange] + ) + + const onFilterClear = useCallback( + (dataKey) => { + const next = { ...(filters ?? EMPTY_FILTERS) } + delete next[dataKey] + onFiltersChange?.(next) + }, + [filters, onFiltersChange] + ) + + const fixedHeaderContent = useCallback( + () => ( + + + dispatch(setSelectionFilter(next)) + } + /> + } + /> + {visibleHeaders.map( + ({ name, dataKey, type, renderer }, index) => { + const { fixed, left, isLastPinned } = + getPinnedCellProps(dataKey, index, { + pinnedLeftOffsets, + pinnedColumnCount, + columnWidths, + }) + return ( + + onFilterChange(dataKey, value) + } + onClear={() => + onFilterClear(dataKey) + } + /> + ) + } + width={ + columnWidths.length > 0 + ? `${columnWidths[index]}px` + : 'auto' + } + /> + ) + } + )} + + ), + [ + headerRowRef, + isCheckboxColumnPinned, + visibleHeaders, + pinnedLeftOffsets, + pinnedColumnCount, + columnWidths, + filters, + columnOptions, + onFilterChange, + onFilterClear, + sortData, + sortField, + sortDirection, + isAllSelected, + onToggleSelectAll, + onReverseSelection, + allRowIds, + selectionFilter, + dispatch, + orgUnitIdToName, + ] + ) + + return ( +
+ {spatialWarning && ( +
+ {i18n.t( + 'Location join over large datasets may be slow (over {{threshold}} features)', + { threshold: LARGE_FEATURE_THRESHOLD_LABEL } + )} +
+ )} + { + const rowId = getRowId(row) + const isSelected = !!rowId && selectedIdSet.has(rowId) + const isHovered = + !!rowId && + (rowId === hoveredRowId || rowId === mapHoveredRowId) + const cellsByDataKey = new Map( + row.map((cell) => [cell.dataKey, cell]) + ) + return ( + <> + + rowId && onCheckboxToggle(rowId, e) + } + /> + {visibleHeaders.map(({ dataKey }, index) => { + const cell = cellsByDataKey.get(dataKey) + if (!cell) { + return null + } + const { value, align } = cell + const { fixed, left, width, isLastPinned } = + getPinnedCellProps(dataKey, index, { + pinnedLeftOffsets, + pinnedColumnCount, + columnWidths, + }) + return ( + + + + ) + })} + + ) + }} + /> + { + setTableContextMenu(null) + onMenuClose(highlightChanged) + }} + /> +
+ ) +} + +CombinedDataTable.propTypes = { + joinConfig: PropTypes.shape({ + layers: PropTypes.object, + }).isRequired, + layers: PropTypes.array.isRequired, + referenceLayer: PropTypes.object.isRequired, + availableWidth: PropTypes.number, + columnConfig: PropTypes.shape({ + orderedKeys: PropTypes.arrayOf(PropTypes.string), + pinnedKeys: PropTypes.arrayOf(PropTypes.string), + visibleKeys: PropTypes.arrayOf(PropTypes.string), + }), + filters: PropTypes.object, + globalSearch: PropTypes.string, + onCountChange: PropTypes.func, + onFiltersChange: PropTypes.func, + onHeadersChange: PropTypes.func, +} + +export default CombinedDataTable diff --git a/src/components/datatable/CombinedTableContextMenu.jsx b/src/components/datatable/CombinedTableContextMenu.jsx new file mode 100644 index 0000000000..2a09e97191 --- /dev/null +++ b/src/components/datatable/CombinedTableContextMenu.jsx @@ -0,0 +1,112 @@ +import i18n from '@dhis2/d2-i18n' +import { Popover, Menu, MenuItem } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React, { useRef } from 'react' +import { useDispatch } from 'react-redux' +import { highlightFeature } from '../../actions/feature.js' +import { getUnionBounds, mergeCrossLayerIds } from '../../util/dataTable.js' +import { IconZoomIn16 } from '../core/icons.jsx' + +const CombinedTableContextMenu = ({ + contextMenu, + referenceLayer, + layers, + rowFeatureIds, + selectedIds, + filteredIds, + onClose, +}) => { + const anchorRef = useRef() + const dispatch = useDispatch() + + if (!contextMenu) { + return null + } + + const { x, y, rowId } = contextMenu + const entry = rowFeatureIds.get(rowId) ?? {} + const allLayers = [referenceLayer, ...layers] + + const zoomTo = (idsByLayerId) => { + dispatch( + highlightFeature({ + layerId: null, + origin: 'table', + zoom: true, + bounds: getUnionBounds(allLayers, idsByLayerId), + crossLayerIds: idsByLayerId, + }) + ) + onClose(true) + } + + return ( + <> +
+ + + } + disabled={!getUnionBounds(allLayers, entry)} + onClick={() => zoomTo(entry)} + /> + } + disabled={!selectedIds?.length} + onClick={() => + zoomTo( + mergeCrossLayerIds(selectedIds, rowFeatureIds) + ) + } + /> + } + disabled={!filteredIds?.length} + onClick={() => + zoomTo( + mergeCrossLayerIds(filteredIds, rowFeatureIds) + ) + } + /> + + + + ) +} + +CombinedTableContextMenu.propTypes = { + layers: PropTypes.array.isRequired, + referenceLayer: PropTypes.object.isRequired, + rowFeatureIds: PropTypes.instanceOf(Map).isRequired, + onClose: PropTypes.func.isRequired, + contextMenu: PropTypes.shape({ + rowId: PropTypes.string, + x: PropTypes.number, + y: PropTypes.number, + }), + filteredIds: PropTypes.array, + selectedIds: PropTypes.array, +} + +export default CombinedTableContextMenu diff --git a/src/components/datatable/__tests__/CombinedDataTable.spec.jsx b/src/components/datatable/__tests__/CombinedDataTable.spec.jsx new file mode 100644 index 0000000000..1f331df231 --- /dev/null +++ b/src/components/datatable/__tests__/CombinedDataTable.spec.jsx @@ -0,0 +1,1095 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import { VirtuosoMockContext } from 'react-virtuoso' +import configureMockStore from 'redux-mock-store' +import { COMBINED_HEADERS_KEY } from '../../../constants/dataTable.js' +import CombinedDataTable from '../CombinedDataTable.jsx' + +jest.mock('../../cachedDataProvider/CachedDataProvider.jsx', () => ({ + useCachedData: () => ({ + systemSettings: { keyAnalysisDigitGroupSeparator: 'COMMA' }, + }), +})) + +const mockStore = configureMockStore() + +const feature = (props) => ({ properties: props }) + +const referenceFeature = (id, name, path) => + feature({ id, name, orgUnitPath: path, level: 2 }) + +const referenceFeatureWithGeometry = ({ id, name, path, coordinates }) => ({ + type: 'Feature', + properties: { id, name, orgUnitPath: path, level: 2 }, + geometry: { type: 'Point', coordinates }, +}) + +const EMPTY_REFERENCE_LAYER = { + id: 'ref1', + layer: 'combinedTableRef', + data: [], +} + +const renderCombinedDataTable = (props, selection = {}) => { + const store = mockStore({ ui: {}, selection }) + const result = render( + + + + + + ) + return { ...result, store } +} + +describe('CombinedDataTable', () => { + test('renders a column header per computed header and a cell per row', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [ + feature({ + orgUnitPath: '/country1/ou1', + rawValue: 10, + legend: 'Low', + }), + ], + }, + ] + + renderCombinedDataTable({ + referenceLayer, + layers, + joinConfig: { + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, + columnConfig: { + visibleKeys: ['id', 'name', 'layerA_rawValue', 'layerA_legend'], + }, + }) + + expect(screen.getByText('Org unit id')).toBeInTheDocument() + expect(screen.getByText('Org unit')).toBeInTheDocument() + expect(screen.getByText('Value (Layer A)')).toBeInTheDocument() + expect(screen.getByText('Legend (Layer A)')).toBeInTheDocument() + expect(screen.getByText('Ou One')).toBeInTheDocument() + expect(screen.getByText('10')).toBeInTheDocument() + expect(screen.getByText('Low')).toBeInTheDocument() + }) + + test('exposes an Org unit hierarchy column, rendering the org unit path as a breadcrumb', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } + + renderCombinedDataTable({ + referenceLayer, + columnConfig: { + visibleKeys: ['id', 'name', 'orgUnitPath'], + }, + }) + + expect(screen.getByText('Org unit hierarchy')).toBeInTheDocument() + // Ancestor names resolve async; falls back to the raw ids until then + expect(screen.getByText('country1 / ou1')).toBeInTheDocument() + }) + + test('formats numeric values with the system digit group separator, matching DataTable', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [ + feature({ + orgUnitPath: '/country1/ou1', + rawValue: 1234567, + }), + ], + }, + ] + + renderCombinedDataTable({ + referenceLayer, + layers, + joinConfig: { + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, + }) + + expect(screen.getByText('1,234,567')).toBeInTheDocument() + }) + + test('renders an em-dash for blank cell values', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [feature({ orgUnitPath: '/country1/ou1' })], + }, + ] + + renderCombinedDataTable({ + referenceLayer, + layers, + joinConfig: { + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, + }) + + expect(screen.getAllByText('—').length).toBeGreaterThan(0) + }) + + test('shows the empty-results placeholder when there are no rows', () => { + renderCombinedDataTable() + + expect(screen.getByText('No matching rows')).toBeInTheDocument() + }) + + test('shows the spatial warning banner when a spatial join exceeds the large-feature threshold', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [ + { + type: 'Feature', + properties: { + id: 'poly1', + name: 'Region', + orgUnitPath: '/country1/poly1', + level: 2, + }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + [0, 0], + ], + ], + }, + }, + ], + } + const pointLayer = { + id: 'points', + name: 'Points', + combinedLayerKey: 'points', + data: Array.from({ length: 10001 }, (_, i) => ({ + type: 'Feature', + properties: { id: `p${i}` }, + geometry: { type: 'Point', coordinates: [1, 1] }, + })), + } + + renderCombinedDataTable({ + referenceLayer, + layers: [pointLayer], + joinConfig: { + layers: { + points: { + type: 'spatial', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, + }) + + expect( + screen.getByText(/Location join over large datasets may be slow/) + ).toBeInTheDocument() + }) + + test('calls onCountChange with the row count', () => { + const onCountChange = jest.fn() + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [ + referenceFeature('ou1', 'Ou One', '/country1/ou1'), + referenceFeature('ou2', 'Ou Two', '/country1/ou2'), + ], + } + + renderCombinedDataTable({ + referenceLayer, + onCountChange, + }) + + expect(onCountChange).toHaveBeenCalledWith(2, 2) + }) + + test('sorts rows when a column sort button is clicked', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [ + referenceFeature('ou1', 'Ou One', '/country1/ou1'), + referenceFeature('ou2', 'Ou Two', '/country1/ou2'), + ], + } + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [ + feature({ orgUnitPath: '/country1/ou1', rawValue: 20 }), + feature({ orgUnitPath: '/country1/ou2', rawValue: 10 }), + ], + }, + ] + + renderCombinedDataTable({ + referenceLayer, + layers, + joinConfig: { + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, + }) + + const rowsBefore = screen.getAllByRole('row').slice(1) + expect(rowsBefore[0]).toHaveTextContent('Ou One') + + fireEvent.click( + screen.getByTestId( + 'combined-table-column-sort-button-Value (Layer A)' + ) + ) + + const rowsAfter = screen.getAllByRole('row').slice(1) + expect(rowsAfter[0]).toHaveTextContent('Ou Two') + }) + + test('applies a per-column filter via onFiltersChange', () => { + const onFiltersChange = jest.fn() + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [ + referenceFeature('ou1', 'Ou One', '/country1/ou1'), + referenceFeature('ou2', 'Ou Two', '/country1/ou2'), + ], + } + + renderCombinedDataTable({ + referenceLayer, + filters: {}, + onFiltersChange, + columnConfig: { visibleKeys: ['id', 'name'] }, + }) + + const input = screen + .getByTestId('data-table-column-filter-search-Org unit id') + .querySelector('input') + fireEvent.focus(input) + fireEvent.change(input, { target: { value: 'ou1' } }) + + expect(onFiltersChange).toHaveBeenCalledWith({ id: 'ou1' }) + }) + + test('dispatches a cross-layer highlight on row hover, and clears it on mouse leave', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [ + feature({ + id: 'evtA1', + orgUnitPath: '/country1/ou1', + rawValue: 20, + }), + ], + }, + { + id: 'layerB', + name: 'Layer B', + combinedLayerKey: 'layerB', + data: [ + feature({ + id: 'evtB1', + orgUnitPath: '/country1/ou1', + rawValue: 5, + }), + ], + }, + ] + + const { store } = renderCombinedDataTable({ + referenceLayer, + layers, + joinConfig: { + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + layerB: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, + }) + + const dataRow = screen.getAllByRole('row')[1] + fireEvent.mouseEnter(dataRow) + + expect(store.getActions()).toContainEqual({ + type: 'FEATURE_HIGHLIGHT', + payload: { + layerId: null, + origin: 'table', + crossLayerIds: { + ref1: ['ou1'], + layerA: ['evtA1'], + layerB: ['evtB1'], + }, + }, + }) + + fireEvent.mouseLeave(dataRow, { relatedTarget: null }) + + expect(store.getActions()).toContainEqual({ + type: 'FEATURE_HIGHLIGHT', + payload: null, + }) + }) + + test('dispatches a merged cross-layer selection when rows are checked', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [ + referenceFeature('ou1', 'Ou One', '/country1/ou1'), + referenceFeature('ou2', 'Ou Two', '/country1/ou2'), + ], + } + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [ + feature({ + id: 'evt1', + orgUnitPath: '/country1/ou1', + rawValue: 20, + }), + feature({ + id: 'evt2', + orgUnitPath: '/country1/ou2', + rawValue: 10, + }), + ], + }, + ] + + const { store } = renderCombinedDataTable({ + referenceLayer, + layers, + joinConfig: { + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, + }) + + const checkboxes = screen.getAllByRole('checkbox') + // checkboxes[0] is the header "select all" checkbox + fireEvent.click(checkboxes[1]) + + expect(store.getActions()).toContainEqual({ + type: 'SELECTION_SET_CROSS_LAYER', + crossLayerIds: { ref1: ['ou1'], layerA: ['evt1'] }, + }) + }) + + test('does not clear the cross-layer selection on unmount, regardless of prior selection', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [feature({ id: 'evt1', orgUnitPath: '/country1/ou1' })], + }, + ] + + const { store, unmount } = renderCombinedDataTable({ + referenceLayer, + layers, + joinConfig: { + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, + }) + + fireEvent.click(screen.getAllByRole('checkbox')[1]) + const actionsBeforeUnmount = store.getActions().length + unmount() + + // Unmount still dispatches an unrelated setCombinedVisibleIds(null) + // cleanup, but must not dispatch another SELECTION_SET_CROSS_LAYER. + const actionsAfterUnmount = store + .getActions() + .slice(actionsBeforeUnmount) + expect(actionsAfterUnmount).not.toContainEqual( + expect.objectContaining({ type: 'SELECTION_SET_CROSS_LAYER' }) + ) + }) + + test('restores a previously-selected row as checked on mount, from the persisted cross-layer selection', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [feature({ id: 'evt1', orgUnitPath: '/country1/ou1' })], + }, + ] + + renderCombinedDataTable( + { + referenceLayer, + layers, + joinConfig: { + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, + }, + { crossLayerIds: { ref1: ['ou1'], layerA: ['evt1'] } } + ) + + expect(screen.getAllByRole('checkbox')[1]).toBeChecked() + }) + + test('reports computed headers up via onHeadersChange, keyed by the combined sentinel', () => { + const onHeadersChange = jest.fn() + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [feature({ orgUnitPath: '/country1/ou1', rawValue: 1 })], + }, + ] + + renderCombinedDataTable({ + referenceLayer, + layers, + joinConfig: { + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, + onHeadersChange, + }) + + expect(onHeadersChange).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ dataKey: 'id' }), + ]), + COMBINED_HEADERS_KEY + ) + }) + + test('hides a column excluded from columnConfig.visibleKeys', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [feature({ orgUnitPath: '/country1/ou1', rawValue: 20 })], + }, + ] + + renderCombinedDataTable({ + referenceLayer, + layers, + joinConfig: { + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, + columnConfig: { visibleKeys: ['id', 'name'] }, + }) + + expect(screen.getByText('Org unit id')).toBeInTheDocument() + expect(screen.queryByText('Value (Layer A)')).not.toBeInTheDocument() + expect(screen.queryByText('20')).not.toBeInTheDocument() + }) + + test('hides Org unit id and Org unit level by default when no columnConfig is given', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } + + renderCombinedDataTable({ referenceLayer }) + + expect(screen.getByText('Org unit')).toBeInTheDocument() + expect(screen.queryByText('Org unit id')).not.toBeInTheDocument() + expect(screen.queryByText('Org unit level')).not.toBeInTheDocument() + }) + + test('reorders columns to put pinned keys first via columnConfig.pinnedKeys', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [feature({ orgUnitPath: '/country1/ou1', rawValue: 20 })], + }, + ] + + renderCombinedDataTable({ + referenceLayer, + layers, + joinConfig: { + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, + columnConfig: { + pinnedKeys: ['level'], + visibleKeys: [ + 'level', + 'name', + 'layerA_rawValue', + 'layerA_legend', + ], + }, + }) + + const headerNames = screen + .getAllByRole('columnheader') + .map((el) => el.textContent) + .filter(Boolean) + // headerNames[0] is the selection column's own filter button label + // ("All"/"N selected") - the first real data column follows it. + expect(headerNames[1]).toBe('Org unit level') + }) + + test('dispatches setSelectionFilter when a selection-filter option is toggled', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } + + const { store } = renderCombinedDataTable({ referenceLayer }) + + fireEvent.click( + screen.getByTestId('data-table-selection-filter-button') + ) + fireEvent.click(screen.getByText('Selected')) + + expect(store.getActions()).toContainEqual({ + type: 'SELECTION_FILTER_SET', + value: ['selected'], + }) + }) + + test('zooms to the row union bounds on row double-click', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [ + referenceFeatureWithGeometry({ + id: 'ou1', + name: 'Ou One', + path: '/country1/ou1', + coordinates: [10, 20], + }), + ], + } + + const { store } = renderCombinedDataTable({ referenceLayer }) + + const dataRow = screen.getAllByRole('row')[1] + fireEvent.doubleClick(dataRow) + + expect(store.getActions()).toContainEqual({ + type: 'FEATURE_HIGHLIGHT', + payload: { + layerId: null, + origin: 'table', + zoom: true, + bounds: [ + [10, 20], + [10, 20], + ], + crossLayerIds: { ref1: ['ou1'] }, + }, + }) + }) + + test('highlights the table row matching a feature hovered directly on the map', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [ + feature({ + id: 'evtA1', + orgUnitPath: '/country1/ou1', + rawValue: 20, + }), + ], + }, + ] + + const store = mockStore({ + ui: {}, + selection: {}, + feature: { id: 'evtA1', layerId: 'layerA', origin: 'map' }, + }) + render( + + + + + + ) + + const dataRow = screen.getAllByRole('row')[1] + expect(dataRow.querySelector('.hovered')).toBeInTheDocument() + }) + + test('ignores a stale single-layer table hover left over in state.feature', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [ + feature({ + id: 'evtA1', + orgUnitPath: '/country1/ou1', + rawValue: 20, + }), + ], + }, + ] + + const store = mockStore({ + ui: {}, + selection: {}, + feature: { id: 'evtA1', layerId: 'layerA', origin: 'table' }, + }) + render( + + + + + + ) + + const dataRow = screen.getAllByRole('row')[1] + expect(dataRow.querySelector('.hovered')).not.toBeInTheDocument() + }) + + test('a plain map click on a joined feature does not select its row', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [ + feature({ + id: 'evtA1', + orgUnitPath: '/country1/ou1', + rawValue: 20, + }), + ], + }, + ] + + const store = mockStore({ + ui: { + lastClickedFeature: { + id: 'evtA1', + layerId: 'layerA', + multiSelect: false, + }, + }, + selection: {}, + }) + render( + + + + + + ) + + expect(store.getActions()).not.toContainEqual( + expect.objectContaining({ type: 'SELECTION_SET_CROSS_LAYER' }) + ) + }) + + test('a ctrl/multiSelect map click on a joined feature selects its row', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [ + feature({ + id: 'evtA1', + orgUnitPath: '/country1/ou1', + rawValue: 20, + }), + ], + }, + ] + + const store = mockStore({ + ui: { + lastClickedFeature: { + id: 'evtA1', + layerId: 'layerA', + multiSelect: true, + }, + }, + selection: {}, + }) + render( + + + + + + ) + + expect(store.getActions()).toContainEqual({ + type: 'SELECTION_SET_CROSS_LAYER', + crossLayerIds: { ref1: ['ou1'], layerA: ['evtA1'] }, + }) + }) + + test('a map click on a feature belonging to no joined row does nothing', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } + + const store = mockStore({ + ui: { + lastClickedFeature: { + id: 'unrelated', + layerId: 'someOtherLayer', + multiSelect: true, + }, + }, + selection: {}, + }) + render( + + + + + + ) + + expect(store.getActions()).not.toContainEqual( + expect.objectContaining({ type: 'SELECTION_SET_CROSS_LAYER' }) + ) + }) + + describe('map visibility from column filters and global search', () => { + const twoOuReferenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [ + referenceFeature('ou1', 'Ou One', '/country1/ou1'), + referenceFeature('ou2', 'Ou Two', '/country1/ou2'), + ], + } + const layerA = { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [ + feature({ + id: 'evtA1', + orgUnitPath: '/country1/ou1', + rawValue: 10, + }), + feature({ + id: 'evtA2', + orgUnitPath: '/country1/ou2', + rawValue: 20, + }), + ], + } + const joinConfig = { + layers: { + layerA: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + } + + test('dispatches combinedVisibleIds narrowed to the filtered rows when a column filter is active', () => { + const { store } = renderCombinedDataTable({ + referenceLayer: twoOuReferenceLayer, + layers: [layerA], + joinConfig, + filters: { id: 'ou1' }, + }) + + expect(store.getActions()).toContainEqual({ + type: 'COMBINED_VISIBLE_IDS_SET', + idsByLayer: { ref1: ['ou1'], layerA: ['evtA1'] }, + }) + }) + + test('dispatches an empty array (hide everything) for a layer with no surviving matches', () => { + const { store } = renderCombinedDataTable({ + referenceLayer: twoOuReferenceLayer, + layers: [layerA], + joinConfig, + filters: { id: 'ou2' }, + }) + + expect(store.getActions()).toContainEqual({ + type: 'COMBINED_VISIBLE_IDS_SET', + idsByLayer: { ref1: ['ou2'], layerA: ['evtA2'] }, + }) + }) + + test('narrows by global search too', () => { + const { store } = renderCombinedDataTable({ + referenceLayer: twoOuReferenceLayer, + layers: [layerA], + joinConfig, + globalSearch: 'Ou One', + }) + + expect(store.getActions()).toContainEqual({ + type: 'COMBINED_VISIBLE_IDS_SET', + idsByLayer: { ref1: ['ou1'], layerA: ['evtA1'] }, + }) + }) + + test('dispatches null (show everything) when no column filter or search is active', () => { + const { store } = renderCombinedDataTable({ + referenceLayer: twoOuReferenceLayer, + layers: [layerA], + joinConfig, + }) + + expect(store.getActions()).toContainEqual({ + type: 'COMBINED_VISIBLE_IDS_SET', + idsByLayer: null, + }) + }) + + test('does not narrow map visibility from the selection filter alone', () => { + const store = mockStore({ + ui: { selectionFilter: ['selected'] }, + selection: {}, + }) + render( + + + + + + ) + + expect(store.getActions()).toContainEqual({ + type: 'COMBINED_VISIBLE_IDS_SET', + idsByLayer: null, + }) + }) + + test('resets combinedVisibleIds to null on unmount', () => { + const { store, unmount } = renderCombinedDataTable({ + referenceLayer: twoOuReferenceLayer, + layers: [layerA], + joinConfig, + filters: { id: 'ou1' }, + }) + + unmount() + + const actions = store.getActions() + expect(actions[actions.length - 1]).toEqual({ + type: 'COMBINED_VISIBLE_IDS_SET', + idsByLayer: null, + }) + }) + }) +}) diff --git a/src/components/datatable/__tests__/CombinedTableContextMenu.spec.jsx b/src/components/datatable/__tests__/CombinedTableContextMenu.spec.jsx new file mode 100644 index 0000000000..7e9f6d0515 --- /dev/null +++ b/src/components/datatable/__tests__/CombinedTableContextMenu.spec.jsx @@ -0,0 +1,103 @@ +import { render, fireEvent, screen } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import configureMockStore from 'redux-mock-store' +import { FEATURE_HIGHLIGHT } from '../../../constants/actionTypes.js' +import { THEMATIC_LAYER } from '../../../constants/layers.js' +import CombinedTableContextMenu from '../CombinedTableContextMenu.jsx' + +const mockStore = configureMockStore() + +const point = (id, coordinates, properties = {}) => ({ + type: 'Feature', + properties: { id, ...properties }, + geometry: { type: 'Point', coordinates }, +}) + +const referenceLayer = { + id: 'ref1', + layer: 'combinedTableRef', + data: [point('ou1', [0, 0])], +} + +const layers = [ + { + id: 'layerA', + layer: THEMATIC_LAYER, + data: [point('a1', [5, 5])], + }, +] + +// rowFeatureIds always names the reference layer's own feature id alongside +// whichever participating layers matched - see useCombinedTableData.js. +const rowFeatureIds = new Map([['ou1', { ref1: ['ou1'], layerA: ['a1'] }]]) + +const contextMenu = { x: 10, y: 10, rowId: 'ou1' } + +const getLink = (testId) => screen.getByTestId(testId).querySelector('a') + +const renderMenu = (props) => { + const store = mockStore({}) + const result = render( + + + + ) + return { ...result, store } +} + +describe('CombinedTableContextMenu — zoom actions', () => { + test('zoom to feature dispatches a crossLayerIds highlight with the union bounds across the reference and participating layers', () => { + const onClose = jest.fn() + const { store } = renderMenu({ onClose }) + fireEvent.click(getLink('combined-table-context-menu-zoom-to-feature')) + expect(store.getActions()).toContainEqual({ + type: FEATURE_HIGHLIGHT, + payload: { + layerId: null, + origin: 'table', + zoom: true, + bounds: [ + [0, 0], + [5, 5], + ], + crossLayerIds: { ref1: ['ou1'], layerA: ['a1'] }, + }, + }) + expect(onClose).toHaveBeenCalledWith(true) + }) + + test('zoom to selected features is disabled when nothing is selected', () => { + renderMenu({ selectedIds: [] }) + expect( + getLink('combined-table-context-menu-zoom-to-selected') + ).toHaveAttribute('aria-disabled', 'true') + }) + + test('zoom to selected features merges every selected row before zooming', () => { + const { store } = renderMenu({ selectedIds: ['ou1'] }) + fireEvent.click(getLink('combined-table-context-menu-zoom-to-selected')) + expect(store.getActions()).toContainEqual( + expect.objectContaining({ + type: FEATURE_HIGHLIGHT, + payload: expect.objectContaining({ + crossLayerIds: { ref1: ['ou1'], layerA: ['a1'] }, + }), + }) + ) + }) + + test('zoom to filtered features is disabled when filteredIds is null (no active filter)', () => { + renderMenu({ filteredIds: null }) + expect( + getLink('combined-table-context-menu-zoom-to-filtered') + ).toHaveAttribute('aria-disabled', 'true') + }) +}) diff --git a/src/components/datatable/styles/CombinedDataTable.module.css b/src/components/datatable/styles/CombinedDataTable.module.css new file mode 100644 index 0000000000..7c5a5dabac --- /dev/null +++ b/src/components/datatable/styles/CombinedDataTable.module.css @@ -0,0 +1,24 @@ +.container { + height: 100%; + display: flex; + flex-direction: column; +} + +.noResults { + display: flex; + color: var(--colors-grey600); + align-items: center; + justify-content: center; + font-size: 12px; + font-style: italic; + min-height: 40px; +} + +.spatialWarning { + flex-shrink: 0; + padding: var(--spacers-dp4) var(--spacers-dp8); + background-color: var(--colors-yellow100); + color: var(--colors-yellow800); + font-size: 12px; + border-bottom: 1px solid var(--colors-yellow300); +} From b592519888f1e70cc55713be67768fafbece00f2 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 10 Sep 2026 13:14:25 +0200 Subject: [PATCH 7/9] feat: wire the Combined data table into BottomPanel [DHIS2-20543] Adds the "Combined" pseudo-option to the layer selector, the join-layers popover (JoinLayersControl/LayerRow), and swaps in CombinedDataTable for DataTable when combinedView is active, integrated with this branch's existing debounced-search and usePanelHeights hooks rather than the old inline implementations from the source branch. Fixed beyond the straight port: JoinLayersControl's isServerClustered computed a bare `undefined` (not `false`) for layers without a serverCluster field, tripping LayerRow's required-bool PropTypes check; coerced with `!!(...)`. Also restored/added test coverage the port's verbatim BottomPanel.spec.jsx replacement left gaps in: the stale activeLayerId fallback, the Combined-to-single-layer selection transition, and the single-layer-mode Clear-filters branch. Confirmed NOT a bug, despite two review agents disagreeing: reducers/map.js's DATA_TABLE_COMBINED_VIEW_TOGGLE case (Step 1) synchronously creates the placeholder reference mapView in the same dispatch that flips dataTable.combinedView to true, so CombinedDataTable's referenceLayer prop is never actually undefined while combinedView is true - verified by reading both reducer cases directly rather than trusting either agent's trace. Co-Authored-By: Claude Sonnet 5 --- src/components/datatable/BottomPanel.jsx | 206 +++- .../datatable/__tests__/BottomPanel.spec.jsx | 670 ++++++++++- .../__tests__/JoinLayersControl.spec.jsx | 1020 +++++++++++++++++ .../datatable/__tests__/LayerRow.spec.jsx | 78 ++ .../datatable/controls/JoinLayersControl.jsx | 241 ++++ .../datatable/controls/LayerRow.jsx | 374 ++++++ .../controls/LayerSelectorControl.jsx | 22 +- .../styles/JoinLayersControl.module.css | 135 +++ .../datatable/styles/BottomPanel.module.css | 7 + .../__tests__/aggregationTypes.spec.js | 19 + src/constants/aggregationTypes.js | 10 + src/constants/dataTable.js | 1 + 12 files changed, 2701 insertions(+), 82 deletions(-) create mode 100644 src/components/datatable/__tests__/JoinLayersControl.spec.jsx create mode 100644 src/components/datatable/__tests__/LayerRow.spec.jsx create mode 100644 src/components/datatable/controls/JoinLayersControl.jsx create mode 100644 src/components/datatable/controls/LayerRow.jsx create mode 100644 src/components/datatable/controls/styles/JoinLayersControl.module.css create mode 100644 src/constants/__tests__/aggregationTypes.spec.js diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 458f65b264..d231b93873 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -1,3 +1,5 @@ +import i18n from '@dhis2/d2-i18n' +import { IconWarningFilled16, Tooltip } from '@dhis2/ui' import React, { useRef, useCallback, @@ -17,21 +19,31 @@ import { toggleDataTable, setDataTableColumnConfig, setActiveDataTableLayer, + toggleCombinedView, + setJoinConfig, + setCombinedColumnConfig, } from '../../actions/dataTable.js' +import { COMBINED_HEADERS_KEY } from '../../constants/dataTable.js' import useDebouncedValue from '../../hooks/useDebouncedValue.js' import useKeyDown from '../../hooks/useKeyDown.js' +import { getOrgUnitsFromRows } from '../../util/analytics.js' import { getEligibleDataTableLayers, hasActiveDataTableFilters, } from '../../util/dataTable.js' import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' +import CombinedDataTable from './CombinedDataTable.jsx' import ClearFiltersControl from './controls/ClearFiltersControl.jsx' import CloseControl from './controls/CloseControl.jsx' import CollapseControl from './controls/CollapseControl.jsx' import ColumnPickerControl from './controls/ColumnPickerControl.jsx' import GlobalSearchControl from './controls/GlobalSearchControl.jsx' import HighlightColorControl from './controls/HighlightColorControl.jsx' +import JoinLayersControl from './controls/JoinLayersControl.jsx' import LayerSelectorControl from './controls/LayerSelectorControl.jsx' +import ReferenceOrgUnitControl, { + useReferenceLayer, +} from './controls/ReferenceOrgUnitControl.jsx' import ResizeHandleControl from './controls/ResizeHandleControl.jsx' import RowCountControl from './controls/RowCountControl.jsx' import ShowInViewControl from './controls/ShowInViewControl.jsx' @@ -42,14 +54,17 @@ import { usePanelHeights } from './usePanelHeights.js' const MIN_HEIGHT = 50 const EMPTY_FILTERS = {} +const EMPTY_JOIN_LAYERS = {} const BottomPanel = () => { const { systemSettings: { keyAnalysisDigitGroupSeparator }, } = useCachedData() - const { openIds, activeLayerId: storedActiveLayerId } = useSelector( - (state) => state.dataTable - ) + const { + openIds, + combinedView, + activeLayerId: storedActiveLayerId, + } = useSelector((state) => state.dataTable) const mapViews = useSelector((state) => state.map.mapViews) const activeLayerId = storedActiveLayerId && openIds.includes(storedActiveLayerId) @@ -72,6 +87,36 @@ const BottomPanel = () => { const selectionFilter = useSelector((state) => state.ui.selectionFilter) const highlightColor = useSelector((state) => state.ui.highlightColor) + const { referenceLayer, openReferenceLayerEditor } = useReferenceLayer() + const combinedEnabled = + !!referenceLayer && getOrgUnitsFromRows(referenceLayer.rows).length > 0 + + const joinLayersConfig = + referenceLayer?.combinedJoinConfig ?? EMPTY_JOIN_LAYERS + const combinedJoinConfig = useMemo( + () => ({ layers: joinLayersConfig }), + [joinLayersConfig] + ) + const combinedColumnConfig = referenceLayer?.combinedColumnConfig ?? null + const combinedLayers = useMemo( + () => mapViews.filter((l) => joinLayersConfig[l.combinedLayerKey]), + [mapViews, joinLayersConfig] + ) + const filteredCombinedLayers = useMemo( + () => + combinedLayers.filter( + (l) => Object.keys(l.dataFilters ?? {}).length > 0 + ), + [combinedLayers] + ) + const serverClusteredCombinedLayers = useMemo( + () => + combinedLayers.filter( + (l) => l.serverCluster && !l.forceClientCluster + ), + [combinedLayers] + ) + const dispatch = useDispatch() const panelRef = useRef(null) const isDraggingRef = useRef(false) @@ -83,9 +128,10 @@ const BottomPanel = () => { const [searchInputValue, setSearchInputValue] = useState('') const globalSearch = useDebouncedValue(searchInputValue, 200) const [headersByLayer, setHeadersByLayer] = useState(null) + const [combinedFilters, setCombinedFilters] = useState(EMPTY_FILTERS) const hasActiveFilters = hasActiveDataTableFilters({ - dataFilters, + dataFilters: combinedView ? combinedFilters : dataFilters, globalSearch: searchInputValue, selectionFilter, showOnlyFeaturesInView, @@ -159,13 +205,18 @@ const BottomPanel = () => { setHeadersByLayer({ layerId, headers }) }, []) + const activeHeadersKey = combinedView ? COMBINED_HEADERS_KEY : activeLayerId const allHeaders = - headersByLayer?.layerId === activeLayerId + headersByLayer?.layerId === activeHeadersKey ? headersByLayer.headers : null const onClearFilters = useCallback(() => { - dispatch(clearDataFilters(activeLayerId)) + if (combinedView) { + setCombinedFilters(EMPTY_FILTERS) + } else { + dispatch(clearDataFilters(activeLayerId)) + } if (showOnlyFeaturesInView) { dispatch(toggleShowOnlyFeaturesInView()) } @@ -173,7 +224,13 @@ const BottomPanel = () => { dispatch(setSelectionFilter([])) } setSearchInputValue('') - }, [dispatch, activeLayerId, showOnlyFeaturesInView, selectionFilter]) + }, [ + dispatch, + activeLayerId, + showOnlyFeaturesInView, + selectionFilter, + combinedView, + ]) const onToggleShowOnlyFeaturesInView = useCallback(() => { dispatch(toggleShowOnlyFeaturesInView()) @@ -242,28 +299,112 @@ const BottomPanel = () => { { dispatch(setActiveDataTableLayer(id)) + if (combinedView) { + dispatch(toggleCombinedView()) + } if (!openIds.includes(id)) { dispatch(toggleDataTable(id)) } }} + onSelectCombined={() => { + if (!combinedView) { + dispatch(toggleCombinedView()) + } + if (!combinedEnabled) { + openReferenceLayerEditor() + } + }} /> - - dispatch( - setDataTableColumnConfig(activeLayerId, config) - ) - } - /> - + {combinedView ? ( + <> + + dispatch( + setCombinedColumnConfig( + referenceLayer.id, + config + ) + ) + } + /> + + dispatch( + setJoinConfig(referenceLayer.id, layers) + ) + } + /> + {filteredCombinedLayers.length > 0 && ( + l.name) + .join(', '), + } + )} + > + + + + + )} + {serverClusteredCombinedLayers.length > 0 && ( + l.name) + .join(', '), + } + )} + > + + + + + )} + + + + ) : ( + <> + + dispatch( + setDataTableColumnConfig( + activeLayerId, + config + ) + ) + } + /> + + + )} {
- + {combinedView ? ( + + ) : ( + + )}
diff --git a/src/components/datatable/__tests__/BottomPanel.spec.jsx b/src/components/datatable/__tests__/BottomPanel.spec.jsx index 4dbbe13d8b..ce8d4bd1fa 100644 --- a/src/components/datatable/__tests__/BottomPanel.spec.jsx +++ b/src/components/datatable/__tests__/BottomPanel.spec.jsx @@ -2,21 +2,36 @@ import { render, fireEvent, screen } from '@testing-library/react' import React from 'react' import { Provider } from 'react-redux' import configureMockStore from 'redux-mock-store' +import { SENTINEL_COMBINED_VALUE } from '../../../constants/dataTable.js' import { THEMATIC_LAYER } from '../../../constants/layers.js' import WindowDimensionsProvider from '../../WindowDimensionsProvider.jsx' import BottomPanel from '../BottomPanel.jsx' +jest.mock('../../cachedDataProvider/CachedDataProvider.jsx', () => ({ + useCachedData: () => ({ + systemSettings: { keyAnalysisDigitGroupSeparator: 'COMMA' }, + }), +})) + jest.mock('../DataTable.jsx', () => { - const DataTableMock = () =>
+ // eslint-disable-next-line react/prop-types + const DataTableMock = ({ activeLayerId }) => ( +
{activeLayerId}
+ ) DataTableMock.displayName = 'DataTableMock' return DataTableMock }) -jest.mock('../../cachedDataProvider/CachedDataProvider.jsx', () => ({ - useCachedData: () => ({ - systemSettings: { keyAnalysisDigitGroupSeparator: ',' }, - }), -})) +const mockJoinConfigCalls = [] +jest.mock('../CombinedDataTable.jsx', () => { + // eslint-disable-next-line react/prop-types + const CombinedDataTableMock = ({ joinConfig }) => { + mockJoinConfigCalls.push(joinConfig) + return
+ } + CombinedDataTableMock.displayName = 'CombinedDataTableMock' + return CombinedDataTableMock +}) const mockStore = configureMockStore() @@ -33,22 +48,44 @@ beforeAll(() => { const DATA_TABLE_HEIGHT = 300 -const renderBottomPanel = ({ dataTable, mapViews } = {}) => { +const DEFAULT_DATA_TABLE_STATE = { + openIds: ['layer1'], + combinedView: false, +} + +const DEFAULT_MAP_VIEWS = [ + { + id: 'layer1', + name: 'Layer 1', + layer: THEMATIC_LAYER, + isLoaded: true, + data: [{}], + }, +] + +const referenceLayer = ( + rows = [{ dimension: 'ou', items: [{ id: 'country1' }] }] +) => ({ + id: 'ref1', + layer: 'combinedTableRef', + rows, +}) + +const renderBottomPanel = ({ + dataTable = DEFAULT_DATA_TABLE_STATE, + mapViews = DEFAULT_MAP_VIEWS, + ui = {}, +} = {}) => { const store = mockStore({ ui: { dataTableHeight: DATA_TABLE_HEIGHT, showOnlyFeaturesInView: false, selectionFilter: [], highlightColor: null, + ...ui, }, - dataTable: dataTable ?? { - openIds: ['layer1'], - activeLayerId: 'layer1', - isPanelVisible: true, - }, - map: { - mapViews: mapViews ?? [{ id: 'layer1', name: 'Layer 1' }], - }, + dataTable, + map: { mapViews }, }) const { container } = render( @@ -57,7 +94,11 @@ const renderBottomPanel = ({ dataTable, mapViews } = {}) => { ) - return { handle: container.querySelector('.resizeHandle'), store } + return { + handle: container.querySelector('.resizeHandle'), + container, + store, + } } const getDisplayHeight = () => @@ -93,85 +134,606 @@ describe('BottomPanel resize cancel', () => { }) }) -describe('BottomPanel layer selection', () => { - const eligibleLayer = (id, name) => ({ - id, - name, +describe('BottomPanel double-click to collapse', () => { + test('double-clicking empty toolbar space toggles the collapsed state', () => { + const { container } = renderBottomPanel() + expect(getDisplayHeight()).toBe(`${DATA_TABLE_HEIGHT}px`) + + fireEvent.doubleClick(container.querySelector('.dataTableControls')) + + expect(getDisplayHeight()).not.toBe(`${DATA_TABLE_HEIGHT}px`) + }) + + test('double-clicking the layer selector does not toggle the collapsed state', () => { + renderBottomPanel() + expect(getDisplayHeight()).toBe(`${DATA_TABLE_HEIGHT}px`) + + fireEvent.doubleClick(getLayerSelector()) + + expect(getDisplayHeight()).toBe(`${DATA_TABLE_HEIGHT}px`) + }) + + test('double-clicking inside an open popover (e.g. Join layers) does not toggle the collapsed state, even on non-control content like the popover background', () => { + renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: [...twoEligibleLayers, referenceLayer()], + }) + expect(getDisplayHeight()).toBe(`${DATA_TABLE_HEIGHT}px`) + + fireEvent.click(screen.getByLabelText('Choose layers to combine')) + // Rendered via a portal, outside renderBottomPanel()'s own container + const popover = document.querySelector('.joinLayersPopover') + expect(popover).toBeInTheDocument() + + fireEvent.doubleClick(popover) + + expect(getDisplayHeight()).toBe(`${DATA_TABLE_HEIGHT}px`) + }) +}) + +const twoEligibleLayers = [ + { + id: 'layer1', + name: 'Layer 1', + combinedLayerKey: 'layer1', layer: THEMATIC_LAYER, isLoaded: true, - data: [{}], + data: [{ properties: { orgUnitPath: '/country1/ou1' } }], + }, + { + id: 'layer2', + name: 'Layer 2', + combinedLayerKey: 'layer2', + layer: THEMATIC_LAYER, + isLoaded: true, + data: [{ properties: { orgUnitPath: '/country1/ou2' } }], + }, +] + +const getLayerSelector = () => screen.getByTestId('data-table-layer-selector') + +describe('BottomPanel layer selector', () => { + test('lists every eligible layer, whether or not its table is open, plus a Combined option', () => { + renderBottomPanel({ + dataTable: DEFAULT_DATA_TABLE_STATE, + mapViews: twoEligibleLayers, + }) + + expect(screen.getByText('Layer 1')).toBeInTheDocument() + expect(screen.getByText('Layer 2')).toBeInTheDocument() + expect(screen.getByText('Combined')).toBeInTheDocument() }) - const mapViews = [ - eligibleLayer('layer1', 'Layer 1'), - eligibleLayer('layer2', 'Layer 2'), - ] - test('restores the stored activeLayerId as the selected value on mount', () => { + test('keeps an already-open tab listed even if its config is edited into a state where isLoaded gets stuck false', () => { + const stuckLayer = { + id: 'layer1', + name: 'Layer 1', + layer: THEMATIC_LAYER, + isLoaded: false, + data: [], + } renderBottomPanel({ dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1'], + }, + mapViews: [stuckLayer, twoEligibleLayers[1]], + }) + + expect(screen.getByText('Layer 1')).toBeInTheDocument() + expect(screen.getByText('Layer 2')).toBeInTheDocument() + }) + + test('selecting a different, already-open layer dispatches DATA_TABLE_ACTIVE_LAYER_SET', () => { + const { store } = renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, openIds: ['layer1', 'layer2'], - activeLayerId: 'layer2', - isPanelVisible: true, }, - mapViews, + mapViews: twoEligibleLayers, }) - expect(screen.getByTestId('data-table-layer-selector')).toHaveValue( - 'layer2' - ) + fireEvent.change(getLayerSelector(), { target: { value: 'layer1' } }) + + expect(store.getActions()).toEqual([ + { type: 'DATA_TABLE_ACTIVE_LAYER_SET', id: 'layer1' }, + ]) }) - test('falls back to the last open tab when the stored activeLayerId is stale (e.g. its layer was removed)', () => { + test('shows the persisted activeLayerId as the active tab on mount, not just the last-opened one', () => { renderBottomPanel({ dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + activeLayerId: 'layer1', + }, + mapViews: twoEligibleLayers, + }) + + expect(screen.getByTestId('datatable-mock')).toHaveTextContent('layer1') + }) + + test('falls back to the last-opened layer when the persisted activeLayerId is no longer open', () => { + renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + activeLayerId: 'layer3', + }, + mapViews: twoEligibleLayers, + }) + + expect(screen.getByTestId('datatable-mock')).toHaveTextContent('layer2') + }) + + test('selecting a layer that has not been opened yet opens it and makes it active', () => { + const { store } = renderBottomPanel({ + dataTable: DEFAULT_DATA_TABLE_STATE, + mapViews: twoEligibleLayers, + }) + + fireEvent.change(getLayerSelector(), { target: { value: 'layer2' } }) + + expect(store.getActions()).toEqual([ + { type: 'DATA_TABLE_ACTIVE_LAYER_SET', id: 'layer2' }, + { type: 'DATA_TABLE_TOGGLE', id: 'layer2' }, + ]) + }) + + test('sets the active layer but does not re-dispatch toggleDataTable when selecting an already-open layer', () => { + const { store } = renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, openIds: ['layer1', 'layer2'], - activeLayerId: 'removed-layer', - isPanelVisible: true, }, - mapViews, + mapViews: twoEligibleLayers, }) - expect(screen.getByTestId('data-table-layer-selector')).toHaveValue( - 'layer2' + fireEvent.change(getLayerSelector(), { target: { value: 'layer1' } }) + + expect(store.getActions()).toEqual([ + { type: 'DATA_TABLE_ACTIVE_LAYER_SET', id: 'layer1' }, + ]) + }) + + test('the active layer is correct on the very first render, with no transient null in between', () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}) + + renderBottomPanel() + + expect(screen.getByTestId('datatable-mock')).toHaveTextContent('layer1') + const layerIdWarnings = consoleError.mock.calls.filter((args) => + args.some( + (arg) => typeof arg === 'string' && arg.includes('layerId') + ) ) + expect(layerIdWarnings).toEqual([]) + + consoleError.mockRestore() }) - test('selecting an already-open layer dispatches setActiveDataTableLayer only', () => { + test('selecting Combined dispatches DATA_TABLE_COMBINED_VIEW_TOGGLE, and nothing else, once a reference with org units already exists', () => { const { store } = renderBottomPanel({ dataTable: { + ...DEFAULT_DATA_TABLE_STATE, openIds: ['layer1', 'layer2'], - activeLayerId: 'layer1', - isPanelVisible: true, }, - mapViews, + mapViews: [...twoEligibleLayers, referenceLayer()], }) - fireEvent.change(screen.getByTestId('data-table-layer-selector'), { - target: { value: 'layer2' }, + fireEvent.change(getLayerSelector(), { + target: { value: SENTINEL_COMBINED_VALUE }, }) expect(store.getActions()).toEqual([ - { type: 'DATA_TABLE_ACTIVE_LAYER_SET', id: 'layer2' }, + { type: 'DATA_TABLE_COMBINED_VIEW_TOGGLE' }, ]) }) - test('selecting an eligible-but-not-open layer also opens it', () => { + test('selecting Combined also opens a draft reference layer editor when none exists yet', () => { const { store } = renderBottomPanel({ dataTable: { - openIds: ['layer1'], - activeLayerId: 'layer1', - isPanelVisible: true, + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], }, - mapViews, + mapViews: twoEligibleLayers, }) - fireEvent.change(screen.getByTestId('data-table-layer-selector'), { - target: { value: 'layer2' }, + fireEvent.change(getLayerSelector(), { + target: { value: SENTINEL_COMBINED_VALUE }, }) expect(store.getActions()).toEqual([ - { type: 'DATA_TABLE_ACTIVE_LAYER_SET', id: 'layer2' }, - { type: 'DATA_TABLE_TOGGLE', id: 'layer2' }, + { type: 'DATA_TABLE_COMBINED_VIEW_TOGGLE' }, + { + type: 'LAYER_EDIT', + payload: { + layer: 'combinedTableRef', + isVisible: false, + rows: [], + }, + }, ]) }) + + test('selecting Combined opens the existing reference layer editor when it has no org units selected yet', () => { + const emptyReference = referenceLayer([]) + const { store } = renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + }, + mapViews: [...twoEligibleLayers, emptyReference], + }) + + fireEvent.change(getLayerSelector(), { + target: { value: SENTINEL_COMBINED_VALUE }, + }) + + expect(store.getActions()).toEqual([ + { type: 'DATA_TABLE_COMBINED_VIEW_TOGGLE' }, + { type: 'LAYER_EDIT', payload: emptyReference }, + ]) + }) + + test('selecting an already-open layer while Combined is active switches back out of Combined view', () => { + const { store } = renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: [...twoEligibleLayers, referenceLayer()], + }) + + fireEvent.change(getLayerSelector(), { target: { value: 'layer1' } }) + + expect(store.getActions()).toEqual([ + { type: 'DATA_TABLE_ACTIVE_LAYER_SET', id: 'layer1' }, + { type: 'DATA_TABLE_COMBINED_VIEW_TOGGLE' }, + ]) + }) +}) + +describe('BottomPanel Combined join controls', () => { + const combinedMapViews = [...twoEligibleLayers, referenceLayer()] + + test('shows the reference org unit control and join layers control while Combined is active', () => { + renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: combinedMapViews, + }) + + expect( + screen.getByLabelText('Configure reference org units') + ).toBeInTheDocument() + expect( + screen.getByLabelText('Choose layers to combine') + ).toBeInTheDocument() + }) + + test('still shows the highlight color and show-in-view controls while Combined is active - they are not per-layer-only', () => { + const { container } = renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: combinedMapViews, + }) + + expect( + container.querySelector('input[type="color"]') + ).toBeInTheDocument() + expect( + screen.getByLabelText('Show only features in current map view') + ).toBeInTheDocument() + }) + + test('the Clear filters button is enabled in Combined mode when "show only features in view" is active, and clicking it turns that off too', () => { + const { store } = renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: combinedMapViews, + ui: { showOnlyFeaturesInView: true }, + }) + + expect(screen.getByLabelText('Clear filters')).not.toBeDisabled() + + fireEvent.click(screen.getByLabelText('Clear filters')) + + expect(store.getActions()).toContainEqual({ + type: 'TOGGLE_SHOW_ONLY_IN_VIEW', + }) + }) + + test('the Clear filters button is enabled in Combined mode when a selection filter is active, and clicking it clears that too', () => { + const { store } = renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: combinedMapViews, + ui: { selectionFilter: ['selected'] }, + }) + + expect(screen.getByLabelText('Clear filters')).not.toBeDisabled() + + fireEvent.click(screen.getByLabelText('Clear filters')) + + expect(store.getActions()).toContainEqual({ + type: 'SELECTION_FILTER_SET', + value: [], + }) + }) + + test('still shows the column picker while Combined is active, session-only (not the per-layer one)', () => { + renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: combinedMapViews, + }) + + expect(screen.getByLabelText('Configure columns')).toBeInTheDocument() + }) + + test('toggling a layer on in the join-layers popover dispatches DATA_TABLE_JOIN_CONFIG_SET with that layer added', () => { + const { store } = renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: combinedMapViews, + }) + + fireEvent.click(screen.getByLabelText('Choose layers to combine')) + fireEvent.click(screen.getByRole('checkbox', { name: 'Layer 1' })) + + expect(store.getActions()).toEqual([ + { + type: 'DATA_TABLE_JOIN_CONFIG_SET', + layerId: 'ref1', + layers: { + layer1: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, + ]) + }) + + test('shows no ambient filtered-layers warning when no joined layer has dataFilters', () => { + renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: [ + ...twoEligibleLayers, + { + ...referenceLayer(), + combinedJoinConfig: { + layer1: { type: 'orgUnit', aggregation: {} }, + }, + }, + ], + }) + + expect( + screen.queryByTestId('data-table-combined-datafilters-warning') + ).not.toBeInTheDocument() + }) + + test('shows an ambient filtered-layers warning naming a joined layer that has active dataFilters', () => { + renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: [ + { ...twoEligibleLayers[0], dataFilters: { rawValue: '>10' } }, + twoEligibleLayers[1], + { + ...referenceLayer(), + combinedJoinConfig: { + layer1: { type: 'orgUnit', aggregation: {} }, + }, + }, + ], + }) + + expect( + screen.getByTestId('data-table-combined-datafilters-warning') + ).toBeInTheDocument() + }) + + test('shows no ambient server-cluster warning when no joined layer is server clustered', () => { + renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: [ + ...twoEligibleLayers, + { + ...referenceLayer(), + combinedJoinConfig: { + layer1: { type: 'orgUnit', aggregation: {} }, + }, + }, + ], + }) + + expect( + screen.queryByTestId('data-table-combined-servercluster-warning') + ).not.toBeInTheDocument() + }) + + test('shows an ambient server-cluster warning naming a joined layer that is server clustered', () => { + renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: [ + { ...twoEligibleLayers[0], serverCluster: true }, + twoEligibleLayers[1], + { + ...referenceLayer(), + combinedJoinConfig: { + layer1: { type: 'orgUnit', aggregation: {} }, + }, + }, + ], + }) + + expect( + screen.getByTestId('data-table-combined-servercluster-warning') + ).toBeInTheDocument() + }) + + test('does not show the ambient server-cluster warning once forceClientCluster is set', () => { + renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: [ + { + ...twoEligibleLayers[0], + serverCluster: true, + forceClientCluster: true, + }, + twoEligibleLayers[1], + { + ...referenceLayer(), + combinedJoinConfig: { + layer1: { type: 'orgUnit', aggregation: {} }, + }, + }, + ], + }) + + expect( + screen.queryByTestId('data-table-combined-servercluster-warning') + ).not.toBeInTheDocument() + }) + + test('toggling an already-joined layer off dispatches DATA_TABLE_JOIN_CONFIG_SET with that layer removed', () => { + const { store } = renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: [ + ...twoEligibleLayers, + { + ...referenceLayer(), + combinedJoinConfig: { + layer1: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + layer2: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, + ], + }) + + fireEvent.click(screen.getByLabelText('Choose layers to combine')) + fireEvent.click(screen.getByRole('checkbox', { name: 'Layer 1' })) + + expect(store.getActions()).toEqual([ + { + type: 'DATA_TABLE_JOIN_CONFIG_SET', + layerId: 'ref1', + layers: { + layer2: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, + ]) + }) +}) + +describe('BottomPanel clear filters (single-layer mode)', () => { + test("clicking Clear filters in single-layer mode clears that layer's data filters", () => { + const { store } = renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1'], + }, + mapViews: [ + { + ...DEFAULT_MAP_VIEWS[0], + dataFilters: { field1: { value: 'x' } }, + }, + ], + }) + + expect(screen.getByLabelText('Clear filters')).not.toBeDisabled() + + fireEvent.click(screen.getByLabelText('Clear filters')) + + expect(store.getActions()).toContainEqual({ + type: 'DATA_FILTERS_CLEAR_ALL', + layerId: 'layer1', + }) + }) +}) + +describe('BottomPanel Combined joinConfig prop stability', () => { + test('passes CombinedDataTable the same joinConfig object reference across an unrelated re-render, instead of a fresh {layers: ...} wrapper every time', () => { + mockJoinConfigCalls.length = 0 + renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: [...twoEligibleLayers, referenceLayer()], + }) + + fireEvent.change(screen.getByPlaceholderText('Search all columns'), { + target: { value: 'a' }, + }) + fireEvent.change(screen.getByPlaceholderText('Search all columns'), { + target: { value: 'ab' }, + }) + + expect(mockJoinConfigCalls.length).toBeGreaterThan(1) + expect( + mockJoinConfigCalls.every((c) => c === mockJoinConfigCalls[0]) + ).toBe(true) + }) }) diff --git a/src/components/datatable/__tests__/JoinLayersControl.spec.jsx b/src/components/datatable/__tests__/JoinLayersControl.spec.jsx new file mode 100644 index 0000000000..b9f7ecb664 --- /dev/null +++ b/src/components/datatable/__tests__/JoinLayersControl.spec.jsx @@ -0,0 +1,1020 @@ +import { render, fireEvent, screen, within } from '@testing-library/react' +import PropTypes from 'prop-types' +import React from 'react' +import { Provider } from 'react-redux' +import configureMockStore from 'redux-mock-store' +import { + EARTH_ENGINE_LAYER, + EVENT_LAYER, + FACILITY_LAYER, + GEOJSON_URL_LAYER, + THEMATIC_LAYER, +} from '../../../constants/layers.js' +import JoinLayersControl from '../controls/JoinLayersControl.jsx' + +const mockStore = configureMockStore() + +const eligibleLayers = [ + { + id: 'layer1', + name: 'Layer 1', + combinedLayerKey: 'layer1', + layer: THEMATIC_LAYER, + data: [], + }, + { + id: 'layer2', + name: 'Layer 2', + combinedLayerKey: 'layer2', + layer: THEMATIC_LAYER, + data: [ + { + properties: { orgUnitPath: '/country1/ou1' }, + geometry: { type: 'Point' }, + }, + ], + }, +] + +const renderControl = (props) => { + const store = mockStore({}) + return { + store, + ...render( + + + + ), + } +} + +const openPicker = () => + fireEvent.click(screen.getByTestId('data-table-join-layers-button')) + +const expandLayer = (layerId) => + fireEvent.click( + screen.getByTestId(`data-table-join-layer-toggle-${layerId}`) + ) + +const StatefulJoinLayersControl = ({ + layersConfig: initialConfig, + ...props +}) => { + const [layersConfig, setLayersConfig] = React.useState(initialConfig ?? {}) + return ( + + ) +} + +StatefulJoinLayersControl.propTypes = { + layersConfig: PropTypes.object, +} + +const renderStatefulControl = (props) => { + const store = mockStore({}) + return { + store, + ...render( + + + + ), + } +} + +describe('JoinLayersControl trigger', () => { + test('is disabled when there are no eligible layers', () => { + renderControl({ eligibleLayers: [] }) + expect( + screen.getByTestId('data-table-join-layers-button') + ).toBeDisabled() + }) + + test('is enabled once eligible layers are available', () => { + renderControl() + expect( + screen.getByTestId('data-table-join-layers-button') + ).not.toBeDisabled() + }) +}) + +describe('JoinLayersControl popover — checkbox list', () => { + test('lists a checkbox per eligible layer', () => { + renderControl() + openPicker() + + expect(screen.getByText('Layer 1')).toBeInTheDocument() + expect(screen.getByText('Layer 2')).toBeInTheDocument() + }) + + test('reflects the currently joined layers as checked', () => { + renderControl({ + layersConfig: { + layer2: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + }) + openPicker() + + expect( + screen.getByRole('checkbox', { name: 'Layer 1' }) + ).not.toBeChecked() + expect(screen.getByRole('checkbox', { name: 'Layer 2' })).toBeChecked() + }) + + test('checking an unselected layer adds it with default org-unit/SUM settings', () => { + const onChange = jest.fn() + renderControl({ + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + onChange, + }) + openPicker() + + fireEvent.click(screen.getByRole('checkbox', { name: 'Layer 2' })) + + expect(onChange).toHaveBeenCalledWith({ + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + layer2: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }) + }) + + test("checking a layer defaults its aggregation to the data item's own aggregation type, not SUM", () => { + const onChange = jest.fn() + renderControl({ + eligibleLayers: [ + { + id: 'layer3', + name: 'Layer 3', + combinedLayerKey: 'layer3', + layer: THEMATIC_LAYER, + data: [{ properties: { orgUnitPath: '/country1/ou1' } }], + columns: [ + { + dimension: 'dx', + items: [ + { + id: 'de1', + name: 'DE 1', + aggregationType: 'AVERAGE', + }, + ], + }, + ], + }, + ], + layersConfig: {}, + onChange, + }) + openPicker() + + fireEvent.click(screen.getByRole('checkbox', { name: 'Layer 3' })) + + expect(onChange).toHaveBeenCalledWith({ + layer3: { + type: 'orgUnit', + aggregation: { rawValue: 'AVERAGE' }, + }, + }) + }) + + test('checking a layer with no org-unit identity of its own defaults to Location join, not Org unit', () => { + const onChange = jest.fn() + renderControl({ + eligibleLayers: [ + { + id: 'geo', + name: 'Zones', + combinedLayerKey: 'geo', + layer: GEOJSON_URL_LAYER, + data: [{ geometry: { type: 'Point' } }], + }, + ], + layersConfig: {}, + onChange, + }) + openPicker() + + fireEvent.click(screen.getByRole('checkbox', { name: 'Zones' })) + + expect(onChange).toHaveBeenCalledWith({ + geo: { type: 'spatial', aggregation: { rawValue: 'SUM' } }, + }) + }) + + test('unchecking a joined layer removes it from the config', () => { + const onChange = jest.fn() + renderControl({ + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + layer2: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + onChange, + }) + openPicker() + + fireEvent.click(screen.getByRole('checkbox', { name: 'Layer 1' })) + + expect(onChange).toHaveBeenCalledWith({ + layer2: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }) + }) +}) + +describe('JoinLayersControl popover — collapsible layer settings', () => { + test('an unjoined layer has no expand/collapse toggle', () => { + renderControl() + openPicker() + + expect( + screen.queryByTestId('data-table-join-layer-toggle-layer1') + ).not.toBeInTheDocument() + }) + + test('a joined layer is collapsed by default when the popover is opened, hiding its settings', () => { + renderControl({ + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + }) + openPicker() + + expect( + screen.getByTestId('data-table-join-layer-toggle-layer1') + ).toBeInTheDocument() + expect( + screen.queryByLabelText('Join type for Layer 1') + ).not.toBeInTheDocument() + }) + + test('checking a previously unjoined layer expands its settings automatically', () => { + renderStatefulControl({ + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + }) + openPicker() + + fireEvent.click(screen.getByRole('checkbox', { name: 'Layer 2' })) + + expect( + screen.getByLabelText('Join type for Layer 2') + ).toBeInTheDocument() + }) + + test('expanding a collapsed layer shows its settings', () => { + renderControl({ + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + }) + openPicker() + + expandLayer('layer1') + + expect( + screen.getByLabelText('Join type for Layer 1') + ).toBeInTheDocument() + }) + + test('collapsing an expanded layer hides its settings again, without changing layersConfig', () => { + const onChange = jest.fn() + renderControl({ + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + onChange, + }) + openPicker() + + expandLayer('layer1') + expandLayer('layer1') + + expect( + screen.queryByLabelText('Join type for Layer 1') + ).not.toBeInTheDocument() + expect(screen.getByRole('checkbox', { name: 'Layer 1' })).toBeChecked() + expect(onChange).not.toHaveBeenCalled() + }) + + test('expanding one layer does not affect another joined layer', () => { + renderControl({ + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + layer2: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + }) + openPicker() + + expandLayer('layer1') + + expect( + screen.getByLabelText('Join type for Layer 1') + ).toBeInTheDocument() + expect( + screen.queryByLabelText('Join type for Layer 2') + ).not.toBeInTheDocument() + }) +}) + +describe('JoinLayersControl popover — per-layer type/aggregation settings', () => { + test('shows the join type and aggregation selects only for joined layers', () => { + renderControl({ + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + }) + openPicker() + expandLayer('layer1') + + expect( + screen.getByLabelText('Join type for Layer 1') + ).toBeInTheDocument() + expect( + screen.getByLabelText('Aggregation type for Layer 1') + ).toBeInTheDocument() + expect( + screen.queryByLabelText('Join type for Layer 2') + ).not.toBeInTheDocument() + }) + + test('does not offer the Location join option for a layer with no geometry sample available', () => { + renderControl({ + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + layer2: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + }) + openPicker() + expandLayer('layer1') + expandLayer('layer2') + + expect( + within(screen.getByLabelText('Join type for Layer 1')).queryByText( + 'Location' + ) + ).not.toBeInTheDocument() + expect( + within(screen.getByLabelText('Join type for Layer 2')).getByText( + 'Location' + ) + ).toBeInTheDocument() + }) + + test('offers Location for polygon geometry regardless of layer type, matched via centroid - including layers with no org-unit identity of their own', () => { + renderControl({ + eligibleLayers: [ + { + id: 'geo', + name: 'Zones', + combinedLayerKey: 'geo', + layer: GEOJSON_URL_LAYER, + data: [{ geometry: { type: 'Polygon' } }], + }, + ], + layersConfig: { + geo: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + }) + openPicker() + expandLayer('geo') + + expect( + within(screen.getByLabelText('Join type for Zones')).getByText( + 'Location' + ) + ).toBeInTheDocument() + }) + + test('changing the join type dispatches onChange with the updated type', () => { + const onChange = jest.fn() + renderControl({ + layersConfig: { + layer2: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + onChange, + }) + openPicker() + expandLayer('layer2') + + fireEvent.change(screen.getByLabelText('Join type for Layer 2'), { + target: { value: 'spatial' }, + }) + + expect(onChange).toHaveBeenCalledWith({ + layer2: { type: 'spatial', aggregation: { rawValue: 'SUM' } }, + }) + }) + + test('changing the aggregation type dispatches onChange with the updated aggregation for that column', () => { + const onChange = jest.fn() + renderControl({ + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + onChange, + }) + openPicker() + expandLayer('layer1') + + fireEvent.change( + screen.getByLabelText('Aggregation type for Layer 1'), + { target: { value: 'AVERAGE' } } + ) + + expect(onChange).toHaveBeenCalledWith({ + layer1: { type: 'orgUnit', aggregation: { rawValue: 'AVERAGE' } }, + }) + }) + + test("shows one labeled aggregation select per Earth Engine stat, and checking it defaults every stat to the first selected stat's own equivalent", () => { + const onChange = jest.fn() + const eeLayer = { + id: 'ee', + name: 'NDVI', + combinedLayerKey: 'ee', + layer: EARTH_ENGINE_LAYER, + aggregationType: ['mean', 'max'], + legend: { title: 'NDVI' }, + data: [{ properties: { orgUnitPath: '/country1/ou1' } }], + } + renderControl({ + eligibleLayers: [eeLayer], + layersConfig: {}, + onChange, + }) + openPicker() + + fireEvent.click(screen.getByRole('checkbox', { name: 'NDVI' })) + + expect(onChange).toHaveBeenCalledWith({ + ee: { + type: 'orgUnit', + aggregation: { mean: 'AVERAGE', max: 'AVERAGE' }, + }, + }) + }) + + test('changing one Earth Engine stat column aggregation leaves the other stat column untouched', () => { + const onChange = jest.fn() + const eeLayer = { + id: 'ee', + name: 'NDVI', + combinedLayerKey: 'ee', + layer: EARTH_ENGINE_LAYER, + aggregationType: ['mean', 'max'], + legend: { title: 'NDVI' }, + data: [{ properties: { orgUnitPath: '/country1/ou1' } }], + } + renderControl({ + eligibleLayers: [eeLayer], + layersConfig: { + ee: { + type: 'orgUnit', + aggregation: { mean: 'SUM', max: 'SUM' }, + }, + }, + onChange, + }) + openPicker() + expandLayer('ee') + + expect( + screen.getByLabelText('Aggregation type for Mean Ndvi (NDVI)') + ).toBeInTheDocument() + expect( + screen.getByLabelText('Aggregation type for Max Ndvi (NDVI)') + ).toBeInTheDocument() + + fireEvent.change( + screen.getByLabelText('Aggregation type for Mean Ndvi (NDVI)'), + { target: { value: 'AVERAGE' } } + ) + + expect(onChange).toHaveBeenCalledWith({ + ee: { + type: 'orgUnit', + aggregation: { mean: 'AVERAGE', max: 'SUM' }, + }, + }) + }) + + test('shows a single shared aggregation select for a multi-period thematic layer, not one per period', () => { + const onChange = jest.fn() + const timelineLayer = { + id: 'timeline', + name: 'Timeline Layer', + combinedLayerKey: 'timeline', + layer: THEMATIC_LAYER, + renderingStrategy: 'TIMELINE', + periods: [ + { id: 'p1', name: 'Jan' }, + { id: 'p2', name: 'Feb' }, + { id: 'p3', name: 'Mar' }, + ], + data: [{ properties: { orgUnitPath: '/country1/ou1' } }], + } + renderControl({ + eligibleLayers: [timelineLayer], + layersConfig: { + timeline: { type: 'orgUnit', aggregation: {} }, + }, + onChange, + }) + openPicker() + expandLayer('timeline') + + expect( + screen.getAllByLabelText('Aggregation type for Timeline Layer') + ).toHaveLength(1) + + fireEvent.change( + screen.getByLabelText('Aggregation type for Timeline Layer'), + { target: { value: 'AVERAGE' } } + ) + + expect(onChange).toHaveBeenCalledWith({ + timeline: { + type: 'orgUnit', + aggregation: { rawValue: 'AVERAGE' }, + }, + }) + }) +}) + +describe('JoinLayersControl popover — aggregation rollup warning', () => { + const referenceLayer = { + data: [ + { + properties: { + id: 'ref1', + name: 'Ref 1', + orgUnitPath: '/country1/ref1', + level: 2, + }, + }, + ], + } + + const rollupLayer = { + id: 'layer1', + name: 'Layer 1', + combinedLayerKey: 'layer1', + layer: THEMATIC_LAYER, + data: [ + { properties: { orgUnitPath: '/country1/ref1/child1' } }, + { properties: { orgUnitPath: '/country1/ref1/child2' } }, + ], + } + + const noRollupLayer = { + id: 'layer1', + name: 'Layer 1', + combinedLayerKey: 'layer1', + layer: THEMATIC_LAYER, + data: [{ properties: { orgUnitPath: '/country1/ref1' } }], + } + + const getWarning = () => + screen.queryByTestId( + 'data-table-join-aggregation-warning-layer1-rawValue' + ) + + test('shows a warning when the layer rolls up into the reference and the aggregation is non-composable (AVERAGE)', () => { + renderControl({ + eligibleLayers: [rollupLayer], + referenceLayer, + layersConfig: { + layer1: { + type: 'orgUnit', + aggregation: { rawValue: 'AVERAGE' }, + }, + }, + }) + openPicker() + expandLayer('layer1') + + expect(getWarning()).toBeInTheDocument() + }) + + test('does not show a warning for an Event layer, even when it rolls up and the aggregation is non-composable (AVERAGE) - event values are raw individual records, not an average-of-averages approximation', () => { + const rollupEventLayer = { + id: 'layer1', + name: 'Layer 1', + combinedLayerKey: 'layer1', + layer: EVENT_LAYER, + styleDataItem: { id: 'de1', valueType: 'NUMBER' }, + legend: { items: [{ name: 'Low' }, { name: 'High' }] }, + data: [ + { properties: { orgUnitPath: '/country1/ref1/child1' } }, + { properties: { orgUnitPath: '/country1/ref1/child2' } }, + ], + } + renderControl({ + eligibleLayers: [rollupEventLayer], + referenceLayer, + layersConfig: { + layer1: { + type: 'orgUnit', + aggregation: { value: 'AVERAGE' }, + }, + }, + }) + openPicker() + expandLayer('layer1') + + expect( + screen.queryByTestId( + 'data-table-join-aggregation-warning-layer1-value' + ) + ).not.toBeInTheDocument() + }) + + test('does not show a warning when the layer rolls up but the aggregation is composable (SUM)', () => { + renderControl({ + eligibleLayers: [rollupLayer], + referenceLayer, + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + }) + openPicker() + expandLayer('layer1') + + expect(getWarning()).not.toBeInTheDocument() + }) + + test('does not show a warning when the aggregation is non-composable but every reference org unit matches at most one feature', () => { + renderControl({ + eligibleLayers: [noRollupLayer], + referenceLayer, + layersConfig: { + layer1: { + type: 'orgUnit', + aggregation: { rawValue: 'AVERAGE' }, + }, + }, + }) + openPicker() + expandLayer('layer1') + + expect(getWarning()).not.toBeInTheDocument() + }) + + test('does not show a warning when no reference layer is available yet', () => { + renderControl({ + eligibleLayers: [rollupLayer], + referenceLayer: undefined, + layersConfig: { + layer1: { + type: 'orgUnit', + aggregation: { rawValue: 'AVERAGE' }, + }, + }, + }) + openPicker() + expandLayer('layer1') + + expect(getWarning()).not.toBeInTheDocument() + }) +}) + +describe('JoinLayersControl popover — unmatched features warning', () => { + const referenceLayer = { + data: [ + { + properties: { + id: 'ref1', + name: 'Ref 1', + orgUnitPath: '/country1/ref1', + level: 2, + }, + }, + ], + } + + const getWarning = () => + screen.queryByTestId('data-table-join-unmatched-warning-layer1') + + test('shows a warning when some of the layer features could not be matched to any reference org unit', () => { + renderControl({ + eligibleLayers: [ + { + id: 'layer1', + name: 'Layer 1', + combinedLayerKey: 'layer1', + layer: THEMATIC_LAYER, + data: [ + { properties: { orgUnitPath: '/country1/ref1' } }, + { properties: { orgUnitPath: '/country2/other' } }, + ], + }, + ], + referenceLayer, + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + }) + openPicker() + expandLayer('layer1') + + expect(getWarning()).toBeInTheDocument() + }) + + test('does not show a warning when every layer feature matches a reference org unit', () => { + renderControl({ + eligibleLayers: [ + { + id: 'layer1', + name: 'Layer 1', + combinedLayerKey: 'layer1', + layer: THEMATIC_LAYER, + data: [{ properties: { orgUnitPath: '/country1/ref1' } }], + }, + ], + referenceLayer, + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + }) + openPicker() + expandLayer('layer1') + + expect(getWarning()).not.toBeInTheDocument() + }) + + test('does not show a warning when no reference layer is available yet', () => { + renderControl({ + eligibleLayers: [ + { + id: 'layer1', + name: 'Layer 1', + combinedLayerKey: 'layer1', + layer: THEMATIC_LAYER, + data: [{ properties: { orgUnitPath: '/country2/other' } }], + }, + ], + referenceLayer: undefined, + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + }) + openPicker() + expandLayer('layer1') + + expect(getWarning()).not.toBeInTheDocument() + }) +}) + +describe('JoinLayersControl popover — count/category value columns', () => { + const countOnlyFacility = { + id: 'facility1', + name: 'Facilities', + combinedLayerKey: 'facility1', + layer: FACILITY_LAYER, + legend: { items: [{ name: 'Facility' }] }, + data: [{ properties: { orgUnitPath: '/country1/ou1' } }], + } + + const categoricalFacility = { + id: 'facility1', + name: 'Facilities', + combinedLayerKey: 'facility1', + layer: FACILITY_LAYER, + organisationUnitGroupSet: { id: 'groupSet1' }, + legend: { + unit: 'Facility Type', + items: [ + { id: 'group1', name: 'Hospital' }, + { id: 'group2', name: 'Clinic' }, + ], + }, + data: [{ properties: { orgUnitPath: '/country1/ou1' } }], + } + + test('a count-only layer shows a static, layer-type-specific count label, not an aggregation-type select', () => { + renderControl({ + eligibleLayers: [countOnlyFacility], + layersConfig: { + facility1: { + type: 'orgUnit', + aggregation: { count: 'COUNT' }, + }, + }, + }) + openPicker() + expandLayer('facility1') + + expect(screen.getByText('Facilities count')).toBeInTheDocument() + expect( + screen.queryByLabelText('Aggregation type for Facilities') + ).not.toBeInTheDocument() + }) + + test('a category layer shows a single shared Count/Percentage select governing every category column, not one per category', () => { + const onChange = jest.fn() + renderControl({ + eligibleLayers: [categoricalFacility], + layersConfig: { + facility1: { + type: 'orgUnit', + aggregation: { categoryDisplayType: 'COUNT' }, + }, + }, + onChange, + }) + openPicker() + expandLayer('facility1') + + expect( + screen.queryByLabelText( + 'Aggregation type for Hospital (Facilities)' + ) + ).not.toBeInTheDocument() + expect(screen.getByText('Facility Type')).toBeInTheDocument() + + const categorySelect = screen.getByLabelText( + 'Category display for Facilities' + ) + expect( + within(categorySelect).getByText('Percentage') + ).toBeInTheDocument() + expect(within(categorySelect).getByText('Count')).toBeInTheDocument() + + fireEvent.change(categorySelect, { target: { value: 'PERCENTAGE' } }) + + expect(onChange).toHaveBeenCalledWith({ + facility1: { + type: 'orgUnit', + aggregation: { categoryDisplayType: 'PERCENTAGE' }, + }, + }) + }) + + test('falls back to a generic "Categories" label when the layer has no legend.unit', () => { + renderControl({ + eligibleLayers: [ + { + ...categoricalFacility, + legend: { items: categoricalFacility.legend.items }, + }, + ], + layersConfig: { + facility1: { type: 'orgUnit', aggregation: {} }, + }, + }) + openPicker() + expandLayer('facility1') + + expect(screen.getByText('Categories')).toBeInTheDocument() + }) + + test('a value-kind layer (Thematic/Earth Engine) is unaffected - keeps the full aggregation-type select', () => { + renderControl({ + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + }) + openPicker() + expandLayer('layer1') + + const select = screen.getByLabelText('Aggregation type for Layer 1') + expect(within(select).getByText('Average')).toBeInTheDocument() + expect(within(select).getByText('Sum')).toBeInTheDocument() + expect(within(select).queryByText('Percentage')).not.toBeInTheDocument() + }) +}) + +describe('JoinLayersControl dataFilters warning', () => { + test('shows no warning or clear button for a layer with no active dataFilters', () => { + renderControl() + openPicker() + + expect( + screen.queryByTestId('data-table-join-datafilters-warning-layer1') + ).not.toBeInTheDocument() + expect( + screen.queryByTestId('data-table-join-clear-datafilters-layer1') + ).not.toBeInTheDocument() + }) + + test('shows the warning and clear button for a layer with active dataFilters, even when not joined', () => { + renderControl({ + eligibleLayers: [ + { ...eligibleLayers[0], dataFilters: { population: '>100' } }, + eligibleLayers[1], + ], + }) + openPicker() + + expect( + screen.getByTestId('data-table-join-datafilters-warning-layer1') + ).toBeInTheDocument() + expect( + screen.getByTestId('data-table-join-clear-datafilters-layer1') + ).toBeInTheDocument() + expect( + screen.queryByTestId('data-table-join-datafilters-warning-layer2') + ).not.toBeInTheDocument() + }) + + test('clicking the clear button dispatches clearDataFilters for that layer', () => { + const { store } = renderControl({ + eligibleLayers: [ + { ...eligibleLayers[0], dataFilters: { population: '>100' } }, + eligibleLayers[1], + ], + }) + openPicker() + + fireEvent.click( + screen.getByTestId('data-table-join-clear-datafilters-layer1') + ) + + expect(store.getActions()).toContainEqual({ + type: 'DATA_FILTERS_CLEAR_ALL', + layerId: 'layer1', + }) + }) +}) + +describe('JoinLayersControl server-cluster warning', () => { + test('shows no warning or switch button for a layer that is not server clustered', () => { + renderControl() + openPicker() + + expect( + screen.queryByTestId('data-table-join-servercluster-warning-layer1') + ).not.toBeInTheDocument() + expect( + screen.queryByTestId('data-table-join-servercluster-switch-layer1') + ).not.toBeInTheDocument() + }) + + test('shows the warning and switch button for a server-clustered layer, even when not joined', () => { + renderControl({ + eligibleLayers: [ + { ...eligibleLayers[0], serverCluster: true }, + eligibleLayers[1], + ], + }) + openPicker() + + expect( + screen.getByTestId('data-table-join-servercluster-warning-layer1') + ).toBeInTheDocument() + expect( + screen.getByTestId('data-table-join-servercluster-switch-layer1') + ).toBeInTheDocument() + expect( + screen.queryByTestId('data-table-join-servercluster-warning-layer2') + ).not.toBeInTheDocument() + }) + + test('does not show the warning once forceClientCluster is set, even if serverCluster is still true', () => { + renderControl({ + eligibleLayers: [ + { + ...eligibleLayers[0], + serverCluster: true, + forceClientCluster: true, + }, + eligibleLayers[1], + ], + }) + openPicker() + + expect( + screen.queryByTestId('data-table-join-servercluster-warning-layer1') + ).not.toBeInTheDocument() + }) + + test('clicking the switch button dispatches setForceClientCluster for that layer', () => { + const { store } = renderControl({ + eligibleLayers: [ + { ...eligibleLayers[0], serverCluster: true }, + eligibleLayers[1], + ], + }) + openPicker() + + fireEvent.click( + screen.getByTestId('data-table-join-servercluster-switch-layer1') + ) + + expect(store.getActions()).toContainEqual({ + type: 'LAYER_FORCE_CLIENT_CLUSTER_SET', + id: 'layer1', + }) + }) +}) diff --git a/src/components/datatable/__tests__/LayerRow.spec.jsx b/src/components/datatable/__tests__/LayerRow.spec.jsx new file mode 100644 index 0000000000..b13a65e3a1 --- /dev/null +++ b/src/components/datatable/__tests__/LayerRow.spec.jsx @@ -0,0 +1,78 @@ +import { render, fireEvent, screen } from '@testing-library/react' +import React from 'react' +import { THEMATIC_LAYER } from '../../../constants/layers.js' +import LayerRow from '../controls/LayerRow.jsx' + +const layer = { id: 'layer1', name: 'Layer 1', layer: THEMATIC_LAYER, data: [] } + +const renderRow = (props) => + render( + + ) + +describe('LayerRow — server-cluster warning', () => { + test('shows no warning or switch button when the layer is not server clustered', () => { + renderRow() + + expect( + screen.queryByTestId('data-table-join-servercluster-warning-layer1') + ).not.toBeInTheDocument() + expect( + screen.queryByTestId('data-table-join-servercluster-switch-layer1') + ).not.toBeInTheDocument() + }) + + test('shows the warning and switch button when the layer is server clustered', () => { + renderRow({ isServerClustered: true }) + + expect( + screen.getByTestId('data-table-join-servercluster-warning-layer1') + ).toBeInTheDocument() + expect( + screen.getByTestId('data-table-join-servercluster-switch-layer1') + ).toBeInTheDocument() + }) + + test('clicking the switch button calls onForceClientCluster', () => { + const onForceClientCluster = jest.fn() + renderRow({ isServerClustered: true, onForceClientCluster }) + + fireEvent.click( + screen.getByTestId('data-table-join-servercluster-switch-layer1') + ) + + expect(onForceClientCluster).toHaveBeenCalledTimes(1) + }) + + test('the warning and the data-filters warning can appear together', () => { + renderRow({ + isServerClustered: true, + hasDataFilters: true, + onClearDataFilters: jest.fn(), + }) + + expect( + screen.getByTestId('data-table-join-servercluster-warning-layer1') + ).toBeInTheDocument() + expect( + screen.getByTestId('data-table-join-datafilters-warning-layer1') + ).toBeInTheDocument() + }) +}) diff --git a/src/components/datatable/controls/JoinLayersControl.jsx b/src/components/datatable/controls/JoinLayersControl.jsx new file mode 100644 index 0000000000..3d2a361f7a --- /dev/null +++ b/src/components/datatable/controls/JoinLayersControl.jsx @@ -0,0 +1,241 @@ +import i18n from '@dhis2/d2-i18n' +import PropTypes from 'prop-types' +import React, { useMemo, useRef, useState } from 'react' +import { useDispatch } from 'react-redux' +import { clearDataFilters } from '../../../actions/dataFilters.js' +import { setForceClientCluster } from '../../../actions/layers.js' +import { + DATA_KEY_KIND_CATEGORY, + ORG_UNIT_PATH_DATA_KEY, +} from '../../../constants/dataTable.js' +import { + getUnmatchedFeatureCount, + hasCombinedRollup, +} from '../../../util/combinedJoinMatch.js' +import { + getCombinedValueDataKeys, + getDefaultCombinedAggregation, +} from '../../../util/dataTable.js' +import { IconLayersStack16 } from '../../core/icons.jsx' +import { FilterDropdownPopover } from '../FilterDropdownPopover.jsx' +import LayerRow from './LayerRow.jsx' +import styles from './styles/JoinLayersControl.module.css' +import ToolbarIconButton from './ToolbarIconButton.jsx' + +const hasOrgUnitIdentity = (layer) => { + const feature = layer.data?.[0] + return !!(feature?.properties ?? feature)?.[ORG_UNIT_PATH_DATA_KEY] +} + +const getDefaultSettings = (layer) => ({ + type: hasOrgUnitIdentity(layer) ? 'orgUnit' : 'spatial', + aggregation: getDefaultCombinedAggregation(layer), +}) + +const JoinLayersControl = ({ + eligibleLayers, + layersConfig, + onChange, + referenceLayer, +}) => { + const anchorRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const [expandedKeys, setExpandedKeys] = useState(() => new Set()) + const dispatch = useDispatch() + + const openPopover = () => { + setExpandedKeys(new Set()) + setIsOpen(true) + } + + const joinQualityByLayerKey = useMemo(() => { + const result = {} + eligibleLayers.forEach((layer) => { + const settings = layersConfig[layer.combinedLayerKey] + if (!settings) { + return + } + result[layer.combinedLayerKey] = { + hasRollup: hasCombinedRollup( + layer, + referenceLayer, + settings.type + ), + unmatchedCount: getUnmatchedFeatureCount( + layer, + referenceLayer, + settings.type + ), + } + }) + return result + }, [eligibleLayers, layersConfig, referenceLayer]) + + const onToggle = (layer) => { + const next = { ...layersConfig } + if (next[layer.combinedLayerKey]) { + delete next[layer.combinedLayerKey] + } else { + next[layer.combinedLayerKey] = getDefaultSettings(layer) + setExpandedKeys((prev) => new Set(prev).add(layer.combinedLayerKey)) + } + onChange(next) + } + + const onToggleExpand = (layerKey) => + setExpandedKeys((prev) => { + const next = new Set(prev) + if (next.has(layerKey)) { + next.delete(layerKey) + } else { + next.add(layerKey) + } + return next + }) + + const onTypeChange = (layerKey, type) => + onChange({ + ...layersConfig, + [layerKey]: { ...layersConfig[layerKey], type }, + }) + + const onAggregationChange = (layerKey, dataKey, aggregationType) => + onChange({ + ...layersConfig, + [layerKey]: { + ...layersConfig[layerKey], + aggregation: { + ...layersConfig[layerKey].aggregation, + [dataKey]: aggregationType, + }, + }, + }) + + return ( + <> + (isOpen ? setIsOpen(false) : openPopover())} + > + + + {isOpen && ( + setIsOpen(false)} + > +
+
+ {eligibleLayers.map((layer) => { + const settings = + layersConfig[layer.combinedLayerKey] + const hasDataFilters = + Object.keys(layer.dataFilters ?? {}) + .length > 0 + const isServerClustered = !!( + layer.serverCluster && + !layer.forceClientCluster + ) + const defaultAggregation = + getDefaultCombinedAggregation(layer) + const { + hasRollup = false, + unmatchedCount = 0, + } = + joinQualityByLayerKey[ + layer.combinedLayerKey + ] ?? {} + const valueDataKeys = + getCombinedValueDataKeys(layer) + const categoryDataKeys = valueDataKeys.filter( + ({ kind }) => + kind === DATA_KEY_KIND_CATEGORY + ) + const seenAggregationKeys = new Set() + const otherDataKeys = valueDataKeys + .filter( + ({ kind }) => + kind !== DATA_KEY_KIND_CATEGORY + ) + .filter(({ dataKey, settingsKey }) => { + const key = settingsKey ?? dataKey + if (seenAggregationKeys.has(key)) { + return false + } + seenAggregationKeys.add(key) + return true + }) + return ( + + onToggleExpand( + layer.combinedLayerKey + ) + } + onToggleJoined={() => onToggle(layer)} + hasDataFilters={hasDataFilters} + onClearDataFilters={() => + dispatch(clearDataFilters(layer.id)) + } + isServerClustered={isServerClustered} + onForceClientCluster={() => + dispatch( + setForceClientCluster(layer.id) + ) + } + settings={settings} + defaultAggregation={defaultAggregation} + hasRollup={hasRollup} + unmatchedCount={unmatchedCount} + categoryDataKeys={categoryDataKeys} + otherDataKeys={otherDataKeys} + onTypeChange={(type) => + onTypeChange( + layer.combinedLayerKey, + type + ) + } + onAggregationChange={(dataKey, type) => + onAggregationChange( + layer.combinedLayerKey, + dataKey, + type + ) + } + /> + ) + })} +
+
+
+ )} + + ) +} + +JoinLayersControl.propTypes = { + eligibleLayers: PropTypes.arrayOf( + PropTypes.shape({ + combinedLayerKey: PropTypes.string, + data: PropTypes.array, + id: PropTypes.string, + layer: PropTypes.string, + name: PropTypes.string, + }) + ).isRequired, + layersConfig: PropTypes.object.isRequired, + onChange: PropTypes.func.isRequired, + referenceLayer: PropTypes.object, +} + +export default JoinLayersControl diff --git a/src/components/datatable/controls/LayerRow.jsx b/src/components/datatable/controls/LayerRow.jsx new file mode 100644 index 0000000000..85a4d40824 --- /dev/null +++ b/src/components/datatable/controls/LayerRow.jsx @@ -0,0 +1,374 @@ +import i18n from '@dhis2/d2-i18n' +import { + IconChevronDown16, + IconChevronRight16, + IconReorder16, + IconWarningFilled16, + Tooltip, +} from '@dhis2/ui' +import PropTypes from 'prop-types' +import React from 'react' +import { + getCategoryValueDisplayTypes, + getCombinedAggregationTypes, +} from '../../../constants/aggregationTypes.js' +import { DATA_KEY_KIND_COUNT } from '../../../constants/dataTable.js' +import { + FACILITY_LAYER, + ORG_UNIT_LAYER, + EVENT_LAYER, + TRACKED_ENTITY_LAYER, +} from '../../../constants/layers.js' +import { NON_COMPOSABLE_AGGREGATION_TYPES } from '../../../util/aggregation.js' +import { CATEGORY_DISPLAY_TYPE_KEY } from '../../../util/dataTable.js' +import { + GEO_TYPE_POINT, + GEO_TYPE_POLYGON, + GEO_TYPE_MULTIPOLYGON, +} from '../../../util/geojson.js' +import Checkbox from '../../core/Checkbox.jsx' +import { FilterActiveIcon } from '../../core/index.js' +import styles from './styles/JoinLayersControl.module.css' + +const isSpatialEligible = (layer) => { + const geometryType = layer.data?.[0]?.geometry?.type + return [GEO_TYPE_POINT, GEO_TYPE_POLYGON, GEO_TYPE_MULTIPOLYGON].includes( + geometryType + ) +} + +const COUNT_LABEL_BY_LAYER_TYPE = { + [FACILITY_LAYER]: () => i18n.t('Facilities count'), + [ORG_UNIT_LAYER]: () => i18n.t('Org units count'), + [EVENT_LAYER]: () => i18n.t('Events count'), + [TRACKED_ENTITY_LAYER]: () => i18n.t('Tracked entities count'), +} + +const getCountLabel = (layer) => + COUNT_LABEL_BY_LAYER_TYPE[layer.layer]?.() ?? i18n.t('Count') + +const LayerRow = ({ + layer, + isExpanded, + onToggleExpand, + onToggleJoined, + hasDataFilters, + onClearDataFilters, + isServerClustered, + onForceClientCluster, + settings, + defaultAggregation, + hasRollup, + unmatchedCount, + categoryDataKeys, + otherDataKeys, + onTypeChange, + onAggregationChange, +}) => { + const aggregationTypes = getCombinedAggregationTypes() + const isJoined = !!settings + + return ( +
+
+ {isJoined ? ( + + ) : ( + + )} + {layer.name} + } + checked={isJoined} + onChange={onToggleJoined} + className={styles.layerCheckbox} + dataTest={`data-table-join-layer-${layer.id}`} + /> + {hasDataFilters && ( + <> + + + + + + + + + + )} + {isServerClustered && ( + <> + + + + + + + + + + )} +
+ {isJoined && isExpanded && ( +
+
+ + {i18n.t('Join by')} + + + {unmatchedCount > 0 && ( + + + + + + )} +
+ {otherDataKeys.map( + ({ dataKey, name, kind, settingsKey }) => { + if (kind === DATA_KEY_KIND_COUNT) { + return ( +
+ + {getCountLabel(layer)} + +
+ ) + } + + const aggregationKey = settingsKey ?? dataKey + const effectiveType = + settings.aggregation?.[aggregationKey] ?? + defaultAggregation[dataKey] + const showWarning = + hasRollup && + layer.layer !== EVENT_LAYER && + NON_COMPOSABLE_AGGREGATION_TYPES.has( + effectiveType + ) + + return ( +
+ + {name ?? i18n.t('Value')} + + + {showWarning && ( + + + + + + )} +
+ ) + } + )} + {categoryDataKeys.length > 0 && ( +
+ + {layer.legend?.unit ?? i18n.t('Categories')} + + +
+ )} +
+ )} +
+ ) +} + +LayerRow.propTypes = { + categoryDataKeys: PropTypes.array.isRequired, + hasDataFilters: PropTypes.bool.isRequired, + hasRollup: PropTypes.bool.isRequired, + isExpanded: PropTypes.bool.isRequired, + isServerClustered: PropTypes.bool.isRequired, + layer: PropTypes.shape({ + data: PropTypes.array, + id: PropTypes.string, + layer: PropTypes.string, + legend: PropTypes.object, + name: PropTypes.string, + }).isRequired, + otherDataKeys: PropTypes.array.isRequired, + unmatchedCount: PropTypes.number.isRequired, + onAggregationChange: PropTypes.func.isRequired, + onClearDataFilters: PropTypes.func.isRequired, + onForceClientCluster: PropTypes.func.isRequired, + onToggleExpand: PropTypes.func.isRequired, + onToggleJoined: PropTypes.func.isRequired, + onTypeChange: PropTypes.func.isRequired, + defaultAggregation: PropTypes.object, + settings: PropTypes.shape({ + aggregation: PropTypes.object, + type: PropTypes.string, + }), +} + +export default LayerRow diff --git a/src/components/datatable/controls/LayerSelectorControl.jsx b/src/components/datatable/controls/LayerSelectorControl.jsx index def564c737..c112d21875 100644 --- a/src/components/datatable/controls/LayerSelectorControl.jsx +++ b/src/components/datatable/controls/LayerSelectorControl.jsx @@ -1,31 +1,47 @@ import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' import React from 'react' +import { SENTINEL_COMBINED_VALUE } from '../../../constants/dataTable.js' import styles from '../styles/BottomPanel.module.css' -const LayerSelectorControl = ({ layers, activeLayerId, onSelectLayer }) => ( +const LayerSelectorControl = ({ + layers, + activeLayerId, + combinedView, + onSelectLayer, + onSelectCombined, +}) => ( ) LayerSelectorControl.propTypes = { + combinedView: PropTypes.bool.isRequired, layers: PropTypes.arrayOf( PropTypes.shape({ id: PropTypes.string.isRequired, name: PropTypes.string, }) ).isRequired, + onSelectCombined: PropTypes.func.isRequired, onSelectLayer: PropTypes.func.isRequired, activeLayerId: PropTypes.string, } diff --git a/src/components/datatable/controls/styles/JoinLayersControl.module.css b/src/components/datatable/controls/styles/JoinLayersControl.module.css new file mode 100644 index 0000000000..8ef148a8d0 --- /dev/null +++ b/src/components/datatable/controls/styles/JoinLayersControl.module.css @@ -0,0 +1,135 @@ +.joinLayersPopover { + composes: popoverPanel from './PopoverPanel.module.css'; + min-width: 264px; +} + +.layerList { + display: flex; + flex-direction: column; + max-height: 320px; + overflow-y: auto; +} + +.layerRow { + padding: var(--spacers-dp4); + border-radius: 3px; +} + +.layerRow:not(:last-child) { + border-bottom: 1px solid var(--colors-grey300); +} + +.layerRowHeader { + display: flex; + align-items: center; + gap: var(--spacers-dp4); +} + +.expandButton { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + border: none; + background: none; + cursor: pointer; + color: var(--colors-grey700); +} + +.expandButton:hover { + color: var(--colors-grey900); +} + +.expandButtonPlaceholder { + width: 16px; + flex-shrink: 0; +} + +.clearDataFiltersButton { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + padding: 0; + border: none; + background: none; + cursor: pointer; + color: var(--colors-grey700); +} + +.clearDataFiltersButton:hover { + color: var(--colors-grey900); +} + +.layerRow:hover { + background: var(--colors-grey100); +} + +.layerCheckbox { + margin: 0; +} + +.layerCheckbox :global(label) { + min-width: 0; +} + +.layerName { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 12px; +} + +.layerSettings { + display: flex; + flex-direction: column; + gap: var(--spacers-dp4); + /* Aligns with the layer-name text, past the chevron + checkbox column */ + padding: var(--spacers-dp4) 0 var(--spacers-dp4) 44px; +} + +.aggregationRow { + display: flex; + align-items: center; + gap: var(--spacers-dp4); +} + +.aggregationRowLabel { + flex: 0 0 80px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 11px; + color: var(--colors-grey700); +} + +.aggregationRow select { + flex: 1; + min-width: 0; + height: 24px; + padding: 0 var(--spacers-dp4); + font-size: 12px; + border: 1px solid var(--colors-grey500); + border-radius: 3px; + background-color: var(--colors-white); +} + +.aggregationWarning { + display: flex; + flex-shrink: 0; + align-items: center; + color: var(--colors-yellow600); +} + +.staticValueLabel { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 11px; + font-style: italic; + color: var(--colors-grey600); +} diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index 26a8c3cd0a..91c565b4ff 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -59,3 +59,10 @@ border-color: var(--colors-blue600); box-shadow: inset 0 0 0 2px var(--colors-blue600); } + +.filteredLayersWarning { + display: flex; + flex-shrink: 0; + align-items: center; + color: var(--colors-yellow600); +} diff --git a/src/constants/__tests__/aggregationTypes.spec.js b/src/constants/__tests__/aggregationTypes.spec.js new file mode 100644 index 0000000000..c3e7a0f6e1 --- /dev/null +++ b/src/constants/__tests__/aggregationTypes.spec.js @@ -0,0 +1,19 @@ +import { + getCombinedAggregationTypes, + getThematicAggregationTypes, +} from '../aggregationTypes.js' + +describe('getCombinedAggregationTypes', () => { + test('excludes DEFAULT (not meaningful outside a thematic layer)', () => { + expect( + getCombinedAggregationTypes().map((type) => type.id) + ).not.toContain('DEFAULT') + }) + + test('otherwise matches the thematic layer aggregation types exactly', () => { + const thematicNonDefault = getThematicAggregationTypes().filter( + (type) => type.id !== 'DEFAULT' + ) + expect(getCombinedAggregationTypes()).toEqual(thematicNonDefault) + }) +}) diff --git a/src/constants/aggregationTypes.js b/src/constants/aggregationTypes.js index 2c942a9cd9..939a64746f 100644 --- a/src/constants/aggregationTypes.js +++ b/src/constants/aggregationTypes.js @@ -12,6 +12,16 @@ export const getThematicAggregationTypes = () => [ { id: 'MAX', name: i18n.t('Max') }, ] +// Combined data table join +export const getCombinedAggregationTypes = () => + getThematicAggregationTypes().filter((type) => type.id !== 'DEFAULT') + +// Combined data table join - categorical value columns (DATA_KEY_KIND_CATEGORY) +export const getCategoryValueDisplayTypes = () => [ + { id: 'PERCENTAGE', name: i18n.t('Percentage') }, + { id: 'COUNT', name: i18n.t('Count') }, +] + // Earth Engine layer export const getEarthEngineStatisticTypes = () => [ { id: 'percentage', name: i18n.t('Percentage') }, diff --git a/src/constants/dataTable.js b/src/constants/dataTable.js index ae2251a0ed..f5457f0b0d 100644 --- a/src/constants/dataTable.js +++ b/src/constants/dataTable.js @@ -1,6 +1,7 @@ export const SENTINEL_NO_VALUE = '' // Matches filterData's existing null/undefined -> empty-string coercion (src/util/filter.js) export const SENTINEL_ANY_VALUE = '__any_value__' export const SENTINEL_SELECTED_ROW = '__selected__' +export const SENTINEL_COMBINED_VALUE = '__combined_value__' export const SORT_ASCENDING = 'asc' export const SORT_DESCENDING = 'desc' From 1694a1737e8552f08b7177b25c6069ca2cbb4844 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 10 Sep 2026 13:46:46 +0200 Subject: [PATCH 8/9] feat: persist combinedJoinConfig/combinedColumnConfig/combinedLayerKey [DHIS2-20543] Ports favorites.js's round-trip for the Combined table's per-layer join config, column config, and stable cross-save layer key, mirroring the existing dataTableColumnConfig pattern exactly (confirmed byte-identical to feat/datatable-pr7-multilayer's version of this file after applying). Fixed beyond the straight port: the new untouched-placeholder cleanup filter in cleanMapConfig read view.combinedJoinConfig as a flat property, but that shape only exists when cleanMapviewConfig is true. FileMenu.jsx's rename-only save path calls cleanMapConfig with cleanMapviewConfig: false on a freshly-fetched map whose combinedJoinConfig is still packed inside the unparsed config JSON string - so a Combined-table reference layer with join layers picked but no org units selected yet would have silently lost its entire join configuration on rename. Gated the filter on cleanMapviewConfig, consistent with every other check in this same function. Added test coverage for combinedColumnConfig/combinedLayerKey round-tripping (the source branch's tests only covered combinedJoinConfig) and for the cleanMapviewConfig: false regression. Co-Authored-By: Claude Sonnet 5 --- src/util/__tests__/favorites.spec.js | 133 +++++++++++++++++++++++++++ src/util/favorites.js | 45 ++++++++- 2 files changed, 174 insertions(+), 4 deletions(-) diff --git a/src/util/__tests__/favorites.spec.js b/src/util/__tests__/favorites.spec.js index e1fdcb9f17..778bc39109 100644 --- a/src/util/__tests__/favorites.spec.js +++ b/src/util/__tests__/favorites.spec.js @@ -1018,6 +1018,139 @@ describe('cleanMapConfig', () => { expect(mapView).not.toHaveProperty('dataTableColumnConfig') }) + test('serializes combinedJoinConfig and combinedColumnConfig into config JSON for the combinedTableRef layer', () => { + const combinedJoinConfig = { + layerA: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + } + const combinedColumnConfig = { orderedKeys: ['layerA_value'] } + const config = { + mapViews: [ + { + layer: 'combinedTableRef', + name: 'Reference org units', + rows: [{ dimension: 'ou', items: [{ id: 'ou1' }] }], + combinedJoinConfig, + combinedColumnConfig, + }, + ], + } + const cleanedConfig = cleanMapConfig({ + config, + defaultBasemapId: 'default', + }) + const mapView = cleanedConfig.mapViews[0] + const parsedConfig = JSON.parse(mapView.config) + expect(parsedConfig.combinedJoinConfig).toEqual(combinedJoinConfig) + expect(parsedConfig.combinedColumnConfig).toEqual(combinedColumnConfig) + expect(mapView).not.toHaveProperty('combinedJoinConfig') + expect(mapView).not.toHaveProperty('combinedColumnConfig') + }) + + test('excludes an untouched combinedTableRef placeholder (no org units, no join layers picked) from the saved favorite', () => { + const config = { + mapViews: [ + { layer: 'thematic' }, + { layer: 'combinedTableRef', rows: [] }, + ], + } + const cleanedConfig = cleanMapConfig({ + config, + defaultBasemapId: 'default', + }) + + expect(cleanedConfig.mapViews).toHaveLength(1) + expect(cleanedConfig.mapViews[0].layer).toBe('thematic') + }) + + test('keeps a combinedTableRef placeholder that has join layers picked, even with no org units yet', () => { + const config = { + mapViews: [ + { + layer: 'combinedTableRef', + rows: [], + combinedJoinConfig: { + layerA: { type: 'orgUnit' }, + }, + }, + ], + } + const cleanedConfig = cleanMapConfig({ + config, + defaultBasemapId: 'default', + }) + + expect(cleanedConfig.mapViews).toHaveLength(1) + }) + + test('does not drop an empty-rows combinedTableRef placeholder when cleanMapviewConfig is false (rename-only save)', () => { + const config = { + mapViews: [ + { + layer: 'combinedTableRef', + rows: [], + // As fetched from the API, join config still lives inside + // the unparsed config JSON, not lifted to a flat field + config: JSON.stringify({ + combinedJoinConfig: { layerA: { type: 'orgUnit' } }, + }), + }, + ], + } + const cleanedConfig = cleanMapConfig({ + config, + defaultBasemapId: 'default', + cleanMapviewConfig: false, + }) + + expect(cleanedConfig.mapViews).toHaveLength(1) + }) + + test('round-trips combinedLayerKey for every layer type that carries it', () => { + const combinedLayerKey = 'combined-key-1' + const config = { + mapViews: [ + { + layer: 'thematic', + name: 'Thematic', + rows: [], + combinedLayerKey, + }, + { + layer: 'earthEngine', + name: 'EE', + layerId: 'ee1', + combinedLayerKey, + }, + { + layer: 'trackedEntity', + name: 'TEI', + rows: [], + combinedLayerKey, + }, + { + layer: 'geoJsonUrl', + name: 'GeoJSON', + rows: [], + config: { url: 'https://example.com/geo.json' }, + combinedLayerKey, + }, + ], + } + const cleanedConfig = cleanMapConfig({ + config, + defaultBasemapId: 'default', + }) + + cleanedConfig.mapViews.forEach((mapView) => { + expect(mapView).not.toHaveProperty('combinedLayerKey') + const parsedConfig = + typeof mapView.config === 'string' + ? JSON.parse(mapView.config) + : mapView.config + expect(parsedConfig.combinedLayerKey).toBe(combinedLayerKey) + }) + }) + test('serializes dataTableColumnConfig into config JSON for geojson layer', () => { const dataTableColumnConfig = { orderedKeys: ['name', 'id'] } const config = { diff --git a/src/util/favorites.js b/src/util/favorites.js index 96032a36aa..7484a9cc26 100644 --- a/src/util/favorites.js +++ b/src/util/favorites.js @@ -1,5 +1,6 @@ import { isNil, omitBy, pick, isObject, omit } from 'lodash/fp' import { + COMBINED_TABLE_REF_LAYER, EARTH_ENGINE_LAYER, EVENT_LAYER, FACILITY_LAYER, @@ -8,6 +9,7 @@ import { THEMATIC_LAYER, TRACKED_ENTITY_LAYER, } from '../constants/layers.js' +import { getOrgUnitsFromRows } from './analytics.js' // TODO: get latitude, longitude, zoom from map + basemap: 'none' const validMapProperties = [ @@ -35,6 +37,9 @@ const validLayerProperties = [ 'colorLow', // Deprecated 'colorScale', 'columns', + 'combinedColumnConfig', // used by combinedTableRef layer + 'combinedJoinConfig', // used by combinedTableRef layer + 'combinedLayerKey', // stable cross-save id, set on every layer type 'config', 'created', 'dataTableColumnConfig', @@ -119,9 +124,19 @@ export const cleanMapConfig = ({ }) => ({ ...omitBy(isNil, pick(validMapProperties, config)), ...getBasemapPayload(config.basemap, defaultBasemapId, serverVersion), - mapViews: config.mapViews.map((view) => - cleanLayerConfig(view, cleanMapviewConfig) - ), + // An untouched combinedTableRef placeholder gets cleaned up. Only when + // cleanMapviewConfig is true — otherwise (e.g. FileMenu's rename-only + // save) combinedJoinConfig is still packed inside the unparsed config + // JSON string rather than lifted to this flat field. + mapViews: (cleanMapviewConfig + ? config.mapViews.filter( + (view) => + view.layer !== COMBINED_TABLE_REF_LAYER || + getOrgUnitsFromRows(view.rows).length > 0 || + Object.keys(view.combinedJoinConfig ?? {}).length > 0 + ) + : config.mapViews + ).map((view) => cleanLayerConfig(view, cleanMapviewConfig)), }) // VERSION-TOGGLE: https://dhis2.atlassian.net/browse/DHIS2-20417 @@ -184,6 +199,15 @@ const buildCommonLayerConfigData = (layer) => { if (layer.dataTableColumnConfig) { configData.dataTableColumnConfig = layer.dataTableColumnConfig } + if (layer.combinedJoinConfig) { + configData.combinedJoinConfig = layer.combinedJoinConfig + } + if (layer.combinedColumnConfig) { + configData.combinedColumnConfig = layer.combinedColumnConfig + } + if (layer.combinedLayerKey) { + configData.combinedLayerKey = layer.combinedLayerKey + } return configData } @@ -199,6 +223,9 @@ const deleteCommonLayerConfigProps = (layer) => { delete layer.countEventsOutsideOrgUnits delete layer.labelDataItem delete layer.dataTableColumnConfig + delete layer.combinedJoinConfig + delete layer.combinedColumnConfig + delete layer.combinedLayerKey } const buildEarthEngineLayerConfigData = (layer) => { @@ -209,6 +236,7 @@ const buildEarthEngineLayerConfigData = (layer) => { aggregationType, period, dataTableColumnConfig, + combinedLayerKey, } = layer return omitBy(isNil, { id, @@ -217,6 +245,7 @@ const buildEarthEngineLayerConfigData = (layer) => { aggregationType, period, dataTableColumnConfig, + combinedLayerKey, }) } @@ -231,6 +260,7 @@ const deleteEarthEngineLayerProps = (layer) => { delete layer.aggregationType delete layer.band delete layer.dataTableColumnConfig + delete layer.combinedLayerKey } const buildTrackedEntityLayerConfigData = (layer) => ({ @@ -245,6 +275,7 @@ const buildTrackedEntityLayerConfigData = (layer) => ({ : null, periodType: layer.periodType, dataTableColumnConfig: layer.dataTableColumnConfig, + combinedLayerKey: layer.combinedLayerKey, }) const deleteTrackedEntityLayerProps = (layer) => { @@ -255,6 +286,7 @@ const deleteTrackedEntityLayerProps = (layer) => { delete layer.relationshipOutsideProgram delete layer.periodType delete layer.dataTableColumnConfig + delete layer.combinedLayerKey } // TODO: This feels hacky, find better way to clean map configs before saving @@ -293,15 +325,20 @@ const models2objects = (layer, cleanMapviewConfig) => { ...(layer.dataTableColumnConfig !== undefined && { dataTableColumnConfig: layer.dataTableColumnConfig, }), + ...(layer.combinedLayerKey !== undefined && { + combinedLayerKey: layer.combinedLayerKey, + }), } } delete layer.featureStyle delete layer.dataTableColumnConfig + delete layer.combinedLayerKey } else if ( layerType === EVENT_LAYER || layerType === THEMATIC_LAYER || layerType === ORG_UNIT_LAYER || - layerType === FACILITY_LAYER + layerType === FACILITY_LAYER || + layerType === COMBINED_TABLE_REF_LAYER ) { if (cleanMapviewConfig) { const configData = buildCommonLayerConfigData(layer) From 75036d4a28cf224f53fb72276c149b20eb541c2c Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 10 Sep 2026 14:39:49 +0200 Subject: [PATCH 9/9] chore(i18n): regenerate en.pot for Combined data table strings Picks up the new translatable strings introduced across the PR8 port (join controls, zoom-menu labels, column headers, warnings). Co-Authored-By: Claude Sonnet 5 --- i18n/en.pot | 205 ++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 176 insertions(+), 29 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 3065b61474..91fcd1fe32 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-09-07T15:11:13.032Z\n" -"PO-Revision-Date: 2026-09-07T15:11:13.032Z\n" +"POT-Creation-Date: 2026-09-10T11:58:06.572Z\n" +"PO-Revision-Date: 2026-09-10T11:58:06.572Z\n" msgid "2020" msgstr "2020" @@ -155,6 +155,35 @@ msgstr "Operator" msgid "Date" msgstr "Date" +msgid "" +"Values from {{layers}} only reflect the filter(s) applied in their own " +"table." +msgstr "" +"Values from {{layers}} only reflect the filter(s) applied in their own " +"table." + +msgid "" +"Values from {{layers}} aren't available to join into the Combined table " +"while clustered on the server." +msgstr "" +"Values from {{layers}} aren't available to join into the Combined table " +"while clustered on the server." + +msgid "No matching rows" +msgstr "No matching rows" + +msgid "Location join over large datasets may be slow (over {{threshold}} features)" +msgstr "Location join over large datasets may be slow (over {{threshold}} features)" + +msgid "Zoom to feature" +msgstr "Zoom to feature" + +msgid "Zoom to selected features" +msgstr "Zoom to selected features" + +msgid "Zoom to filtered features" +msgstr "Zoom to filtered features" + msgid "Edit layer" msgstr "Edit layer" @@ -263,18 +292,9 @@ msgstr "Drill down one level" msgid "View profile" msgstr "View profile" -msgid "Zoom to feature" -msgstr "Zoom to feature" - msgid "Zoom to layer" msgstr "Zoom to layer" -msgid "Zoom to selected features" -msgstr "Zoom to selected features" - -msgid "Zoom to filtered features" -msgstr "Zoom to filtered features" - msgid "Event details aren't available while this layer is clustered on the server" msgstr "Event details aren't available while this layer is clustered on the server" @@ -326,9 +346,109 @@ msgstr "Search all columns" msgid "Highlight color" msgstr "Highlight color" +msgid "Choose layers to combine" +msgstr "Choose layers to combine" + +msgid "Facilities count" +msgstr "Facilities count" + +msgid "Org units count" +msgstr "Org units count" + +msgid "Events count" +msgstr "Events count" + +msgid "Tracked entities count" +msgstr "Tracked entities count" + +msgid "Count" +msgstr "Count" + +msgid "Collapse {{layer}}" +msgstr "Collapse {{layer}}" + +msgid "Expand {{layer}}" +msgstr "Expand {{layer}}" + +msgid "" +"This layer has a filter active from its own table - Combined only reflects " +"the filtered records." +msgstr "" +"This layer has a filter active from its own table - Combined only reflects " +"the filtered records." + +msgid "Clear filters applied to {{layer}}" +msgstr "Clear filters applied to {{layer}}" + +msgid "" +"This layer is clustered on the server - its data isn't available to join " +"into the Combined table." +msgstr "" +"This layer is clustered on the server - its data isn't available to join " +"into the Combined table." + +msgid "Switch to client clustering" +msgstr "Switch to client clustering" + +msgid "Join by" +msgstr "Join by" + +msgid "Join type for {{layer}}" +msgstr "Join type for {{layer}}" + +msgid "Org unit" +msgstr "Org unit" + +msgid "Location" +msgstr "Location" + +msgid "" +"{{count}} feature(s) from {{layer}} could not be matched to a reference org " +"unit (wrong level, no matching parent, or outside every boundary) and will " +"be excluded from the Combined table." +msgid_plural "" +"{{count}} feature(s) from {{layer}} could not be matched to a reference org " +"unit (wrong level, no matching parent, or outside every boundary) and will " +"be excluded from the Combined table." +msgstr[0] "" +"{{count}} feature(s) from {{layer}} could not be matched to a reference org " +"unit (wrong level, no matching parent, or outside every boundary) and will " +"be excluded from the Combined table." +msgstr[1] "" +"{{count}} feature(s) from {{layer}} could not be matched to a reference org " +"unit (wrong level, no matching parent, or outside every boundary) and will " +"be excluded from the Combined table." + +msgid "Aggregation type for {{name}} ({{layer}})" +msgstr "Aggregation type for {{name}} ({{layer}})" + +msgid "Aggregation type for {{layer}}" +msgstr "Aggregation type for {{layer}}" + +msgid "" +"Several {{layer}} features roll up into each reference org unit here - " +"{{type}} is an approximation of the values you can see joined in, not a " +"recomputation over the combined area." +msgstr "" +"Several {{layer}} features roll up into each reference org unit here - " +"{{type}} is an approximation of the values you can see joined in, not a " +"recomputation over the combined area." + +msgid "Categories" +msgstr "Categories" + +msgid "Category display for {{layer}}" +msgstr "Category display for {{layer}}" + msgid "Choose a data table to view" msgstr "Choose a data table to view" +msgid "Combined" +msgstr "Combined" + +msgid "Configure reference org units" +msgstr "Configure reference org units" + msgid "{{filtered}} of {{total}} rows" msgstr "{{filtered}} of {{total}} rows" @@ -338,6 +458,45 @@ msgstr "{{total}} rows" msgid "Show only features in current map view" msgstr "Show only features in current map view" +msgid "Count ({{layer}})" +msgstr "Count ({{layer}})" + +msgid "{{name}} ({{unit}}) ({{layer}})" +msgstr "{{name}} ({{unit}}) ({{layer}})" + +msgid "%" +msgstr "%" + +msgid "count" +msgstr "count" + +msgid "Value ({{layer}}, {{period}})" +msgstr "Value ({{layer}}, {{period}})" + +msgid "Current period" +msgstr "Current period" + +msgid "{{name}} ({{layer}})" +msgstr "{{name}} ({{layer}})" + +msgid "Value ({{layer}})" +msgstr "Value ({{layer}})" + +msgid "Legend ({{layer}})" +msgstr "Legend ({{layer}})" + +msgid "Legend ({{layer}}, {{period}})" +msgstr "Legend ({{layer}}, {{period}})" + +msgid "Org unit id" +msgstr "Org unit id" + +msgid "Org unit level" +msgstr "Org unit level" + +msgid "Org unit hierarchy" +msgstr "Org unit hierarchy" + msgid "No valid data was found for the current layer configuration." msgstr "No valid data was found for the current layer configuration." @@ -450,6 +609,9 @@ msgstr "Earth Engine" msgid "feature" msgstr "feature" +msgid "reference org units" +msgstr "reference org units" + msgid "Edit {{name}} layer" msgstr "Edit {{name}} layer" @@ -462,6 +624,9 @@ msgstr "Update layer" msgid "Add layer" msgstr "Add layer" +msgid "Update reference" +msgstr "Update reference" + msgid "Area statistics (popups and data table)" msgstr "Area statistics (popups and data table)" @@ -1232,9 +1397,6 @@ msgstr "Tracked Entity Type" msgid "By data element" msgstr "By data element" -msgid "Count" -msgstr "Count" - msgid "Average" msgstr "Average" @@ -2089,15 +2251,6 @@ msgstr "GroupSet used for styling was not found" msgid "Id" msgstr "Id" -msgid "Org unit id" -msgstr "Org unit id" - -msgid "Org unit" -msgstr "Org unit" - -msgid "Org unit level" -msgstr "Org unit level" - msgid "Geometry type" msgstr "Geometry type" @@ -2107,9 +2260,6 @@ msgstr "Range" msgid "Org unit boundary" msgstr "Org unit boundary" -msgid "Org unit hierarchy" -msgstr "Org unit hierarchy" - msgid "Created" msgstr "Created" @@ -2119,9 +2269,6 @@ msgstr "Group" msgid "Icon" msgstr "Icon" -msgid "Current period" -msgstr "Current period" - msgid "Value ({{period}})" msgstr "Value ({{period}})"