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}})" 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/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/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/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__/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__/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/__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__/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/__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/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/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/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/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); +} 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/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/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/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 ( { ) 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/map/MapView.jsx b/src/components/map/MapView.jsx index 53e354b3d4..83199e4231 100644 --- a/src/components/map/MapView.jsx +++ b/src/components/map/MapView.jsx @@ -18,6 +18,7 @@ const MapView = (props) => { 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/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/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/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/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 ea8e695587..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' @@ -26,3 +27,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/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/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'), 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..2c17122159 100644 --- a/src/reducers/__tests__/map.spec.js +++ b/src/reducers/__tests__/map.spec.js @@ -1,4 +1,10 @@ import * as types from '../../constants/actionTypes.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' @@ -257,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 = { @@ -269,6 +358,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 +522,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 +708,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..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 = { @@ -94,6 +96,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 +205,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 +320,8 @@ const map = (state = defaultState, action) => { ...action.payload, id: generateUid(), isVisible: action.payload.isVisible ?? true, + combinedLayerKey: + action.payload.combinedLayerKey ?? generateUid(), }, ], } @@ -286,9 +329,32 @@ 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.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: { @@ -301,6 +367,7 @@ const map = (state = defaultState, action) => { const duplicate = { ...state.mapViews[sourceIndex], id: generateUid(), + combinedLayerKey: generateUid(), } delete duplicate.isLoading delete duplicate.coordinate @@ -337,6 +404,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 } 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__/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..41e6ca81bf 100644 --- a/src/util/__tests__/dataTable.spec.js +++ b/src/util/__tests__/dataTable.spec.js @@ -1,15 +1,35 @@ -import { EXTERNAL_LAYER, THEMATIC_LAYER } from '../../constants/layers.js' +import { + DATA_KEY_KIND_CATEGORY, + DATA_KEY_KIND_COUNT, + DATA_KEY_KIND_VALUE, +} from '../../constants/dataTable.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, + getCombinedLegendConfig, + getCombinedValueDataKeys, + getDefaultCombinedAggregation, + getDefaultReferenceRows, getEligibleDataTableLayers, + getFeatureCategoryKey, getLayerSelectedIds, getNextSorting, getPanelHeights, getRowClickAction, getRowId, + getUnionBounds, hasActiveDataTableFilters, isDataTableOpen, isFilterable, + mergeCrossLayerIds, shouldClearFeatureHighlight, } from '../dataTable.js' @@ -272,24 +292,37 @@ describe('isDataTableOpen', () => { expect( isDataTableOpen({ openIds: ['layer1'], + combinedView: false, isPanelVisible: true, }) ).toBe(true) }) - test('is closed when there are no open tabs', () => { + 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 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) @@ -318,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', () => { @@ -361,3 +429,901 @@ 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 + ) + }) +}) + +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/__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/__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/__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/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/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..0e332edc53 100644 --- a/src/util/dataTable.js +++ b/src/util/dataTable.js @@ -1,5 +1,291 @@ -import { SORT_ASCENDING, SORT_DESCENDING } from '../constants/dataTable.js' -import { DATA_TABLE_LAYER_TYPES } from '../constants/layers.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, + FACILITY_LAYER, + EVENT_LAYER, + TRACKED_ENTITY_LAYER, + RENDERING_STRATEGY_SINGLE, + RENDERING_STRATEGY_TIMELINE, +} from '../constants/layers.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 @@ -59,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() @@ -81,6 +370,110 @@ 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, + 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/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) 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, 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, + } + }) +}