diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index 45eb84debd..6e6bfc048f 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -358,7 +358,7 @@ describe('data table', () => { cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') // Check that row 0 range value is empty - checkTableCell({ row: 0, column: 5, expectedContent: '' }) + checkTableCell({ row: 0, column: 8, expectedContent: '' }) // Sort by range, which is a string cy.getByDataTest('data-table-column-sort-button-Range').click() @@ -367,12 +367,12 @@ describe('data table', () => { cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') // Check that row 0 range value has value '0-40' - checkTableCell({ row: 0, column: 5, expectedContent: '0 – 40' }) + checkTableCell({ row: 0, column: 8, expectedContent: '0 – 40' }) // Check that row 5 range value has value '90 - 120' - checkTableCell({ row: 5, column: 5, expectedContent: '90 – 120' }) + checkTableCell({ row: 5, column: 8, expectedContent: '90 – 120' }) // Check that row 6 range value is empty - checkTableCell({ row: 6, column: 5, expectedContent: '' }) + checkTableCell({ row: 6, column: 8, expectedContent: '' }) }) }) diff --git a/cypress/integration/layers/eventlayer.cy.js b/cypress/integration/layers/eventlayer.cy.js index 9b77e74e3a..ab813a44f7 100644 --- a/cypress/integration/layers/eventlayer.cy.js +++ b/cypress/integration/layers/eventlayer.cy.js @@ -338,7 +338,7 @@ context('Event Layers', () => { 'Event location', '-13.188339, 8.405215', 'Organisation unit', - 'Event time', + 'Event date', 'Age in years', 'Mode of Discharge', ]) diff --git a/i18n/en.pot b/i18n/en.pot index 5f7fed09f9..9375e4be98 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-04T12:03:11.272Z\n" -"PO-Revision-Date: 2026-09-04T12:03:11.272Z\n" +"POT-Creation-Date: 2026-09-07T08:34:02.249Z\n" +"PO-Revision-Date: 2026-09-07T08:34:02.249Z\n" msgid "2020" msgstr "2020" @@ -167,6 +167,9 @@ msgstr "Sort by Selected" msgid "Sort by {{column}}" msgstr "Sort by {{column}}" +msgid "Edit layer" +msgstr "Edit layer" + msgid "Something went wrong" msgstr "Something went wrong" @@ -248,6 +251,12 @@ 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" + +msgid "Show event details" +msgstr "Show event details" + msgid "No features match your filters" msgstr "No features match your filters" @@ -302,9 +311,6 @@ msgstr "{{total}} rows" msgid "Show only features in current map view" msgstr "Show only features in current map view" -msgid "Data table is not supported when events are grouped on the server." -msgstr "Data table is not supported when events are grouped on the server." - msgid "No valid data was found for the current layer configuration." msgstr "No valid data was found for the current layer configuration." @@ -318,33 +324,6 @@ msgstr "" msgid "No valid data fields were found for this layer." msgstr "No valid data fields were found for this layer." -msgid "Id" -msgstr "Id" - -msgid "Level" -msgstr "Level" - -msgid "Parent" -msgstr "Parent" - -msgid "Type" -msgstr "Type" - -msgid "Legend" -msgstr "Legend" - -msgid "Range" -msgstr "Range" - -msgid "Org unit" -msgstr "Org unit" - -msgid "Org unit boundary" -msgstr "Org unit boundary" - -msgid "Event time" -msgstr "Event time" - msgid "Loading Earth Engine data…" msgstr "Loading Earth Engine data…" @@ -833,9 +812,6 @@ msgstr "Open in Data Visualizer app" msgid "Download data" msgstr "Download data" -msgid "Edit layer" -msgstr "Edit layer" - msgid "Duplicate layer" msgstr "Duplicate layer" @@ -909,6 +885,9 @@ msgstr[1] "{{n}} org units without coordinates" msgid "Selected org units: No coordinates found" msgstr "Selected org units: No coordinates found" +msgid "External layer definition not found, showing last known settings" +msgstr "External layer definition not found, showing last known settings" + msgid "Error" msgstr "Error" @@ -940,12 +919,18 @@ msgstr "Could not retrieve event data" msgid "Organisation unit" msgstr "Organisation unit" +msgid "Event date" +msgstr "Event date" + msgid "Groups" msgstr "Groups" msgid "Parent unit" msgstr "Parent unit" +msgid "Level" +msgstr "Level" + msgid "Not set" msgstr "Not set" @@ -1071,6 +1056,9 @@ msgstr "No data found for this period." msgid "Image of the organisation unit" msgstr "Image of the organisation unit" +msgid "Parent" +msgstr "Parent" + msgid "Code" msgstr "Code" @@ -1190,6 +1178,9 @@ msgstr "Click to unpin legend" msgid "Click to pin legend" msgstr "Click to pin legend" +msgid "Legend" +msgstr "Legend" + msgid "Hide layer" msgstr "Hide layer" @@ -2065,6 +2056,33 @@ msgstr "Facility" msgid "GroupSet used for styling was not found" msgstr "GroupSet used for styling was not found" +msgid "Id" +msgstr "Id" + +msgid "Type" +msgstr "Type" + +msgid "Range" +msgstr "Range" + +msgid "Org unit" +msgstr "Org unit" + +msgid "Org unit boundary" +msgstr "Org unit boundary" + +msgid "Group" +msgstr "Group" + +msgid "Icon" +msgstr "Icon" + +msgid "Current period" +msgstr "Current period" + +msgid "Value ({{period}})" +msgstr "Value ({{period}})" + msgid "Start date is invalid" msgstr "Start date is invalid" diff --git a/package.json b/package.json index 05dd149b49..42f07ecd43 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@dhis2/analytics": "^29.5.5", "@dhis2/app-runtime": "^3.17.3", "@dhis2/app-service-datastore": "^1.0.0-beta.3", - "@dhis2/maps-gl": "git+https://github.com/d2-ci/maps-gl.git#e89c7e9bf5634da8b13684c314eb22838f629ed6", + "@dhis2/maps-gl": "git+https://github.com/d2-ci/maps-gl.git#6758ac621ff7ed582ad458bb4c9f90900358cedc", "@dhis2/ui": "^10.17.0", "@dnd-kit/core": "^6.0.8", "@dnd-kit/modifiers": "^9.0.0", diff --git a/src/actions/__tests__/dataTable.spec.js b/src/actions/__tests__/dataTable.spec.js index bc50b2763d..217c86a66c 100644 --- a/src/actions/__tests__/dataTable.spec.js +++ b/src/actions/__tests__/dataTable.spec.js @@ -3,6 +3,7 @@ import { closeDataTable, toggleDataTable, resizeDataTable, + setActiveTimelinePeriod, } from '../dataTable.js' describe('closeDataTable', () => { @@ -30,3 +31,13 @@ describe('resizeDataTable', () => { }) }) }) + +describe('setActiveTimelinePeriod', () => { + it('creates an ACTIVE_TIMELINE_PERIOD_SET action', () => { + const period = { id: '202301', name: 'January 2023' } + expect(setActiveTimelinePeriod(period)).toEqual({ + type: types.ACTIVE_TIMELINE_PERIOD_SET, + period, + }) + }) +}) diff --git a/src/actions/dataTable.js b/src/actions/dataTable.js index ceb7ed56e3..281c7e9cef 100644 --- a/src/actions/dataTable.js +++ b/src/actions/dataTable.js @@ -38,3 +38,8 @@ export const setDataTableColumnConfig = (layerId, config) => ({ layerId, config, }) + +export const setActiveTimelinePeriod = (period) => ({ + type: types.ACTIVE_TIMELINE_PERIOD_SET, + period, +}) diff --git a/src/actions/layers.js b/src/actions/layers.js index 1af92b1d58..4583af12f7 100644 --- a/src/actions/layers.js +++ b/src/actions/layers.js @@ -66,3 +66,9 @@ export const setLayerLoading = (id) => ({ type: types.LAYER_LOADING_SET, id, }) + +// Force client-side clustering for a server-clustered event layer (session-only) +export const setForceClientCluster = (id) => ({ + type: types.LAYER_FORCE_CLIENT_CLUSTER_SET, + id, +}) diff --git a/src/components/core/Checkbox.jsx b/src/components/core/Checkbox.jsx index 5567af53e2..2f19b9e406 100644 --- a/src/components/core/Checkbox.jsx +++ b/src/components/core/Checkbox.jsx @@ -8,6 +8,7 @@ import styles from './styles/Checkbox.module.css' const Checkbox = ({ label, checked = false, + indeterminate = false, disabled, dense = true, tooltip, @@ -23,7 +24,8 @@ const Checkbox = ({ > onChange(checked)} @@ -43,6 +45,7 @@ Checkbox.propTypes = { dataTest: PropTypes.string, dense: PropTypes.bool, disabled: PropTypes.bool, + indeterminate: PropTypes.bool, label: PropTypes.node, style: PropTypes.object, tooltip: PropTypes.string, diff --git a/src/components/core/styles/Checkbox.module.css b/src/components/core/styles/Checkbox.module.css index ead0686f2f..410baa373c 100644 --- a/src/components/core/styles/Checkbox.module.css +++ b/src/components/core/styles/Checkbox.module.css @@ -5,7 +5,7 @@ } /* This styles the tooltip span containing the svg */ -.checkbox span { +.checkbox > span { margin-left: var(--spacers-dp4); display: flex; flex-direction: column; diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 8ff0c7bb70..85e78eae04 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -16,8 +16,7 @@ import { } from '../../actions/dataTable.js' import useDebouncedValue from '../../hooks/useDebouncedValue.js' import useKeyDown from '../../hooks/useKeyDown.js' -import { getCssVar } from '../../util/helpers.js' -import { useWindowDimensions } from '../WindowDimensionsProvider.jsx' +import { hasActiveDataTableFilters } from '../../util/dataTable.js' import ActiveLayerControl from './controls/ActiveLayerControl.jsx' import ClearFiltersControl from './controls/ClearFiltersControl.jsx' import CloseControl from './controls/CloseControl.jsx' @@ -31,12 +30,12 @@ import ShowInViewControl from './controls/ShowInViewControl.jsx' import DataTable from './DataTable.jsx' import ErrorBoundary from './ErrorBoundary.jsx' import styles from './styles/BottomPanel.module.css' +import { usePanelHeights } from './usePanelHeights.js' const MIN_HEIGHT = 50 const EMPTY_FILTERS = {} const BottomPanel = () => { - const dataTableHeight = useSelector((state) => state.ui.dataTableHeight) const activeLayerId = useSelector((state) => state.dataTable) const activeLayer = useSelector((state) => state.map.mapViews.find((l) => l.id === activeLayerId) @@ -49,9 +48,9 @@ const BottomPanel = () => { const highlightColor = useSelector((state) => state.ui.highlightColor) const dispatch = useDispatch() - const { height } = useWindowDimensions() const panelRef = useRef(null) const isDraggingRef = useRef(false) + const preDragCollapsedRef = useRef(false) const [panelWidth, setPanelWidth] = useState(0) const [totalCount, setTotalCount] = useState(null) const [filteredCount, setFilteredCount] = useState(null) @@ -60,18 +59,15 @@ const BottomPanel = () => { const globalSearch = useDebouncedValue(searchInputValue, 200) const [headersByLayer, setHeadersByLayer] = useState(null) - const hasActiveFilters = - Object.keys(dataFilters).length > 0 || - searchInputValue.trim() !== '' || - selectionFilter?.length > 0 || - showOnlyFeaturesInView + const hasActiveFilters = hasActiveDataTableFilters({ + dataFilters, + globalSearch: searchInputValue, + selectionFilter, + showOnlyFeaturesInView, + }) - const maxHeight = - height - getCssVar('--header-height') - getCssVar('--toolbar-height') - const tableHeight = - dataTableHeight < maxHeight ? dataTableHeight : maxHeight - const collapsedHeight = getCssVar('--data-table-controls-height') - const displayHeight = isCollapsed ? collapsedHeight : tableHeight + const { maxHeight, collapsedHeight, displayHeight } = + usePanelHeights(isCollapsed) const toggleCollapsed = useCallback( () => setIsCollapsed((collapsed) => !collapsed), @@ -90,7 +86,8 @@ const BottomPanel = () => { const onResizeStart = useCallback(() => { isDraggingRef.current = true - }, []) + preDragCollapsedRef.current = isCollapsed + }, [isCollapsed]) const onResize = useCallback( (h) => { @@ -116,6 +113,15 @@ const BottomPanel = () => { [dispatch] ) + const onResizeCancel = useCallback(() => { + isDraggingRef.current = false + setIsCollapsed(preDragCollapsedRef.current) + document.documentElement.style.setProperty( + '--data-table-height', + `${displayHeight}px` + ) + }, [displayHeight]) + const onCountChange = useCallback((total, filtered) => { setTotalCount(total) setFilteredCount(filtered) @@ -217,6 +223,7 @@ const BottomPanel = () => { onResizeStart={onResizeStart} onResize={onResize} onResizeEnd={onResizeEnd} + onResizeCancel={onResizeCancel} /> { - {!isCollapsed && ( -
- - - -
- )} +
+ + + +
) } diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 5b4eb8f5aa..c03403a3f0 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -1,7 +1,6 @@ import i18n from '@dhis2/d2-i18n' import { DataTableRow, - DataTableCell, DataTableColumnHeader, ComponentCover, CenteredContent, @@ -22,6 +21,7 @@ import { useSelector, useDispatch } from 'react-redux' import { TableVirtuoso } from 'react-virtuoso' import { setSelectionFilter } from '../../actions/dataTable.js' import { highlightFeature } from '../../actions/feature.js' +import { editLayer, setForceClientCluster } from '../../actions/layers.js' import { toggleFeatureSelection, selectFeatureRange, @@ -30,15 +30,15 @@ import { SENTINEL_SELECTED_ROW, SORT_ASCENDING, } from '../../constants/dataTable.js' -import { isDarkColor } from '../../util/colors.js' import { + buildFeatureIndex, getNextSorting, getRowClickAction, getRowId, + hasActiveDataTableFilters, isFilterable, shouldClearFeatureHighlight, } from '../../util/dataTable.js' -import { formatWithSeparator } from '../../util/numbers.js' import { getPinnedCellProps, getPinnedCount, @@ -48,6 +48,7 @@ import { import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import { SortIcon } from '../core/icons.jsx' import FilterInput from './FilterInput.jsx' +import RowCells from './RowCells.jsx' import SelectionFilterButton from './SelectionFilterButton.jsx' import styles from './styles/DataTable.module.css' import TableContextMenu from './TableContextMenu.jsx' @@ -57,6 +58,9 @@ import { useColumnWidths } from './useColumnWidths.js' import { useRowSelection } from './useRowSelection.js' import { useTableData } from './useTableData.js' +const TABLE_STYLE = { height: '100%', width: '100%' } +const VIEWPORT_OVERSCAN = { top: 400, bottom: 400 } + const Table = ({ availableWidth, onCountChange, @@ -97,11 +101,17 @@ const Table = ({ [sortField, sortDirection] ) + // Read via ref rather than a dependency, so this callback stays stable + // across hovers instead of getting a new identity on every single mouse-enter + const featureRef = useRef(feature) + featureRef.current = feature + const setFeatureHighlight = useCallback( (row) => { const id = getRowId(row) + const currentFeature = featureRef.current - if (!id || !feature || id !== feature.id) { + if (!id || !currentFeature || id !== currentFeature.id) { dispatch( highlightFeature( id @@ -115,7 +125,7 @@ const Table = ({ ) } }, - [feature, dispatch, layer.id] + [dispatch, layer.id] ) const clearFeatureHighlight = useCallback( (event) => { @@ -126,16 +136,10 @@ const Table = ({ [dispatch] ) - const featureById = useMemo(() => { - const map = new Map() - layer.data?.forEach((f) => { - const id = f.properties?.id ?? f.id - if (id != null) { - map.set(id, f) - } - }) - return map - }, [layer.data]) + const featureById = useMemo( + () => buildFeatureIndex(layer.data), + [layer.data] + ) const [tableContextMenu, setTableContextMenu] = useState(null) @@ -177,11 +181,12 @@ const Table = ({ selectionFilter, selectedIdSet, globalSearch, + keyAnalysisDigitGroupSeparator, }) useEffect(() => { - onHeadersChange?.(headers, activeLayerId) - }, [onHeadersChange, headers, activeLayerId]) + onHeadersChange?.(headers, layer.id) + }, [onHeadersChange, headers, layer.id]) const columnConfig = layer.dataTableColumnConfig const pinnedKeys = useMemo( @@ -190,10 +195,20 @@ const Table = ({ ) const visibleHeaders = useMemo( - () => getVisibleHeaders(headers, columnConfig), + () => 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, @@ -269,10 +284,20 @@ const Table = ({ [dispatch, layer.id] ) - const hasActiveFilters = - Object.keys(layer.dataFilters ?? {}).length > 0 || - !!globalSearch?.trim() || - selectionFilter?.length > 0 + const hasActiveFilters = hasActiveDataTableFilters({ + dataFilters: layer.dataFilters, + globalSearch, + selectionFilter, + showOnlyFeaturesInView, + }) + + const showServerClusterAction = + layer.serverCluster && !layer.forceClientCluster + + const onForceClientCluster = useCallback( + () => dispatch(setForceClientCluster(layer.id)), + [dispatch, layer.id] + ) const tableContext = useMemo( () => ({ @@ -285,6 +310,8 @@ const Table = ({ totalCount, hasActiveFilters, onClearFilters, + showServerClusterAction, + onForceClientCluster, }), [ setFeatureHighlight, @@ -296,6 +323,8 @@ const Table = ({ totalCount, hasActiveFilters, onClearFilters, + showServerClusterAction, + onForceClientCluster, ] ) @@ -337,274 +366,243 @@ const Table = ({ layerId: layer.id, }) - if (error) { - return

{error}

- } + const computeItemKey = useCallback( + (index, row) => getRowId(row) ?? index, + [] + ) - return ( - <> - getRowId(row) ?? index} - increaseViewportBy={{ top: 400, bottom: 400 }} - fixedHeaderContent={() => ( - - - dispatch(setSelectionFilter(next)) - } - /> + const fixedHeaderContent = useCallback( + () => ( + + + dispatch(setSelectionFilter(next)) } + /> + } + > +
+ + + + -
- - - - - - - - - -
- - {visibleHeaders.map( - ({ name, dataKey, type, optionSet }, index) => { - const { fixed, left, isLastPinned } = - getPinnedCellProps(dataKey, index, { - pinnedLeftOffsets, - pinnedColumnCount, - columnWidths, + +
+ + - - - - ) - } - )} - - )} - itemContent={(_, row) => { - const rowId = getRowId(row) - const isSelected = !!rowId && selectedIdSet.has(rowId) - const isHovered = - !!rowId && - feature?.id === rowId && - feature?.layerId === layer.id - - const cellsByDataKey = new Map( - row.map((cell) => [cell.dataKey, cell]) - ) - - return ( - <> - - - rowId && - dispatch( - toggleFeatureSelection( - rowId, - layer.id - ) - ) + e.stopPropagation()} /> - - {visibleHeaders.map(({ dataKey }, index) => { - const cell = cellsByDataKey.get(dataKey) - if (!cell) { - return null + + +
+
+ {visibleHeaders.map( + ({ name, dataKey, type, optionSet, renderer }, index) => { + const { fixed, left, isLastPinned } = + getPinnedCellProps(dataKey, index, { + pinnedLeftOffsets, + pinnedColumnCount, + columnWidths, + }) + return ( + + ) + } + width={ + columnWidths.length > 0 + ? `${columnWidths[index]}px` + : 'auto' + } + > + + + {name} + + - {dataKey === 'color' - ? value?.toLowerCase() - : formatWithSeparator( - value, - keyAnalysisDigitGroupSeparator - )} - - ) - })} - - ) - }} + + + + + ) + } + )} +
+ ), + [ + isCheckboxColumnPinned, + selectionFilter, + dispatch, + allRowIds, + onReverseSelection, + sortData, + sortField, + sortDirection, + visibleHeaders, + pinnedLeftOffsets, + pinnedColumnCount, + columnWidths, + columnOptions, + isAllSelected, + onToggleSelectAll, + headerRowRef, + ] + ) + + const onToggleSelection = useCallback( + (rowId) => dispatch(toggleFeatureSelection(rowId, layer.id)), + [dispatch, layer.id] + ) + + const itemContent = useCallback( + (_, row) => ( + + ), + [ + visibleHeaders, + selectedIdSet, + feature, + layer.id, + isCheckboxColumnPinned, + pinnedLeftOffsets, + pinnedColumnCount, + columnWidths, + rendererByDataKey, + typeByDataKey, + keyAnalysisDigitGroupSeparator, + onToggleSelection, + ] + ) + + if (error) { + return ( +

+ {error} + +

+ ) + } + + return ( + <> + {(isLoading || layer?.isLoaded === false || layer?.isLoading) && ( - +
- + {loadingReason && ( {loadingReason} diff --git a/src/components/datatable/ErrorBoundary.jsx b/src/components/datatable/ErrorBoundary.jsx index d9032eab8a..b8c0db86c1 100644 --- a/src/components/datatable/ErrorBoundary.jsx +++ b/src/components/datatable/ErrorBoundary.jsx @@ -2,6 +2,7 @@ import i18n from '@dhis2/d2-i18n' import { CenteredContent } from '@dhis2/ui' import PropTypes from 'prop-types' import React, { Component } from 'react' +import styles from './styles/ErrorBoundary.module.css' class ErrorBoundary extends Component { constructor(props) { @@ -24,7 +25,9 @@ class ErrorBoundary extends Component { if (this.state.error) { return ( -

{i18n.t('Something went wrong')}

+

+ {i18n.t('Something went wrong')} +

) } diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index 91d4759221..0741860ad6 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -2,21 +2,32 @@ import i18n from '@dhis2/d2-i18n' import { Input, IconFilter16, IconSync16 } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' -import React, { useMemo, useRef, useState } from 'react' +import React, { useCallback, useMemo, useRef, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { Virtuoso } from 'react-virtuoso' import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' import { SENTINEL_ANY_VALUE, SENTINEL_NO_VALUE, + RENDERER_COLOR, + RENDERER_ICON, + TYPE_NUMBER, + // TYPE_DATE, + // TYPE_DATETIME, + // TYPE_TIME, } from '../../constants/dataTable.js' import useOptionSet from '../../hooks/useOptionSet.js' import { + getCyclicIndex, getDisplayValue, getFilteredOptions, getPopoverWidth, getSelectedAndAppliedString, measureMaxTextWidth, + toHighlightedIndex, + toOptionIndex, + OPTION_ROW_HEIGHT, + MAX_LIST_HEIGHT, } from '../../util/filterInput.js' import { getInvertibleValues, @@ -27,6 +38,7 @@ import { import { formatWithSeparator } from '../../util/numbers.js' import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import Checkbox from '../core/Checkbox.jsx' +// import DateGroupFilterInput from './DateGroupFilterInput.jsx' import { FilterDropdownPopover, getDropdownPlacement, @@ -34,8 +46,6 @@ import { import FilterHelpTooltip from './FilterHelpTooltip.jsx' import styles from './styles/FilterInput.module.css' -const OPTION_ROW_HEIGHT = 28 // Checkbox rows are a fixed height so the list can be virtualized -const MAX_LIST_HEIGHT = 260 const NUMERIC_HELP_HEIGHT = 140 const TEXT_HELP_HEIGHT = 56 const NUMERIC_FILTER_HELP = ( @@ -56,7 +66,7 @@ const TEXT_FILTER_HELP = ( ) const NUMERIC_INPUT_DISALLOWED = /[^0-9.\-<>=,&\s]/g -const SearchableFilterPopover = ({ +const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ dataKey, name, layerId, @@ -64,8 +74,9 @@ const SearchableFilterPopover = ({ options, resolveLabel, type, + renderer, allowCustomFilter = true, -}) => { +}) { const dispatch = useDispatch() const anchorRef = useRef(null) const listRef = useRef(null) @@ -104,13 +115,37 @@ const SearchableFilterPopover = ({ ? dispatch(setDataFilter(layerId, dataKey, text)) : dispatch(clearDataFilter(layerId, dataKey)) - const hasNotSetOption = options.some( - ({ value }) => value === SENTINEL_NO_VALUE + const isIconColumn = renderer === RENDERER_ICON + + const renderOptionLabel = (value) => + isIconColumn ? ( + + { + e.target.style.visibility = 'hidden' + }} + /> + {value.split('/').pop()} + + ) : ( + resolveLabel(value) + ) + + const hasNotSetOption = useMemo( + () => options.some(({ value }) => value === SENTINEL_NO_VALUE), + [options] ) - const realOptions = options.filter( - ({ value }) => value !== SENTINEL_NO_VALUE + const realOptions = useMemo( + () => options.filter(({ value }) => value !== SENTINEL_NO_VALUE), + [options] + ) + const realValues = useMemo( + () => realOptions.map((o) => o.value), + [realOptions] ) - const realValues = realOptions.map((o) => o.value) const anyValueActive = selected.includes(SENTINEL_ANY_VALUE) const popoverWidth = useMemo(() => { @@ -121,12 +156,16 @@ const SearchableFilterPopover = ({ const font = `11px ${getComputedStyle(document.body).fontFamily}` const maxLabelWidth = measureMaxTextWidth(labels, font) return getPopoverWidth(maxLabelWidth) + // resolveLabel's identity only changes alongside type/optionSet, which don't change without realOptions changing too // eslint-disable-next-line react-hooks/exhaustive-deps }, [realOptions, hasNotSetOption]) const onToggleAnyValue = () => applyValues(toggleAnyValue(selected)) - const invertibleValues = getInvertibleValues(hasNotSetOption, realValues) + const invertibleValues = useMemo( + () => getInvertibleValues(hasNotSetOption, realValues), + [hasNotSetOption, realValues] + ) const onToggleRealValue = (value) => applyValues(toggleRealValue(selected, value, realValues)) @@ -136,28 +175,28 @@ const SearchableFilterPopover = ({ const trimmedSearch = searchText.trim() const normalizedSearch = trimmedSearch.toLowerCase() - const filteredOptions = getFilteredOptions({ - realOptions, - trimmedSearch, - normalizedSearch, - type, - resolveLabel, - }) - const hasExactMatch = filteredOptions.some( - ({ value }) => resolveLabel(value).toLowerCase() === normalizedSearch + const filteredOptions = useMemo( + () => + getFilteredOptions({ + realOptions, + trimmedSearch, + normalizedSearch, + type, + resolveLabel, + }), + [realOptions, trimmedSearch, normalizedSearch, type, resolveLabel] ) - const showCustomFilterRow = - allowCustomFilter && normalizedSearch !== '' && !hasExactMatch + const showCustomFilterRow = allowCustomFilter && normalizedSearch !== '' const totalCount = filteredOptions.length + (showCustomFilterRow ? 1 : 0) const customFilterTag = - type === 'number' ? i18n.t('Use filter') : i18n.t('Contains') + type === TYPE_NUMBER ? i18n.t('Use filter') : i18n.t('Contains') const hasActiveFilter = selected.length > 0 || appliedString !== '' const onSearchChange = ({ value }) => { const sanitized = - type === 'number' + type === TYPE_NUMBER ? value.replace(NUMERIC_INPUT_DISALLOWED, '') : value setSearchText(sanitized) @@ -175,18 +214,11 @@ const SearchableFilterPopover = ({ return } - const normalized = trimmed.toLowerCase() - const exactMatch = options.some( - ({ value: optionValue }) => - resolveLabel(optionValue).toLowerCase() === normalized - ) - if (!exactMatch) { - applyCustomFilter(trimmed) - } + applyCustomFilter(trimmed) } const scrollHighlightedIntoView = (index) => { - const optionIndex = showCustomFilterRow ? index - 1 : index + const optionIndex = toOptionIndex(index, showCustomFilterRow) if (optionIndex >= 0 && optionIndex < filteredOptions.length) { listRef.current?.scrollToIndex({ index: optionIndex, @@ -206,9 +238,7 @@ const SearchableFilterPopover = ({ applyCustomFilter(searchText.trim()) return } - const optionIndex = showCustomFilterRow - ? highlightedIndex - 1 - : highlightedIndex + const optionIndex = toOptionIndex(highlightedIndex, showCustomFilterRow) if (optionIndex >= 0 && optionIndex < filteredOptions.length) { toggleValue(filteredOptions[optionIndex].value) } @@ -219,7 +249,7 @@ const SearchableFilterPopover = ({ case 'ArrowDown': event.preventDefault() setHighlightedIndex((i) => { - const next = totalCount ? (i + 1) % totalCount : -1 + const next = getCyclicIndex(i, totalCount, 1) scrollHighlightedIntoView(next) return next }) @@ -227,9 +257,7 @@ const SearchableFilterPopover = ({ case 'ArrowUp': event.preventDefault() setHighlightedIndex((i) => { - const next = totalCount - ? (i - 1 + totalCount) % totalCount - : -1 + const next = getCyclicIndex(i, totalCount, -1) scrollHighlightedIntoView(next) return next }) @@ -261,7 +289,7 @@ const SearchableFilterPopover = ({ clearable dataTest={`data-table-column-filter-search-${name}`} placeholder={ - type === 'number' + type === TYPE_NUMBER ? i18n.t('Search or type > 5, < 8…') : i18n.t('Search') } @@ -280,11 +308,15 @@ const SearchableFilterPopover = ({
@@ -401,7 +433,9 @@ const SearchableFilterPopover = ({ computeItemKey={(_, option) => option.value} itemContent={(index, option) => ( )} @@ -430,7 +465,7 @@ const SearchableFilterPopover = ({ )}
) -} +}) SearchableFilterPopover.propTypes = { dataKey: PropTypes.string.isRequired, @@ -445,6 +480,7 @@ SearchableFilterPopover.propTypes = { PropTypes.arrayOf(PropTypes.string), ]), layerId: PropTypes.string, + renderer: PropTypes.string, } const PlainSearchableFilter = (props) => { @@ -453,14 +489,20 @@ const PlainSearchableFilter = (props) => { systemSettings: { keyAnalysisDigitGroupSeparator }, } = useCachedData() - const resolveLabel = (value) => { - if (value === SENTINEL_NO_VALUE) { - return i18n.t('No value') - } - return type === 'number' - ? formatWithSeparator(Number(value), keyAnalysisDigitGroupSeparator) - : value - } + const resolveLabel = useCallback( + (value) => { + if (value === SENTINEL_NO_VALUE) { + return i18n.t('No value') + } + return type === TYPE_NUMBER + ? formatWithSeparator( + Number(value), + keyAnalysisDigitGroupSeparator + ) + : value + }, + [type, keyAnalysisDigitGroupSeparator] + ) return } @@ -471,10 +513,18 @@ PlainSearchableFilter.propTypes = { const OptionSetSearchableFilter = ({ optionSetId, ...props }) => { const { optionSet } = useOptionSet(optionSetId) - const resolveLabel = (value) => - value === SENTINEL_NO_VALUE - ? i18n.t('No value') - : optionSet?.options.find((o) => o.code === value)?.name ?? value + const optionByCode = useMemo(() => { + const map = new Map() + optionSet?.options.forEach((o) => map.set(o.code, o)) + return map + }, [optionSet]) + const resolveLabel = useCallback( + (value) => + value === SENTINEL_NO_VALUE + ? i18n.t('No value') + : optionByCode.get(value)?.name ?? value, + [optionByCode] + ) return ( { +const FilterInput = React.memo(function FilterInput({ + type, + dataKey, + name, + options, + optionSetId, + renderer, +}) { const dataTable = useSelector((state) => state.dataTable) const map = useSelector((state) => state.map) @@ -504,6 +561,20 @@ const FilterInput = ({ type, dataKey, name, options, optionSetId }) => { const filterValue = filters?.[dataKey] + /* const isDateType = + type === TYPE_DATE || type === TYPE_DATETIME || type === TYPE_TIME */ + + /* return isDateType ? ( + + ) : */ + return optionSetId ? ( { options={options ?? []} optionSetId={optionSetId} type={type} + renderer={renderer} /> ) : ( { filterValue={filterValue} options={options ?? []} type={type} + renderer={renderer} /> ) -} +}) FilterInput.propTypes = { dataKey: PropTypes.string.isRequired, @@ -532,6 +605,7 @@ FilterInput.propTypes = { type: PropTypes.string.isRequired, optionSetId: PropTypes.string, options: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.string })), + renderer: PropTypes.string, } export default FilterInput diff --git a/src/components/datatable/RowCells.jsx b/src/components/datatable/RowCells.jsx new file mode 100644 index 0000000000..541298e884 --- /dev/null +++ b/src/components/datatable/RowCells.jsx @@ -0,0 +1,145 @@ +import { DataTableCell } from '@dhis2/ui' +import cx from 'classnames' +import PropTypes from 'prop-types' +import React from 'react' +import { + RENDERER_COLOR, + RENDERER_ICON, + RENDERER_DATE, + TYPE_DATE, +} from '../../constants/dataTable.js' +import { isDarkColor } from '../../util/colors.js' +import { getRowId } from '../../util/dataTable.js' +import { formatDate, formatDatetime } from '../../util/helpers.js' +import { formatWithSeparator } from '../../util/numbers.js' +import { getPinnedCellProps } from '../../util/tableColumns.js' +import styles from './styles/DataTable.module.css' + +const RowCells = ({ + row, + visibleHeaders, + selectedIdSet, + hoveredFeature, + layerId, + isCheckboxColumnPinned, + pinnedLeftOffsets, + pinnedColumnCount, + columnWidths, + rendererByDataKey, + typeByDataKey, + keyAnalysisDigitGroupSeparator, + onToggleSelection, +}) => { + const rowId = getRowId(row) + const isSelected = !!rowId && selectedIdSet.has(rowId) + const isHovered = + !!rowId && + hoveredFeature?.id === rowId && + hoveredFeature?.layerId === layerId + + const cellsByDataKey = new Map(row.map((cell) => [cell.dataKey, cell])) + + return ( + <> + + rowId && onToggleSelection(rowId)} + onClick={(e) => e.stopPropagation()} + /> + + {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, + } + ) + const renderer = rendererByDataKey.get(dataKey) + const isColorCell = renderer === RENDERER_COLOR + const isIconCell = renderer === RENDERER_ICON + const isDateCell = renderer === RENDERER_DATE + const isDateOnlyCell = typeByDataKey.get(dataKey) === TYPE_DATE + return ( + + {isColorCell && value?.toLowerCase()} + {isIconCell && value && ( + { + e.target.style.visibility = 'hidden' + }} + /> + )} + {isDateCell && + value && + (isDateOnlyCell + ? formatDate(value) + : formatDatetime(value))} + {!isColorCell && + !isIconCell && + !isDateCell && + formatWithSeparator( + value, + keyAnalysisDigitGroupSeparator + )} + + ) + })} + + ) +} + +RowCells.propTypes = { + columnWidths: PropTypes.array.isRequired, + isCheckboxColumnPinned: PropTypes.bool.isRequired, + pinnedColumnCount: PropTypes.number.isRequired, + pinnedLeftOffsets: PropTypes.object.isRequired, + rendererByDataKey: PropTypes.instanceOf(Map).isRequired, + row: PropTypes.array.isRequired, + selectedIdSet: PropTypes.instanceOf(Set).isRequired, + typeByDataKey: PropTypes.instanceOf(Map).isRequired, + visibleHeaders: PropTypes.array.isRequired, + onToggleSelection: PropTypes.func.isRequired, + hoveredFeature: PropTypes.object, + keyAnalysisDigitGroupSeparator: PropTypes.string, + layerId: PropTypes.string, +} + +export default RowCells diff --git a/src/components/datatable/TableContextMenu.jsx b/src/components/datatable/TableContextMenu.jsx index dac6bef021..eb69c3e55d 100644 --- a/src/components/datatable/TableContextMenu.jsx +++ b/src/components/datatable/TableContextMenu.jsx @@ -18,6 +18,7 @@ import { EVENT_LAYER, FACILITY_LAYER, GEOJSON_URL_LAYER, + TRACKED_ENTITY_LAYER, } from '../../constants/layers.js' import { getGeojsonFeatureProfile } from '../../util/geojson.js' import { drillUpDown } from '../../util/map.js' @@ -29,6 +30,7 @@ const UNDRILLABLE_LAYERS = new Set([ FACILITY_LAYER, EVENT_LAYER, GEOJSON_URL_LAYER, + TRACKED_ENTITY_LAYER, ]) const TableContextMenu = ({ @@ -63,7 +65,8 @@ const TableContextMenu = ({ const canDrill = !UNDRILLABLE_LAYERS.has(layerType) - const canViewProfile = id && layerType !== EVENT_LAYER + const canViewProfile = + id && layerType !== EVENT_LAYER && layerType !== TRACKED_ENTITY_LAYER return ( <> diff --git a/src/components/datatable/TableVirtuosoComponents.jsx b/src/components/datatable/TableVirtuosoComponents.jsx index 46dd3b5964..0acdaa1e88 100644 --- a/src/components/datatable/TableVirtuosoComponents.jsx +++ b/src/components/datatable/TableVirtuosoComponents.jsx @@ -23,15 +23,19 @@ DataTableWithVirtuosoContext.propTypes = { }), } -const DataTableRowWithVirtuosoContext = ({ context, item, ...props }) => ( - context.onMouseEnter(item)} - onMouseLeave={context.onMouseLeave} - onContextMenu={(e) => context.onContextMenu(e, item)} - onClick={(e) => context.onRowClick(item, e)} - onDoubleClick={() => context.onRowDoubleClick(item)} - {...props} - /> +const DataTableRowWithVirtuosoContext = React.memo( + function DataTableRowWithVirtuosoContext({ context, item, ...props }) { + return ( + context.onMouseEnter(item)} + onMouseLeave={context.onMouseLeave} + onContextMenu={(e) => context.onContextMenu(e, item)} + onClick={(e) => context.onRowClick(item, e)} + onDoubleClick={() => context.onRowDoubleClick(item)} + {...props} + /> + ) + } ) DataTableRowWithVirtuosoContext.propTypes = { @@ -51,27 +55,50 @@ DataTableRowWithVirtuosoContext.propTypes = { ), } -const EmptyPlaceholder = ({ context }) => ( +const getEmptyPlaceholderContent = (context) => { + if (context.showServerClusterAction) { + return ( + <> + {i18n.t( + "Event details aren't available while this layer is clustered on the server" + )} + + + ) + } + + if (context.totalCount > 0) { + return ( + <> + {i18n.t('No features match your filters')} + {context.hasActiveFilters && ( + + )} + + ) + } + + return i18n.t('No results found') +} + +export const EmptyPlaceholder = ({ context }) => (
- {context.totalCount > 0 ? ( - <> - {i18n.t('No features match your filters')} - {context.hasActiveFilters && ( - - )} - - ) : ( - i18n.t('No results found') - )} + {getEmptyPlaceholderContent(context)}
@@ -81,8 +108,10 @@ const EmptyPlaceholder = ({ context }) => ( EmptyPlaceholder.propTypes = { context: PropTypes.shape({ hasActiveFilters: PropTypes.bool, + showServerClusterAction: PropTypes.bool, totalCount: PropTypes.number, onClearFilters: PropTypes.func, + onForceClientCluster: PropTypes.func, }), } diff --git a/src/components/datatable/__tests__/BottomPanel.spec.jsx b/src/components/datatable/__tests__/BottomPanel.spec.jsx new file mode 100644 index 0000000000..314397bfae --- /dev/null +++ b/src/components/datatable/__tests__/BottomPanel.spec.jsx @@ -0,0 +1,81 @@ +import { render, fireEvent } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import configureMockStore from 'redux-mock-store' +import WindowDimensionsProvider from '../../WindowDimensionsProvider.jsx' +import BottomPanel from '../BottomPanel.jsx' + +jest.mock('../DataTable.jsx', () => { + const DataTableMock = () =>
+ DataTableMock.displayName = 'DataTableMock' + return DataTableMock +}) + +const mockStore = configureMockStore() + +// jsdom doesn't implement pointer capture or ResizeObserver +beforeAll(() => { + Element.prototype.setPointerCapture = jest.fn() + Element.prototype.releasePointerCapture = jest.fn() + global.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } +}) + +const DATA_TABLE_HEIGHT = 300 + +const renderBottomPanel = () => { + const store = mockStore({ + ui: { + dataTableHeight: DATA_TABLE_HEIGHT, + showOnlyFeaturesInView: false, + selectionFilter: [], + highlightColor: null, + }, + dataTable: 'layer1', + map: { mapViews: [{ id: 'layer1', name: 'Layer 1' }] }, + }) + const { container } = render( + + + + + + ) + return { handle: container.querySelector('.resizeHandle') } +} + +const getDisplayHeight = () => + document.documentElement.style.getPropertyValue('--data-table-height') + +describe('BottomPanel resize cancel', () => { + test('cancelling a drag that never collapsed the panel reverts the transient height', () => { + const { handle } = renderBottomPanel() + expect(getDisplayHeight()).toBe(`${DATA_TABLE_HEIGHT}px`) + + fireEvent.pointerDown(handle, { pointerId: 1, clientY: 500 }) + fireEvent.pointerMove(handle, { pointerId: 1, clientY: 600 }) + expect(getDisplayHeight()).not.toBe(`${DATA_TABLE_HEIGHT}px`) + + fireEvent.pointerCancel(handle, { pointerId: 1, clientY: 600 }) + expect(getDisplayHeight()).toBe(`${DATA_TABLE_HEIGHT}px`) + }) + + test('cancelling a drag that collapsed the panel restores the pre-drag expanded height', () => { + const { handle } = renderBottomPanel() + expect(getDisplayHeight()).toBe(`${DATA_TABLE_HEIGHT}px`) + + fireEvent.pointerDown(handle, { pointerId: 1, clientY: 500 }) + // Drag far enough down to cross the collapse threshold (MIN_HEIGHT) + fireEvent.pointerMove(handle, { + pointerId: 1, + clientY: window.innerHeight, + }) + expect(getDisplayHeight()).not.toBe(`${DATA_TABLE_HEIGHT}px`) + + fireEvent.pointerCancel(handle, { pointerId: 1, clientY: 0 }) + expect(getDisplayHeight()).toBe(`${DATA_TABLE_HEIGHT}px`) + }) +}) diff --git a/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx index 94c25351a7..9df0ad1b3c 100644 --- a/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx +++ b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx @@ -355,3 +355,47 @@ describe('ColumnPicker search', () => { }) }) }) + +describe('ColumnPicker defaultHidden headers (e.g. period columns)', () => { + const headersWithHiddenColumn = [ + ...headers, + { + name: 'Value (Jan 2023)', + dataKey: 'period_202301_rawValue', + defaultHidden: true, + }, + ] + + test('appears in the main list, unchecked, when there is no saved config yet', () => { + renderColumnPicker({ allHeaders: headersWithHiddenColumn }) + openPicker() + expect(screen.getByLabelText('Value (Jan 2023)')).not.toBeChecked() + }) + + test('checking it dispatches visibleKeys with its dataKey added, alongside the other default-visible columns', () => { + const { store } = renderColumnPicker({ + allHeaders: headersWithHiddenColumn, + }) + openPicker() + fireEvent.click(screen.getByLabelText('Value (Jan 2023)')) + expect(store.getActions()).toContainEqual({ + type: DATA_TABLE_COLUMN_CONFIG_SET, + layerId: 'layer1', + config: { + visibleKeys: [ + 'name', + 'rawValue', + 'legend', + 'period_202301_rawValue', + ], + pinnedKeys: [], + orderedKeys: [ + 'name', + 'rawValue', + 'legend', + 'period_202301_rawValue', + ], + }, + }) + }) +}) diff --git a/src/components/datatable/__tests__/FilterInput.spec.jsx b/src/components/datatable/__tests__/FilterInput.spec.jsx index ea143ce676..6dbaf6f6c1 100644 --- a/src/components/datatable/__tests__/FilterInput.spec.jsx +++ b/src/components/datatable/__tests__/FilterInput.spec.jsx @@ -201,6 +201,25 @@ describe('FilterInput multi-select path (no optionSetId)', () => { ).toBeInTheDocument() }) + test('renders Icon column options as a thumbnail plus filename, not the raw URL', () => { + renderFilterInput({ + dataKey: 'iconUrl', + name: 'Icon', + renderer: 'rendericon', + options: [{ value: 'https://server/api/icons/mapMarker024.png' }], + }) + openPopover('Icon') + const checkbox = screen.getByLabelText('mapMarker024.png') + expect(checkbox).toBeInTheDocument() + expect( + screen.queryByLabelText('https://server/api/icons/mapMarker024.png') + ).not.toBeInTheDocument() + expect(checkbox.closest('label').querySelector('img')).toHaveAttribute( + 'src', + 'https://server/api/icons/mapMarker024.png' + ) + }) + test('formats numeric column options with the system digit group separator', () => { renderFilterInput({ dataKey: 'value', @@ -406,16 +425,25 @@ describe('FilterInput searchable popover — custom filter row', () => { expect(row).toHaveTextContent('medium') }) - test('is hidden when the typed text exactly matches an existing option', () => { - const options = [{ value: 'High' }, { value: 'Low' }] - renderFilterInput({ dataKey: 'legend', name: 'Legend', options }) + test('stays shown and keeps live-applying even when the typed text exactly matches an existing option', () => { + const { store } = renderFilterInput({ + dataKey: 'legend', + name: 'Legend', + options: [{ value: 'High' }, { value: 'Low' }], + }) openPopover('Legend') fireEvent.change(getInput('Legend'), { target: { value: 'High' }, }) expect( - screen.queryByTestId('data-table-column-filter-custom-Legend') - ).not.toBeInTheDocument() + screen.getByTestId('data-table-column-filter-custom-Legend') + ).toBeInTheDocument() + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'legend', + filter: 'High', + }) }) test('clearing the typed text clears an already-applied custom filter live', () => { diff --git a/src/components/datatable/__tests__/RowCells.spec.jsx b/src/components/datatable/__tests__/RowCells.spec.jsx new file mode 100644 index 0000000000..7206646534 --- /dev/null +++ b/src/components/datatable/__tests__/RowCells.spec.jsx @@ -0,0 +1,122 @@ +import { render, screen } from '@testing-library/react' +import React from 'react' +import { + RENDERER_COLOR, + RENDERER_ICON, + RENDERER_DATE, + TYPE_DATE, +} from '../../../constants/dataTable.js' +import RowCells from '../RowCells.jsx' + +const NAME_HEADER = { dataKey: 'name' } + +const defaultProps = { + visibleHeaders: [NAME_HEADER], + selectedIdSet: new Set(), + hoveredFeature: null, + layerId: 'layer1', + isCheckboxColumnPinned: false, + pinnedLeftOffsets: {}, + pinnedColumnCount: 0, + columnWidths: [], + rendererByDataKey: new Map(), + typeByDataKey: new Map(), + keyAnalysisDigitGroupSeparator: undefined, + onToggleSelection: jest.fn(), +} + +const renderRow = (row, overrides = {}) => + render( + + + + + + +
+ ) + +describe('RowCells', () => { + it('renders a plain formatted value cell by default', () => { + renderRow([ + { dataKey: 'id', value: 'row1' }, + { dataKey: 'name', value: 'Bo' }, + ]) + expect(screen.getByText('Bo')).toBeInTheDocument() + }) + + it('renders a color cell as a lowercased swatch value with a background color', () => { + renderRow( + [ + { dataKey: 'id', value: 'row1' }, + { dataKey: 'name', value: '#FF0000' }, + ], + { rendererByDataKey: new Map([['name', RENDERER_COLOR]]) } + ) + const cell = screen.getByText('#ff0000') + expect(cell).toBeInTheDocument() + expect(cell.closest('td')).toHaveStyle({ + backgroundColor: '#FF0000', + }) + }) + + it('renders an icon cell as an image', () => { + const { container } = renderRow( + [ + { dataKey: 'id', value: 'row1' }, + { dataKey: 'name', value: 'https://example.com/icon.png' }, + ], + { rendererByDataKey: new Map([['name', RENDERER_ICON]]) } + ) + expect(container.querySelector('img')).toHaveAttribute( + 'src', + 'https://example.com/icon.png' + ) + }) + + it('renders a date-only cell formatted as just the date', () => { + renderRow( + [ + { dataKey: 'id', value: 'row1' }, + { dataKey: 'name', value: '2024-03-15T10:30:00' }, + ], + { + rendererByDataKey: new Map([['name', RENDERER_DATE]]), + typeByDataKey: new Map([['name', TYPE_DATE]]), + } + ) + expect(screen.getByText('2024-03-15')).toBeInTheDocument() + }) + + it('renders a datetime cell formatted with the time', () => { + renderRow( + [ + { dataKey: 'id', value: 'row1' }, + { dataKey: 'name', value: '2024-03-15T10:30:00' }, + ], + { rendererByDataKey: new Map([['name', RENDERER_DATE]]) } + ) + expect(screen.getByText('2024-03-15 10:30')).toBeInTheDocument() + }) + + it('checks the checkbox when the row id is in selectedIdSet', () => { + renderRow([{ dataKey: 'id', value: 'row1' }], { + selectedIdSet: new Set(['row1']), + }) + expect(screen.getByRole('checkbox')).toBeChecked() + }) + + it('calls onToggleSelection with the row id when the checkbox is clicked', () => { + const onToggleSelection = jest.fn() + renderRow([{ dataKey: 'id', value: 'row1' }], { onToggleSelection }) + + screen.getByRole('checkbox').click() + + expect(onToggleSelection).toHaveBeenCalledWith('row1') + }) + + it('skips headers with no matching cell in the row', () => { + renderRow([{ dataKey: 'id', value: 'row1' }]) + expect(screen.queryByText('Bo')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/datatable/__tests__/TableContextMenu.spec.jsx b/src/components/datatable/__tests__/TableContextMenu.spec.jsx index 71f81c655e..0384092cdc 100644 --- a/src/components/datatable/__tests__/TableContextMenu.spec.jsx +++ b/src/components/datatable/__tests__/TableContextMenu.spec.jsx @@ -2,8 +2,14 @@ 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 { FACILITY_LAYER } from '../../../constants/layers.js' +import { + FEATURE_HIGHLIGHT, + ORGANISATION_UNIT_PROFILE_SET, +} from '../../../constants/actionTypes.js' +import { + FACILITY_LAYER, + TRACKED_ENTITY_LAYER, +} from '../../../constants/layers.js' import TableContextMenu from '../TableContextMenu.jsx' jest.mock('../../cachedDataProvider/CachedDataProvider.jsx', () => ({ @@ -37,6 +43,33 @@ const renderMenu = (props) => { return { ...result, store } } +describe('TableContextMenu — view profile menu item', () => { + test('is not offered for a Tracked Entity row (id is a TEI uid, not an org unit id)', () => { + renderMenu({ + layer: { id: 'layer1', layer: TRACKED_ENTITY_LAYER }, + contextMenu: { x: 10, y: 10, featureProps: { id: 'tei1' } }, + }) + expect( + screen.queryByTestId('data-table-context-menu-view-profile') + ).not.toBeInTheDocument() + }) + + test('dispatches setOrgUnitProfile with the row id for a layer type that supports it', () => { + const { store } = renderMenu({ + contextMenu: { x: 10, y: 10, featureProps: { id: 'ou1' } }, + }) + fireEvent.click( + screen + .getByTestId('data-table-context-menu-view-profile') + .querySelector('a') + ) + expect(store.getActions()).toContainEqual({ + type: ORGANISATION_UNIT_PROFILE_SET, + payload: 'ou1', + }) + }) +}) + describe('TableContextMenu — zoom to filtered features', () => { test('is disabled when no filter is active (filteredIds is null)', () => { renderMenu({ filteredIds: null }) diff --git a/src/components/datatable/__tests__/TableVirtuosoComponents.spec.jsx b/src/components/datatable/__tests__/TableVirtuosoComponents.spec.jsx new file mode 100644 index 0000000000..0134dc4510 --- /dev/null +++ b/src/components/datatable/__tests__/TableVirtuosoComponents.spec.jsx @@ -0,0 +1,61 @@ +import { render, fireEvent, screen } from '@testing-library/react' +import React from 'react' +import { EmptyPlaceholder } from '../TableVirtuosoComponents.jsx' + +const renderPlaceholder = (context) => + render( + + +
+ ) + +describe('EmptyPlaceholder', () => { + test('shows the server-cluster action when showServerClusterAction is true', () => { + const onForceClientCluster = jest.fn() + renderPlaceholder({ + showServerClusterAction: true, + onForceClientCluster, + }) + + expect( + screen.getByText( + "Event details aren't available while this layer is clustered on the server" + ) + ).toBeTruthy() + + fireEvent.click(screen.getByText('Show event details')) + expect(onForceClientCluster).toHaveBeenCalledTimes(1) + + expect(screen.queryByText('No features match your filters')).toBeNull() + expect(screen.queryByText('No results found')).toBeNull() + }) + + test('shows the clear-filters action when filters produced zero rows', () => { + const onClearFilters = jest.fn() + renderPlaceholder({ + showServerClusterAction: false, + totalCount: 10, + hasActiveFilters: true, + onClearFilters, + }) + + expect(screen.getByText('No features match your filters')).toBeTruthy() + + fireEvent.click(screen.getByText('Clear filters')) + expect(onClearFilters).toHaveBeenCalledTimes(1) + + expect(screen.queryByText('Show event details')).toBeNull() + }) + + test('shows plain "No results found" when there is no data at all', () => { + renderPlaceholder({ + showServerClusterAction: false, + totalCount: 0, + hasActiveFilters: false, + }) + + expect(screen.getByText('No results found')).toBeTruthy() + expect(screen.queryByText('Show event details')).toBeNull() + expect(screen.queryByText('No features match your filters')).toBeNull() + }) +}) diff --git a/src/components/datatable/__tests__/useColumnWidths.spec.jsx b/src/components/datatable/__tests__/useColumnWidths.spec.jsx new file mode 100644 index 0000000000..66978648c6 --- /dev/null +++ b/src/components/datatable/__tests__/useColumnWidths.spec.jsx @@ -0,0 +1,113 @@ +import { render, act } from '@testing-library/react' +import PropTypes from 'prop-types' +import React from 'react' +import { useColumnWidths } from '../useColumnWidths.js' + +let rafCallbacks + +beforeEach(() => { + rafCallbacks = [] + jest.spyOn(global, 'requestAnimationFrame').mockImplementation((cb) => { + rafCallbacks.push(cb) + return rafCallbacks.length + }) + jest.spyOn( + HTMLElement.prototype, + 'getBoundingClientRect' + ).mockImplementation(function () { + return { width: Number(this.dataset.width) || 0 } + }) +}) + +afterEach(() => { + global.requestAnimationFrame.mockRestore() + HTMLElement.prototype.getBoundingClientRect.mockRestore() +}) + +const flushRaf = () => { + while (rafCallbacks.length) { + rafCallbacks.shift()() + } +} + +// A stable reference: the hook re-measures whenever `headers` changes +// identity, so passing a literal from the test body would spuriously +// reset the measurement on every rerender +const HEADERS = [{ dataKey: 'a' }, { dataKey: 'b' }] + +const Harness = ({ availableWidth, error, widths, onColumnWidths }) => { + const { headerRowRef, columnWidths } = useColumnWidths({ + availableWidth, + headers: HEADERS, + error, + }) + onColumnWidths(columnWidths) + return ( + + + + + +
{/* checkbox column, skipped */} + {widths.map((w, i) => ( + + ))} +
+ ) +} + +Harness.propTypes = { + widths: PropTypes.arrayOf(PropTypes.number).isRequired, + onColumnWidths: PropTypes.func.isRequired, + availableWidth: PropTypes.number, + error: PropTypes.bool, +} + +describe('useColumnWidths - MIN_COLUMN_WIDTH floor', () => { + it('floors a narrower-than-minimum measured column up to the minimum, leaving wider columns untouched', () => { + let latestWidths + render( + { + latestWidths = w + }} + /> + ) + act(() => { + flushRaf() + }) + expect(latestWidths).toEqual([100, 150]) + }) + + it('the floored width stays the resize-clamp floor on a later shrink, instead of scaling down further', () => { + let latestWidths + const { rerender } = render( + { + latestWidths = w + }} + /> + ) + act(() => { + flushRaf() + }) + expect(latestWidths).toEqual([100, 150]) + + act(() => { + rerender( + { + latestWidths = w + }} + /> + ) + }) + expect(latestWidths).toEqual([100, 150]) + }) +}) diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index ae6f606a20..0f9c59ea97 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -64,6 +64,106 @@ describe('useTableData headers', () => { expect(isLoading).toBe(false) }) + test('adds an Icon column for a facility layer styled by group set symbol', () => { + const store = { aggregations: {} } + const layer = { + layer: 'facility', + dataFilters: null, + data: [ + { + properties: { + id: 'facility-1', + name: 'Facility 1', + type: 'Point', + iconUrl: 'https://server/images/orgunitgroup/1.png', + group: 'Hospitals', + }, + }, + ], + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + + const { headers, rows } = result.current + expect(headers).toContainEqual({ + name: 'Icon', + dataKey: 'iconUrl', + type: 'string', + renderer: 'rendericon', + }) + expect(headers).toContainEqual({ + name: 'Group', + dataKey: 'group', + type: 'string', + }) + expect(headers).not.toContainEqual( + expect.objectContaining({ dataKey: 'color' }) + ) + expect(rows[0]).toContainEqual( + expect.objectContaining({ + value: 'https://server/images/orgunitgroup/1.png', + dataKey: 'iconUrl', + }) + ) + }) + + test('adds a Color column for an orgUnit layer styled by group set color', () => { + const store = { aggregations: {} } + const layer = { + layer: 'orgUnit', + dataFilters: null, + data: [ + { + properties: { + id: 'ou-1', + name: 'Bo District', + type: 'MultiPolygon', + level: 2, + color: '#ff0000', + group: 'Rural', + }, + }, + ], + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + + const { headers } = result.current + expect(headers).toContainEqual( + expect.objectContaining({ name: 'Color', dataKey: 'color' }) + ) + expect(headers).toContainEqual( + expect.objectContaining({ name: 'Group', dataKey: 'group' }) + ) + expect(headers).not.toContainEqual( + expect.objectContaining({ dataKey: 'iconUrl' }) + ) + }) + test('gets headers and rows for orgUnit layer', () => { const store = { aggregations: {}, @@ -161,11 +261,11 @@ describe('useTableData headers', () => { { name: 'Name', dataKey: 'name', type: 'string' }, { name: 'Id', dataKey: 'id', type: 'string' }, { name: 'Value', dataKey: 'rawValue', type: 'number' }, - { name: 'Legend', dataKey: 'legend', type: 'string' }, - { name: 'Range', dataKey: 'range', type: 'string' }, { name: 'Level', dataKey: 'level', type: 'number' }, { name: 'Parent', dataKey: 'parentName', type: 'string' }, { name: 'Type', dataKey: 'type', type: 'string' }, + { name: 'Legend', dataKey: 'legend', type: 'string' }, + { name: 'Range', dataKey: 'range', type: 'string' }, { name: 'Color', dataKey: 'color', @@ -179,16 +279,206 @@ describe('useTableData headers', () => { { value: 'Ngelehun CHC', dataKey: 'name' }, { value: 'thematicId-1', dataKey: 'id' }, { value: 106.3, dataKey: 'rawValue' }, - { value: 'Great', dataKey: 'legend' }, - { value: '90 – 120', dataKey: 'range' }, { value: 4, dataKey: 'level' }, { value: 'Badjia', dataKey: 'parentName' }, { value: 'Point', dataKey: 'type' }, + { value: 'Great', dataKey: 'legend' }, + { value: '90 – 120', dataKey: 'range' }, { value: '#FFFFB2', dataKey: 'color' }, ]) expect(isLoading).toBe(false) }) + test('gets current-period Value/Legend/Range/Color for a timeline thematic layer', () => { + // The active timeline period is Map.jsx's own local UI state, synced + // into state.ui.activeTimelinePeriod (not part of the layer config). + const store = { + aggregations: {}, + ui: { + activeTimelinePeriod: { id: '202302', name: 'February 2023' }, + }, + } + const layer = { + layer: 'thematic', + renderingStrategy: 'TIMELINE', + valuesByPeriod: { + 202301: { + 'ou-1': { value: 100, color: '#aaaaaa', legend: 'Low' }, + }, + 202302: { + 'ou-1': { + value: 200, + color: '#bbbbbb', + legend: 'High', + range: '150 – 250', + }, + }, + }, + dataFilters: null, + data: [ + { + properties: { + id: 'ou-1', + name: 'Ngelehun CHC', + type: 'Point', + }, + }, + ], + } + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + const { headers, rows } = result.current + expect(headers).toMatchObject([ + { name: 'Name', dataKey: 'name' }, + { name: 'Id', dataKey: 'id' }, + { name: 'Value (February 2023)', dataKey: 'rawValue' }, + { name: 'Level', dataKey: 'level' }, + { name: 'Parent', dataKey: 'parentName' }, + { name: 'Type', dataKey: 'type' }, + { name: 'Legend (February 2023)', dataKey: 'legend' }, + { name: 'Range (February 2023)', dataKey: 'range' }, + { name: 'Color (February 2023)', dataKey: 'color' }, + ]) + expect(rows[0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ value: 200, dataKey: 'rawValue' }), + expect.objectContaining({ value: 'High', dataKey: 'legend' }), + expect.objectContaining({ + value: '150 – 250', + dataKey: 'range', + }), + ]) + ) + }) + + test('adds a defaultHidden raw-value-only column for every other period, for a timeline thematic layer', () => { + const store = { + aggregations: {}, + ui: { + activeTimelinePeriod: { id: '202302', name: 'February 2023' }, + }, + } + const layer = { + layer: 'thematic', + renderingStrategy: 'TIMELINE', + periods: [ + { id: '202301', name: 'January 2023' }, + { id: '202302', name: 'February 2023' }, + ], + valuesByPeriod: { + 202301: { 'ou-1': { value: 100 } }, + 202302: { 'ou-1': { value: 200 } }, + }, + dataFilters: null, + data: [ + { + properties: { + id: 'ou-1', + name: 'Ngelehun CHC', + type: 'Point', + }, + }, + ], + } + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + const { headers, rows } = result.current + expect(headers).not.toContainEqual( + expect.objectContaining({ dataKey: 'period_202302_rawValue' }) + ) + expect(headers).toContainEqual({ + name: 'Value (January 2023)', + dataKey: 'period_202301_rawValue', + type: 'number', + defaultHidden: true, + }) + expect(rows[0]).toContainEqual( + expect.objectContaining({ + value: 100, + dataKey: 'period_202301_rawValue', + }) + ) + }) + + test('split-by-period thematic layer has no default current-period column, only defaultHidden period columns', () => { + const store = { aggregations: {} } + const layer = { + layer: 'thematic', + renderingStrategy: 'SPLIT_BY_PERIOD', + periods: [{ id: '202301', name: 'January 2023' }], + valuesByPeriod: { + 202301: { 'ou-1': { value: 100 } }, + }, + dataFilters: null, + data: [ + { + properties: { + id: 'ou-1', + name: 'Ngelehun CHC', + type: 'Point', + }, + }, + ], + } + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + const { headers, rows } = result.current + expect(headers).toMatchObject([ + { name: 'Name', dataKey: 'name' }, + { name: 'Id', dataKey: 'id' }, + { name: 'Level', dataKey: 'level' }, + { name: 'Parent', dataKey: 'parentName' }, + { name: 'Type', dataKey: 'type' }, + { + name: 'Value (January 2023)', + dataKey: 'period_202301_rawValue', + defaultHidden: true, + }, + ]) + expect(rows[0]).not.toContainEqual( + expect.objectContaining({ dataKey: 'rawValue' }) + ) + expect(rows[0]).toContainEqual( + expect.objectContaining({ + value: 100, + dataKey: 'period_202301_rawValue', + }) + ) + }) + test('gets headers and rows for event layer', () => { const store = { aggregations: {}, @@ -255,49 +545,349 @@ describe('useTableData headers', () => { ), } ) - const { headers, rows, isLoading } = result.current - expect(headers).toHaveLength(7) - expect(headers).toMatchObject([ - { name: 'Org unit', dataKey: 'ouname', type: 'string' }, - { name: 'Id', dataKey: 'id', type: 'string' }, - { - name: 'Event time', - dataKey: 'eventdate', - type: 'date', - renderer: 'formatTime...', - }, - { name: 'Last updated on', dataKey: 'lastupdated', type: 'string' }, - { name: 'Event status', dataKey: 'eventstatus', type: 'string' }, - { name: 'Gender', dataKey: 'oZg33kd9taw', type: 'string' }, - { name: 'Type', dataKey: 'type', type: 'string' }, - ]) - expect(rows).toHaveLength(1) - expect(rows[0]).toHaveLength(7) - expect(rows[0]).toMatchObject([ - { value: 'Lumley Hospital', dataKey: 'ouname' }, - { value: 'a9712323629', dataKey: 'id' }, - { value: '2023-05-15 00:00:00.0', dataKey: 'eventdate' }, - { value: '2018-04-12 20:58:51.31', dataKey: 'lastupdated' }, - { value: 'ACTIVE', dataKey: 'eventstatus' }, - { value: 'Female', dataKey: 'oZg33kd9taw' }, - { value: 'Point', dataKey: 'type' }, - ]) - expect(isLoading).toBe(false) + const { headers, rows, isLoading } = result.current + expect(headers).toHaveLength(7) + expect(headers).toMatchObject([ + { name: 'Org unit', dataKey: 'ouname', type: 'string' }, + { name: 'Id', dataKey: 'id', type: 'string' }, + { + name: 'Event date', + dataKey: 'eventdate', + type: 'date', + renderer: 'renderdate', + }, + { + name: 'Last updated on', + dataKey: 'lastupdated', + type: 'date', + renderer: 'renderdate', + }, + { name: 'Event status', dataKey: 'eventstatus', type: 'string' }, + { name: 'Gender', dataKey: 'oZg33kd9taw', type: 'string' }, + { name: 'Type', dataKey: 'type', type: 'string' }, + ]) + expect(rows).toHaveLength(1) + expect(rows[0]).toHaveLength(7) + expect(rows[0]).toMatchObject([ + { value: 'Lumley Hospital', dataKey: 'ouname' }, + { value: 'a9712323629', dataKey: 'id' }, + { value: '2023-05-15 00:00:00.0', dataKey: 'eventdate' }, + { value: '2018-04-12 20:58:51.31', dataKey: 'lastupdated' }, + { value: 'ACTIVE', dataKey: 'eventstatus' }, + { value: 'Female', dataKey: 'oZg33kd9taw' }, + { value: 'Point', dataKey: 'type' }, + ]) + expect(isLoading).toBe(false) + }) + + test('is not "extending" a server-clustered event layer that has not been forced to client-cluster', () => { + const store = { aggregations: {} } + const layer = { + layer: 'event', + dataFilters: null, + serverCluster: true, + isExtended: false, + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + + expect(result.current.isLoading).toBe(false) + expect(result.current.loadingReason).toBeNull() + }) + + test('shows "Loading additional events…" while forceClientCluster reload is in flight', () => { + const store = { aggregations: {} } + const layer = { + layer: 'event', + dataFilters: null, + serverCluster: true, + forceClientCluster: true, + isExtended: false, + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + + expect(result.current.isLoading).toBe(true) + expect(result.current.loadingReason).toBe('Loading additional events…') + }) + + test('gets headers and rows for tracked entity layer', () => { + const store = { + aggregations: {}, + } + const layer = { + layer: 'trackedEntity', + dataFilters: null, + headers: [ + { + name: 'First name', + dataKey: 'w75KJ2mc4zz', + valueType: 'TEXT', + }, + { + name: 'Age', + dataKey: 'zDhUuAYrxNC', + valueType: 'NUMBER', + }, + ], + data: [ + { + properties: { + id: 'PsgJS8BUxZd', + w75KJ2mc4zz: 'Gabrielle', + zDhUuAYrxNC: 28, + color: '#e57200', + }, + }, + ], + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + + const { headers, rows, isLoading } = result.current + expect(headers).toHaveLength(4) + expect(headers).toMatchObject([ + { name: 'Id', dataKey: 'id', type: 'string' }, + { name: 'First name', dataKey: 'w75KJ2mc4zz', type: 'string' }, + { name: 'Age', dataKey: 'zDhUuAYrxNC', type: 'number' }, + { name: 'Color', dataKey: 'color', type: 'string' }, + ]) + expect(rows).toHaveLength(1) + expect(rows[0]).toHaveLength(4) + expect(rows[0]).toMatchObject([ + { value: 'PsgJS8BUxZd', dataKey: 'id' }, + { value: 'Gabrielle', dataKey: 'w75KJ2mc4zz' }, + { value: 28, dataKey: 'zDhUuAYrxNC' }, + { value: '#e57200', dataKey: 'color' }, + ]) + expect(isLoading).toBe(false) + }) + + test('treats NUMBER header with optionSet as string type', () => { + const store = { aggregations: {} } + const layer = { + layer: 'event', + dataFilters: null, + isExtended: true, + headers: [ + { + name: 'AbCdEfGhIjK', + column: 'Severity', + valueType: 'NUMBER', + optionSet: { id: 'xyz123' }, + }, + ], + data: [ + { + properties: { + id: 'evt1', + type: 'Point', + ouname: 'Test OU', + eventdate: '2023-01-01', + AbCdEfGhIjK: 'high', + }, + }, + ], + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + + const { headers } = result.current + const severityHeader = headers.find((h) => h.dataKey === 'AbCdEfGhIjK') + expect(severityHeader.type).toBe('string') + }) + + test('treats NUMBER header without optionSet as number type', () => { + const store = { aggregations: {} } + const layer = { + layer: 'event', + dataFilters: null, + isExtended: true, + headers: [ + { + name: 'AbCdEfGhIjK', + column: 'Score', + valueType: 'NUMBER', + }, + ], + data: [ + { + properties: { + id: 'evt2', + type: 'Point', + ouname: 'Test OU', + eventdate: '2023-01-01', + AbCdEfGhIjK: 42, + }, + }, + ], + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + + const { headers } = result.current + const scoreHeader = headers.find((h) => h.dataKey === 'AbCdEfGhIjK') + expect(scoreHeader.type).toBe('number') + }) + + test('adds Legend/Range/Color columns for an event layer styled by a numeric data item', () => { + const store = { aggregations: {} } + const layer = { + layer: 'event', + dataFilters: null, + isExtended: true, + styleDataItem: { id: 'AbCdEfGhIjK' }, + legend: { + items: [ + { + name: 'Low', + color: '#aaaaaa', + startValue: 0, + endValue: 50, + colorGroup: 0, + }, + { + name: 'High', + color: '#bbbbbb', + startValue: 50, + endValue: 100, + colorGroup: 1, + }, + ], + }, + headers: [ + { name: 'AbCdEfGhIjK', column: 'Score', valueType: 'NUMBER' }, + ], + data: [ + { + properties: { + id: 'evt1', + type: 'Point', + ouname: 'Test OU', + eventdate: '2023-01-01', + AbCdEfGhIjK: 75, + value: 75, + color: '#bbbbbb', + colorGroup: 1, + }, + }, + ], + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + + const { headers, rows } = result.current + expect(headers).toContainEqual({ + name: 'Legend', + dataKey: 'legend', + type: 'string', + }) + expect(headers).toContainEqual({ + name: 'Range', + dataKey: 'range', + type: 'string', + }) + expect(rows[0]).toContainEqual( + expect.objectContaining({ value: 'High', dataKey: 'legend' }) + ) + expect(rows[0]).toContainEqual( + expect.objectContaining({ value: '50 – 100', dataKey: 'range' }) + ) }) - test('treats NUMBER header with optionSet as string type', () => { + test('formats an event layer’s Range using the layer’s own legendDecimalPlaces', () => { const store = { aggregations: {} } const layer = { layer: 'event', dataFilters: null, isExtended: true, + styleDataItem: { id: 'AbCdEfGhIjK' }, + legendDecimalPlaces: 1, + legend: { + items: [ + { + name: 'High', + color: '#bbbbbb', + startValue: 50.256, + endValue: 100.789, + colorGroup: 0, + }, + ], + }, headers: [ - { - name: 'AbCdEfGhIjK', - column: 'Severity', - valueType: 'NUMBER', - optionSet: { id: 'xyz123' }, - }, + { name: 'AbCdEfGhIjK', column: 'Score', valueType: 'NUMBER' }, ], data: [ { @@ -306,7 +896,10 @@ describe('useTableData headers', () => { type: 'Point', ouname: 'Test OU', eventdate: '2023-01-01', - AbCdEfGhIjK: 'high', + AbCdEfGhIjK: 75, + value: 75, + color: '#bbbbbb', + colorGroup: 0, }, }, ], @@ -326,32 +919,35 @@ describe('useTableData headers', () => { } ) - const { headers } = result.current - const severityHeader = headers.find((h) => h.dataKey === 'AbCdEfGhIjK') - expect(severityHeader.type).toBe('string') + expect(result.current.rows[0]).toContainEqual( + expect.objectContaining({ value: '50.3 – 100.8', dataKey: 'range' }) + ) }) - test('treats NUMBER header without optionSet as number type', () => { + test('leaves Range empty for an event layer styled by a non-numeric (option set) data item', () => { const store = { aggregations: {} } const layer = { layer: 'event', dataFilters: null, isExtended: true, + styleDataItem: { id: 'AbCdEfGhIjK', optionSet: { id: 'os1' } }, + legend: { + items: [{ name: 'Yes', color: '#00ff00', colorGroup: 0 }], + }, headers: [ - { - name: 'AbCdEfGhIjK', - column: 'Score', - valueType: 'NUMBER', - }, + { name: 'AbCdEfGhIjK', column: 'Answer', valueType: 'TEXT' }, ], data: [ { properties: { - id: 'evt2', + id: 'evt1', type: 'Point', ouname: 'Test OU', eventdate: '2023-01-01', - AbCdEfGhIjK: 42, + AbCdEfGhIjK: 'Yes', + value: 'Yes', + color: '#00ff00', + colorGroup: 0, }, }, ], @@ -371,9 +967,13 @@ describe('useTableData headers', () => { } ) - const { headers } = result.current - const scoreHeader = headers.find((h) => h.dataKey === 'AbCdEfGhIjK') - expect(scoreHeader.type).toBe('number') + const { rows } = result.current + expect(rows[0]).toContainEqual( + expect.objectContaining({ value: 'Yes', dataKey: 'legend' }) + ) + expect(rows[0]).toContainEqual( + expect.objectContaining({ value: undefined, dataKey: 'range' }) + ) }) test('gets headers and rows for EE population layer', () => { @@ -620,6 +1220,49 @@ describe('useTableData headers', () => { ]) expect(isLoading).toBe(false) }) + + test('gets headers and rows for a geoJsonUrl layer, labeling the synthetic color property "Color"', () => { + const store = { aggregations: {} } + const layer = { + layer: 'geoJsonUrl', + dataFilters: null, + data: [ + { + geometry: { type: 'Point' }, + properties: { + id: 'feature-1', + name: 'Feature 1', + color: '#ff0000', + }, + }, + ], + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + + const { headers, rows } = result.current + expect(headers).toContainEqual({ + name: 'Color', + dataKey: 'color', + type: 'string', + renderer: 'rendercolor', + }) + expect(rows[0]).toContainEqual( + expect.objectContaining({ value: '#ff0000', dataKey: 'color' }) + ) + }) }) describe('useTableData sorting', () => { @@ -1412,3 +2055,166 @@ describe('useTableData globalSearch', () => { expect(current.rows).toHaveLength(0) }) }) + +describe('useTableData showOnlyFeaturesInView', () => { + const store = { aggregations: {} } + const bounds = [-10, -10, 10, 10] + + const layer = { + id: 'test-layer', + layer: 'orgUnit', + dataFilters: null, + data: [ + { + id: 'inview', + properties: { id: 'inview', name: 'In view' }, + geometry: { type: 'Point', coordinates: [0, 0] }, + }, + { + id: 'outofview', + properties: { id: 'outofview', name: 'Out of view' }, + geometry: { type: 'Point', coordinates: [50, 50] }, + }, + ], + } + + const renderTableData = (props) => + renderHook(() => useTableData(props), { + wrapper: ({ children }) => ( + {children} + ), + }).result + + test('includes all rows when the toggle is off', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + showOnlyFeaturesInView: false, + mapBounds: bounds, + }) + expect(current.rows).toHaveLength(2) + }) + + test('excludes features outside the current map bounds when the toggle is on', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + showOnlyFeaturesInView: true, + mapBounds: bounds, + }) + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( + 'In view' + ) + }) + + test('excludes features without geometry when the toggle is on', () => { + const layerWithoutCoords = { + ...layer, + data: [layer.data[0]], + dataWithoutCoords: [ + { + id: 'nogeom', + properties: { id: 'nogeom', name: 'No geometry' }, + geometry: null, + }, + ], + } + + const { current } = renderTableData({ + layer: layerWithoutCoords, + sortField: 'name', + sortDirection: 'asc', + showOnlyFeaturesInView: true, + mapBounds: bounds, + }) + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( + 'In view' + ) + }) +}) + +describe('useTableData selectionFilter', () => { + const store = { aggregations: {} } + + const layer = { + id: 'test-layer', + layer: 'orgUnit', + dataFilters: null, + data: [ + { id: 'a', properties: { id: 'a', name: 'Item A' } }, + { id: 'b', properties: { id: 'b', name: 'Item B' } }, + ], + } + + const renderTableData = (props) => + renderHook(() => useTableData(props), { + wrapper: ({ children }) => ( + {children} + ), + }).result + + test('includes all rows when no filter is applied', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + selectionFilter: [], + selectedIdSet: new Set(['a']), + }) + expect(current.rows).toHaveLength(2) + }) + + test('includes only selected rows when filtered to "selected"', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + selectionFilter: ['selected'], + selectedIdSet: new Set(['a']), + }) + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( + 'Item A' + ) + }) + + test('includes only non-selected rows when filtered to "not-selected"', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + selectionFilter: ['not-selected'], + selectedIdSet: new Set(['a']), + }) + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( + 'Item B' + ) + }) + + test('includes all rows when both options are checked', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + selectionFilter: ['selected', 'not-selected'], + selectedIdSet: new Set(['a']), + }) + expect(current.rows).toHaveLength(2) + }) + + test('shows no rows when filtered to "selected" and nothing is selected', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + selectionFilter: ['selected'], + selectedIdSet: new Set(), + }) + expect(current.rows).toHaveLength(0) + }) +}) diff --git a/src/components/datatable/controls/ColumnPickerControl.jsx b/src/components/datatable/controls/ColumnPickerControl.jsx index 6f9e52ad61..fe7ca0a70f 100644 --- a/src/components/datatable/controls/ColumnPickerControl.jsx +++ b/src/components/datatable/controls/ColumnPickerControl.jsx @@ -16,17 +16,25 @@ import { sortableKeyboardCoordinates, verticalListSortingStrategy, } from '@dnd-kit/sortable' -import { arrayMoveImmutable } from 'array-move' import cx from 'classnames' import PropTypes from 'prop-types' -import React, { useCallback, useLayoutEffect, useRef, useState } from 'react' +import React, { + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' import { createPortal } from 'react-dom' import { useDispatch } from 'react-redux' import { setDataTableColumnConfig } from '../../../actions/dataTable.js' import { + filterHeadersByName, + getDefaultVisibleKeys, + getOrderedHeaders, getPinnedCount, - getVisibleHeaders, isPinnedGroupEnd, + reorderHeaderKeys, reverseVisibleKeys, togglePinnedKey, toggleVisibleKey, @@ -37,8 +45,14 @@ import styles from './styles/ColumnPickerControl.module.css' import ToolbarIconButton from './ToolbarIconButton.jsx' const DRAG_OVERLAY_Z_INDEX = 2100 +const EMPTY_HEADERS = [] +const EMPTY_KEYS = [] -const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { +const ColumnPickerControl = React.memo(function ColumnPickerControl({ + layerId, + allHeaders, + columnConfig, +}) { const dispatch = useDispatch() const anchorRef = useRef(null) const [isOpen, setIsOpen] = useState(false) @@ -65,20 +79,26 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { } }, []) - const headers = allHeaders ?? [] + const headers = allHeaders ?? EMPTY_HEADERS const visibleKeys = - columnConfig?.visibleKeys ?? headers.map((h) => h.dataKey) - const pinnedKeys = columnConfig?.pinnedKeys ?? [] + columnConfig?.visibleKeys ?? getDefaultVisibleKeys(headers) + const pinnedKeys = columnConfig?.pinnedKeys ?? EMPTY_KEYS const orderedKeys = columnConfig?.orderedKeys ?? headers.map((h) => h.dataKey) - const orderedHeaders = getVisibleHeaders(headers, { - orderedKeys, - pinnedKeys, - }) + const orderedHeaders = useMemo( + () => + isOpen + ? getOrderedHeaders(headers, { orderedKeys, pinnedKeys }) + : EMPTY_HEADERS, + [isOpen, headers, orderedKeys, pinnedKeys] + ) - const pinnedCount = getPinnedCount(orderedHeaders, pinnedKeys) + const pinnedCount = useMemo( + () => (isOpen ? getPinnedCount(orderedHeaders, pinnedKeys) : 0), + [isOpen, orderedHeaders, pinnedKeys] + ) const updateConfig = (partial) => dispatch( @@ -112,8 +132,12 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { const onResetToDefaults = () => dispatch(setDataTableColumnConfig(layerId, undefined)) - const filteredHeaders = orderedHeaders.filter((h) => - h.name.toLowerCase().includes(search.trim().toLowerCase()) + const filteredHeaders = useMemo( + () => + isOpen + ? filterHeadersByName(orderedHeaders, search) + : EMPTY_HEADERS, + [isOpen, orderedHeaders, search] ) const sensors = useSensors( @@ -132,19 +156,12 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { setActiveId(null) if (over && active.id !== over.id) { - const oldIndex = orderedHeaders.findIndex( - (h) => h.dataKey === active.id - ) - const newIndex = orderedHeaders.findIndex( - (h) => h.dataKey === over.id + const nextOrder = reorderHeaderKeys( + orderedHeaders, + active.id, + over.id ) - - if (oldIndex !== -1 && newIndex !== -1) { - const nextOrder = arrayMoveImmutable( - orderedHeaders, - oldIndex, - newIndex - ).map((h) => h.dataKey) + if (nextOrder) { updateConfig({ orderedKeys: nextOrder }) } } @@ -292,7 +309,7 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { )} ) -} +}) ColumnPickerControl.propTypes = { layerId: PropTypes.string.isRequired, diff --git a/src/components/datatable/controls/ResizeHandleControl.jsx b/src/components/datatable/controls/ResizeHandleControl.jsx index 38a16d15d2..7a4bd7286b 100644 --- a/src/components/datatable/controls/ResizeHandleControl.jsx +++ b/src/components/datatable/controls/ResizeHandleControl.jsx @@ -11,6 +11,7 @@ const ResizeHandleControl = ({ onResize, onResizeStart, onResizeEnd, + onResizeCancel, minHeight = 50, maxHeight = 500, }) => { @@ -47,10 +48,7 @@ const ResizeHandleControl = ({ onResize?.(getHeight(evt.clientY)) } - const onPointerUp = (evt) => { - if (!isDraggingRef.current) { - return - } + const endDrag = (evt) => { isDraggingRef.current = false evt.currentTarget.releasePointerCapture(evt.pointerId) if (!hasMovedRef.current) { @@ -58,7 +56,25 @@ const ResizeHandleControl = ({ } evt.currentTarget.style.removeProperty('cursor') document.body.style.removeProperty('cursor') - onResizeEnd?.(getHeight(evt.clientY)) + } + + const onPointerUp = (evt) => { + if (!isDraggingRef.current) { + return + } + const shouldCommit = hasMovedRef.current + endDrag(evt) + if (shouldCommit) { + onResizeEnd?.(getHeight(evt.clientY)) + } + } + + const onPointerCancel = (evt) => { + if (!isDraggingRef.current) { + return + } + endDrag(evt) + onResizeCancel?.() } // In case the handle/panel unmounts mid-drag @@ -77,7 +93,7 @@ const ResizeHandleControl = ({ onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} - onPointerCancel={onPointerUp} + onPointerCancel={onPointerCancel} > @@ -90,6 +106,7 @@ ResizeHandleControl.propTypes = { maxHeight: PropTypes.number.isRequired, minHeight: PropTypes.number, onResize: PropTypes.func, + onResizeCancel: PropTypes.func, onResizeEnd: PropTypes.func, onResizeStart: PropTypes.func, } diff --git a/src/components/datatable/controls/__tests__/ResizeHandleControl.spec.jsx b/src/components/datatable/controls/__tests__/ResizeHandleControl.spec.jsx index 3101e25d59..86720223ba 100644 --- a/src/components/datatable/controls/__tests__/ResizeHandleControl.spec.jsx +++ b/src/components/datatable/controls/__tests__/ResizeHandleControl.spec.jsx @@ -20,12 +20,14 @@ const renderHandle = (props = {}) => { const onResize = jest.fn() const onResizeStart = jest.fn() const onResizeEnd = jest.fn() + const onResizeCancel = jest.fn() const { container } = render( ) @@ -34,6 +36,7 @@ const renderHandle = (props = {}) => { onResize, onResizeStart, onResizeEnd, + onResizeCancel, } } @@ -62,7 +65,8 @@ describe('ResizeHandleControl', () => { }) it('resizes once the pointer moves past the drag threshold', () => { - const { handle, onResize, onResizeStart, onResizeEnd } = renderHandle() + const { handle, onResize, onResizeStart, onResizeEnd, onResizeCancel } = + renderHandle() firePointerEvent('pointerDown', handle, { clientY: 300 }) firePointerEvent('pointerMove', handle, { clientY: 280 }) @@ -71,5 +75,38 @@ describe('ResizeHandleControl', () => { expect(onResizeStart).toHaveBeenCalledTimes(1) expect(onResize).toHaveBeenCalled() expect(onResizeEnd).toHaveBeenCalledTimes(1) + expect(onResizeCancel).not.toHaveBeenCalled() + }) + + it('resets the drag state on cancel without committing a resize', () => { + const { handle, onResizeEnd, onResizeCancel } = renderHandle() + + firePointerEvent('pointerDown', handle, { clientY: 500 }) + firePointerEvent('pointerMove', handle, { clientY: 400 }) + firePointerEvent('pointerCancel', handle, { clientY: 0 }) + + expect(onResizeCancel).toHaveBeenCalledTimes(1) + expect(onResizeEnd).not.toHaveBeenCalled() + }) + + it('ignores a stray pointercancel with no active drag', () => { + const { handle, onResizeCancel, onResizeEnd } = renderHandle() + + firePointerEvent('pointerCancel', handle, { clientY: 0 }) + + expect(onResizeCancel).not.toHaveBeenCalled() + expect(onResizeEnd).not.toHaveBeenCalled() + }) + + it('ignores a pointer up/cancel that arrives after the drag already ended', () => { + const { handle, onResizeEnd, onResizeCancel } = renderHandle() + + firePointerEvent('pointerDown', handle, { clientY: 500 }) + firePointerEvent('pointerMove', handle, { clientY: 400 }) + firePointerEvent('pointerUp', handle, { clientY: 400 }) + firePointerEvent('pointerCancel', handle, { clientY: 0 }) + + expect(onResizeEnd).toHaveBeenCalledTimes(1) + expect(onResizeCancel).not.toHaveBeenCalled() }) }) diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index 9ab8b8aaf2..5f23ec511b 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -17,6 +17,7 @@ .tableContainer { flex: 1; min-height: 0; + overflow: hidden; position: relative; } @@ -39,3 +40,54 @@ background-color: var(--colors-grey300); flex-shrink: 0; } + +.clearFiltersButton:disabled { + color: var(--colors-grey400); + cursor: not-allowed; +} + +.clearFiltersButton:disabled:hover { + color: var(--colors-grey400); + background-color: transparent; +} + +.toggleButton.active { + color: var(--colors-blue700); + background-color: var(--colors-blue100); +} + +.toggleButton.active:hover { + background-color: var(--colors-blue200); +} + +/* !important beats @dhis2/ui's own ColorPicker field margin. */ +.highlightColorPicker { + margin-bottom: 0 !important; + flex-shrink: 0; + display: flex; + align-items: center; + position: relative; + top: -1px; +} + +/* !important beats @dhis2/ui's own ColorPicker label size. */ +.highlightColorPicker label { + box-sizing: border-box; + overflow: hidden; + min-width: 18px !important; + min-height: 18px !important; +} + +.globalSearch { + flex: 0 1 160px; + min-width: 90px; +} + +.globalSearch > :global(div) { + width: 100%; +} + +.globalSearch :global(input.dense) { + padding: 4px 6px; + font-size: 11px; +} diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index 8f6c8de3f4..416b4a55d8 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -22,6 +22,13 @@ th.monoCell { font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', 'Consolas', monospace; } +.iconCell { + display: block; + width: 20px; + height: 20px; + object-fit: contain; +} + th.checkboxCell, td.checkboxCell { width: 76px; @@ -69,6 +76,10 @@ th.hovered { gap: 2px; } +.headerTitle { + padding-top: var(--spacers-dp4); +} + .reverseButton { display: inline-flex; align-items: center; @@ -149,14 +160,32 @@ th.hovered { .loadingReason { font-size: 12px; - color: var(--colors-grey700); + color: var(--colors-white); } .noSupport { position: absolute; - top: 50%; left: 50%; - transform: translateX(-50%) translateY(-50%); + transform: translateX(-50%); + display: flex; + align-items: center; + gap: var(--spacers-dp8); color: var(--colors-grey600); font-style: italic; + font-size: 12px; +} + +.editLayerLink { + font-size: 12px; + font-style: normal; + color: var(--colors-blue600); + background: transparent; + border: none; + padding: 0; + cursor: pointer; + text-decoration: underline; +} + +.editLayerLink:hover { + color: var(--colors-blue700); } diff --git a/src/components/datatable/styles/ErrorBoundary.module.css b/src/components/datatable/styles/ErrorBoundary.module.css new file mode 100644 index 0000000000..816db97bd3 --- /dev/null +++ b/src/components/datatable/styles/ErrorBoundary.module.css @@ -0,0 +1,5 @@ +.message { + color: var(--colors-grey600); + font-style: italic; + font-size: 12px; +} diff --git a/src/components/datatable/styles/FilterInput.module.css b/src/components/datatable/styles/FilterInput.module.css index 336b3c496a..2541b751cf 100644 --- a/src/components/datatable/styles/FilterInput.module.css +++ b/src/components/datatable/styles/FilterInput.module.css @@ -104,6 +104,23 @@ font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', 'Consolas', monospace; } +.iconOption { + display: flex; + align-items: center; + gap: 6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.iconOptionThumbnail { + display: block; + flex: none; + width: 16px; + height: 16px; + object-fit: contain; +} + .denseCheckbox { margin: 0; padding: 4px 0; @@ -160,3 +177,28 @@ .multiSelectPopover .highlighted { background: var(--colors-grey100); } + +.treeRow { + display: flex; + align-items: center; + width: 100%; +} + +.expandButton { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + border: none; + flex-shrink: 0; + background: transparent; + color: var(--colors-grey700); + cursor: pointer; +} + +.expandButtonPlaceholder { + width: 16px; + flex-shrink: 0; +} diff --git a/src/components/datatable/useColumnWidths.js b/src/components/datatable/useColumnWidths.js index 2644c6c86c..c6a857c636 100644 --- a/src/components/datatable/useColumnWidths.js +++ b/src/components/datatable/useColumnWidths.js @@ -1,5 +1,7 @@ import { useEffect, useRef, useState } from 'react' +const MIN_COLUMN_WIDTH = 100 + export const useColumnWidths = ({ availableWidth, headers, error }) => { const headerRowRef = useRef(null) const minColumnWidthsRef = useRef([]) @@ -23,7 +25,9 @@ export const useColumnWidths = ({ availableWidth, headers, error }) => { for (const cell of dataCells) { const rect = cell.getBoundingClientRect() - measuredColumnWidths.push(Math.floor(rect.width)) + measuredColumnWidths.push( + Math.max(MIN_COLUMN_WIDTH, Math.floor(rect.width)) + ) } minColumnWidthsRef.current = measuredColumnWidths diff --git a/src/components/datatable/usePanelHeights.js b/src/components/datatable/usePanelHeights.js new file mode 100644 index 0000000000..22ef1da263 --- /dev/null +++ b/src/components/datatable/usePanelHeights.js @@ -0,0 +1,18 @@ +import { useSelector } from 'react-redux' +import { getPanelHeights } from '../../util/dataTable.js' +import { getCssVar } from '../../util/helpers.js' +import { useWindowDimensions } from '../WindowDimensionsProvider.jsx' + +export const usePanelHeights = (isCollapsed) => { + const dataTableHeight = useSelector((state) => state.ui.dataTableHeight) + const { height } = useWindowDimensions() + + return getPanelHeights({ + windowHeight: height, + dataTableHeight, + isCollapsed, + headerHeight: getCssVar('--header-height'), + toolbarHeight: getCssVar('--toolbar-height'), + controlsHeight: getCssVar('--data-table-controls-height'), + }) +} diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index b9a8031573..131941cb7b 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -1,55 +1,38 @@ import i18n from '@dhis2/d2-i18n' -import { useMemo, useRef } from 'react' +import { useDeferredValue, useMemo, useRef } from 'react' import { useSelector } from 'react-redux' -import { SENTINEL_NO_VALUE, SORT_ASCENDING } from '../../constants/dataTable.js' +import { + SENTINEL_SELECTED_ROW, + SORT_ASCENDING, +} from '../../constants/dataTable.js' import { EVENT_LAYER, THEMATIC_LAYER, - ORG_UNIT_LAYER, EARTH_ENGINE_LAYER, - FACILITY_LAYER, - GEOJSON_URL_LAYER, + RENDERING_STRATEGY_SINGLE, + RENDERING_STRATEGY_TIMELINE, } from '../../constants/layers.js' import { SELECTION_FILTER_SELECTED, SELECTION_FILTER_NOT_SELECTED, } from '../../constants/selection.js' -import { numberValueTypes } from '../../constants/valueTypes.js' -import { hasClasses } from '../../util/earthEngine.js' import { filterByGlobalSearch, filterData } from '../../util/filter.js' -import { getGeojsonDisplayData, isFeatureInBounds } from '../../util/geojson.js' -import { getRoundToPrecisionFn, getPrecision } from '../../util/numbers.js' +import { + buildRowCells, + getColumnDistinctValues, +} from '../../util/tableColumns.js' +import { + TYPE_STRING, + ERROR_NON_HOMOGENOUS_FEATURES, + getHeadersForLayer, +} from '../../util/tableHeaders.js' +import { ERROR_NO_VALID_DATA, buildTableData } from '../../util/tableRows.js' import { compareColumnOptionValues, compareRows } from '../../util/tableSort.js' -import { isValidUid } from '../../util/uid.js' - -const TYPE_NUMBER = 'number' -const TYPE_STRING = 'string' -const TYPE_DATE = 'date' -const NAME = 'name' -const ID = 'id' -const VALUE = 'rawValue' -const LEGEND = 'legend' -const RANGE = 'range' -const LEVEL = 'level' -const PARENT_NAME = 'parentName' -const TYPE = 'type' -const COLOR = 'color' -const OUNAME = 'ouname' -const OUBOUNDARY = 'ouBoundary' -const EVENTDATE = 'eventdate' - -const ERROR_SERVER_CLUSTER = 'SERVER_CLUSTER' -const ERROR_NO_VALID_DATA = 'NO_VALID_DATA' const ERROR_NO_HEADERS = 'NO_HEADERS' -const ERROR_NON_HOMOGENOUS_FEATURES = 'NON_HOMOGENOUS_FEATURES' const getErrorCodeText = (code) => { switch (code) { - case ERROR_SERVER_CLUSTER: - return i18n.t( - 'Data table is not supported when events are grouped on the server.' - ) case ERROR_NO_VALID_DATA: return i18n.t( 'No valid data was found for the current layer configuration.' @@ -65,128 +48,6 @@ const getErrorCodeText = (code) => { } } -const defaultFieldsMap = () => ({ - [NAME]: { name: i18n.t('Name'), dataKey: NAME, type: TYPE_STRING }, - [ID]: { name: i18n.t('Id'), dataKey: ID, type: TYPE_STRING }, - [LEVEL]: { name: i18n.t('Level'), dataKey: LEVEL, type: TYPE_NUMBER }, - [PARENT_NAME]: { - name: i18n.t('Parent'), - dataKey: PARENT_NAME, - type: TYPE_STRING, - }, - [TYPE]: { name: i18n.t('Type'), dataKey: TYPE, type: TYPE_STRING }, - [VALUE]: { name: i18n.t('Value'), dataKey: VALUE, type: TYPE_NUMBER }, - [LEGEND]: { name: i18n.t('Legend'), dataKey: LEGEND, type: TYPE_STRING }, - [RANGE]: { name: i18n.t('Range'), dataKey: RANGE, type: TYPE_STRING }, - [OUNAME]: { name: i18n.t('Org unit'), dataKey: OUNAME, type: TYPE_STRING }, - [OUBOUNDARY]: { - name: i18n.t('Org unit boundary'), - dataKey: OUBOUNDARY, - type: TYPE_STRING, - }, - [EVENTDATE]: { - name: i18n.t('Event time'), - dataKey: EVENTDATE, - type: TYPE_DATE, - renderer: 'formatTime...', - }, - [COLOR]: { - name: i18n.t('Color'), - dataKey: COLOR, - type: TYPE_STRING, - renderer: 'rendercolor', - }, -}) - -const getThematicHeaders = () => - [NAME, ID, VALUE, LEGEND, RANGE, LEVEL, PARENT_NAME, TYPE, COLOR].map( - (field) => defaultFieldsMap()[field] - ) - -const getEventHeaders = ({ - layerHeaders = [], - styleDataItem, - countEventsOutsideOrgUnits, -}) => { - const fields = [OUNAME, ID, EVENTDATE].map( - (field) => defaultFieldsMap()[field] - ) - - if (countEventsOutsideOrgUnits) { - fields.push(defaultFieldsMap()[OUBOUNDARY]) - } - - const customFields = layerHeaders - .filter(({ name }) => isValidUid(name)) - .map(({ name: dataKey, column: name, valueType, optionSet }) => ({ - name, - dataKey, - type: - !optionSet && numberValueTypes.includes(valueType) - ? TYPE_NUMBER - : TYPE_STRING, - optionSet: optionSet || null, - })) - - customFields.push(defaultFieldsMap()[TYPE]) - - if (styleDataItem) { - customFields.push(defaultFieldsMap()[COLOR]) - } - - return fields.concat(customFields) -} - -const getOrgUnitHeaders = () => - [NAME, ID, LEVEL, PARENT_NAME, TYPE].map( - (field) => defaultFieldsMap()[field] - ) - -const getFacilityHeaders = () => - [NAME, ID, TYPE].map((field) => defaultFieldsMap()[field]) - -const toTitleCase = (str) => - str.replace( - /\w\S*/g, - (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() - ) - -const getEarthEngineHeaders = ({ aggregationType, legend, data }) => { - const { title, items } = legend - - let customFields = [] - - if (hasClasses(aggregationType) && items) { - customFields = items.map(({ value, name }) => ({ - name, - dataKey: String(value), - roundFn: getRoundToPrecisionFn(2), - type: TYPE_NUMBER, - })) - } else if (Array.isArray(aggregationType) && aggregationType.length) { - customFields = aggregationType.map((type) => { - let roundFn = null - if (data?.length) { - const precision = getPrecision(data.map((d) => d[type])) - roundFn = getRoundToPrecisionFn(precision) - } - return { - name: toTitleCase(`${type} ${title}`), - dataKey: type, - roundFn, - type: TYPE_NUMBER, - } - }) - } - - return [NAME, ID, TYPE] - .map((field) => defaultFieldsMap()[field]) - .concat(customFields) -} - -const getGeoJsonUrlHeaders = (firstDataItem) => - getGeojsonDisplayData(firstDataItem) - const EMPTY_AGGREGATIONS = {} const EMPTY_LAYER = {} const EMPTY_COLUMN_OPTIONS = {} @@ -200,9 +61,13 @@ export const useTableData = ({ selectionFilter, selectedIdSet, globalSearch, + keyAnalysisDigitGroupSeparator, }) => { const allAggregations = useSelector((state) => state.aggregations) const aggregations = allAggregations[layer.id] || EMPTY_AGGREGATIONS + const externalPeriod = useSelector( + (state) => state.ui?.activeTimelinePeriod + ) const errorCode = useRef(null) @@ -217,46 +82,59 @@ export const useTableData = ({ dataFilters, headers: layerHeaders, serverCluster, + renderingStrategy, + valuesByPeriod, + periods, + legendDecimalPlaces, } = layer || EMPTY_LAYER + const isMultiPeriodThematic = + layerType === THEMATIC_LAYER && + renderingStrategy && + renderingStrategy !== RENDERING_STRATEGY_SINGLE + const isTimelineThematic = + isMultiPeriodThematic && + renderingStrategy === RENDERING_STRATEGY_TIMELINE + const isStyledEvent = layerType === EVENT_LAYER && !!styleDataItem + // Only depend on mapBounds while the toggle is on, so panning/zooming // doesn't recompute dataWithAggregations below when it's off const boundsDependency = showOnlyFeaturesInView ? mapBounds : null + const selectedIdSetDependency = + sortField === SENTINEL_SELECTED_ROW || selectionFilter?.length + ? selectedIdSet + : null + const periodsDependency = isMultiPeriodThematic ? periods : null + const valuesByPeriodDependency = isMultiPeriodThematic + ? valuesByPeriod + : null + const externalPeriodDependency = isTimelineThematic ? externalPeriod : null const dataWithAggregations = useMemo(() => { - errorCode.current = null - if (serverCluster) { - errorCode.current = ERROR_SERVER_CLUSTER - return null - } - - const allData = dataWithoutCoords?.length - ? [...(data || []), ...dataWithoutCoords] - : data - - if (!allData?.length) { - errorCode.current = ERROR_NO_VALID_DATA - return null - } - - const inViewData = showOnlyFeaturesInView - ? allData.filter((d) => isFeatureInBounds(d, mapBounds)) - : allData - - if (layerType === GEOJSON_URL_LAYER) { - return inViewData.map((d) => ({ - ...d.properties, - })) - } + const { data: rows, errorCode: rowsErrorCode } = buildTableData( + layerType, + { + data, + dataWithoutCoords, + serverCluster, + showOnlyFeaturesInView, + mapBounds, + aggregations, + isStyledEvent, + isMultiPeriodThematic, + isTimelineThematic, + legend, + valuesByPeriod, + externalPeriod, + periods, + keyAnalysisDigitGroupSeparator, + legendDecimalPlaces, + } + ) - return inViewData - .filter((d) => !d.properties.hasAdditionalGeometry) - .map((d, index) => ({ - ...(d.properties || d), - ...aggregations[d.id], - // Row-order tie-breaker for compareRows when no sortField is set - index, - })) + errorCode.current = rowsErrorCode ?? null + return rowsErrorCode ? null : rows + // *Dependency vars proxy their raw counterparts (see above) // eslint-disable-next-line react-hooks/exhaustive-deps }, [ data, @@ -266,6 +144,15 @@ export const useTableData = ({ layerType, showOnlyFeaturesInView, boundsDependency, + isMultiPeriodThematic, + isTimelineThematic, + valuesByPeriodDependency, + externalPeriodDependency, + periodsDependency, + isStyledEvent, + legend, + keyAnalysisDigitGroupSeparator, + legendDecimalPlaces, ]) const headers = useMemo(() => { @@ -273,47 +160,26 @@ export const useTableData = ({ return null } - let headers = null - switch (layerType) { - case THEMATIC_LAYER: - headers = getThematicHeaders() - break - case EVENT_LAYER: - headers = getEventHeaders({ - layerHeaders, - styleDataItem, - countEventsOutsideOrgUnits, - }) - break - case ORG_UNIT_LAYER: - headers = getOrgUnitHeaders() - break - case EARTH_ENGINE_LAYER: - headers = getEarthEngineHeaders({ - aggregationType, - legend, - data: dataWithAggregations, - }) - break - case FACILITY_LAYER: - headers = getFacilityHeaders() - break - case GEOJSON_URL_LAYER: { - if ( - data.some( - (feature) => - feature.geometry.type !== data[0].geometry.type - ) - ) { - errorCode.current = ERROR_NON_HOMOGENOUS_FEATURES - return null - } - - headers = getGeoJsonUrlHeaders(data[0]) - break + const { headers, errorCode: headersErrorCode } = getHeadersForLayer( + layerType, + { + isMultiPeriodThematic, + isTimelineThematic, + externalPeriod, + periods, + layerHeaders, + styleDataItem, + countEventsOutsideOrgUnits, + aggregationType, + legend, + data: dataWithAggregations, + rawData: data, } - default: - break + ) + + if (headersErrorCode) { + errorCode.current = headersErrorCode + return null } if (!headers?.length) { @@ -321,6 +187,8 @@ export const useTableData = ({ return null } return headers + // *Dependency vars proxy their raw counterparts (see above) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ layerType, aggregationType, @@ -330,29 +198,31 @@ export const useTableData = ({ dataWithAggregations, data, layerHeaders, + isMultiPeriodThematic, + isTimelineThematic, + externalPeriodDependency, + periodsDependency, ]) + // Expensive: scans every row once per column + const deferredDataForOptions = useDeferredValue(dataWithAggregations) + const columnDistinctValues = useMemo( + () => getColumnDistinctValues(headers, deferredDataForOptions), + [headers, deferredDataForOptions] + ) + + // Cheap: just re-orders each column's already-known distinct-value list const columnOptions = useMemo(() => { - if (!headers?.length || !dataWithAggregations?.length) { + if (!columnDistinctValues) { return EMPTY_COLUMN_OPTIONS } const result = {} - headers.forEach(({ dataKey, type }) => { - const seen = new Set() - for (const item of dataWithAggregations) { - const val = item[dataKey] - seen.add( - val === undefined || val === null || val === '' - ? SENTINEL_NO_VALUE - : String(val) - ) - } - - if (seen.size > 0) { + Object.entries(columnDistinctValues).forEach( + ([dataKey, { values, type }]) => { const direction = dataKey === sortField ? sortDirection : SORT_ASCENDING - result[dataKey] = Array.from(seen) + result[dataKey] = [...values] .sort((a, b) => compareColumnOptionValues(a, b, { dataKey, @@ -362,10 +232,10 @@ export const useTableData = ({ ) .map((value) => ({ value })) } - }) + ) return Object.keys(result).length ? result : EMPTY_COLUMN_OPTIONS - }, [headers, dataWithAggregations, sortField, sortDirection]) + }, [columnDistinctValues, sortField, sortDirection]) const rows = useMemo(() => { if (errorCode.current) { @@ -410,18 +280,9 @@ export const useTableData = ({ compareRows(a, b, { sortField, sortDirection, selectedIdSet }) ) - return filteredData.map((item) => - headers.map(({ dataKey, roundFn, type }) => { - const value = roundFn ? roundFn(item[dataKey]) : item[dataKey] - - return { - dataKey, - value: type === TYPE_NUMBER && isNaN(value) ? null : value, - align: type === TYPE_NUMBER ? 'right' : 'left', - itemId: item.id, - } - }) - ) + return filteredData.map((item) => buildRowCells(item, headers)) + // *Dependency vars proxy their raw counterparts (see above) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ headers, dataWithAggregations, @@ -430,7 +291,7 @@ export const useTableData = ({ sortField, sortDirection, selectionFilter, - selectedIdSet, + selectedIdSetDependency, ]) // EE layers and event layers may be loading additional data @@ -439,7 +300,9 @@ export const useTableData = ({ aggregationType?.length && (!aggregations || aggregations === EMPTY_AGGREGATIONS) const isExtendingEvents = - layerType === EVENT_LAYER && !layer.isExtended && !serverCluster + layerType === EVENT_LAYER && + !layer.isExtended && + !!(!serverCluster || layer.forceClientCluster) const isLoading = isLoadingAggregations || isExtendingEvents let loadingReason = null if (isLoadingAggregations) { diff --git a/src/components/layers/toolbar/LayerToolbarMoreMenu.jsx b/src/components/layers/toolbar/LayerToolbarMoreMenu.jsx index 0e0ad2dd67..4092711c2c 100644 --- a/src/components/layers/toolbar/LayerToolbarMoreMenu.jsx +++ b/src/components/layers/toolbar/LayerToolbarMoreMenu.jsx @@ -15,7 +15,7 @@ import { import PropTypes from 'prop-types' import React, { useState, useRef } from 'react' import { connect } from 'react-redux' -import { EARTH_ENGINE_LAYER } from '../../../constants/layers.js' +import { EARTH_ENGINE_LAYER, EVENT_LAYER } from '../../../constants/layers.js' import { IconButton } from '../../core/index.js' import styles from './styles/LayerToolbarMore.module.css' @@ -170,8 +170,12 @@ export default connect( { layer = DEFAULT_EMPTY_LAYER } ) => { const isEarthEngine = layer.layer === EARTH_ENGINE_LAYER + const isServerClusteredEvent = + layer.layer === EVENT_LAYER && layer.serverCluster const hasOrgUnitData = - layer.data && (!isEarthEngine || layer.aggregationType?.length > 0) + isServerClusteredEvent || + (layer.data && + (!isEarthEngine || layer.aggregationType?.length > 0)) const isLoading = isEarthEngine && hasOrgUnitData && !aggregations[layer.id] diff --git a/src/components/layers/toolbar/__tests__/LayerToolbarMoreMenu.spec.jsx b/src/components/layers/toolbar/__tests__/LayerToolbarMoreMenu.spec.jsx index 314b731eaa..f524fa7bce 100644 --- a/src/components/layers/toolbar/__tests__/LayerToolbarMoreMenu.spec.jsx +++ b/src/components/layers/toolbar/__tests__/LayerToolbarMoreMenu.spec.jsx @@ -150,6 +150,73 @@ describe('LayerToolbarMoreMenu', () => { }) }) + test('enables Show data table for a server-clustered event layer with no data yet', async () => { + const store = { + aggregations: {}, + } + + const layer = { + id: 'rainbowdash', + layer: 'event', + serverCluster: true, + } + + render( + + + + ) + + fireEvent.click(screen.getByLabelText('Toggle layer menu')) + + await waitFor(() => { + expect(screen.queryByText('Show data table')).toBeTruthy() + expect( + screen + .queryByText('Show data table') + .closest('li') + .classList.contains('disabled') + ).toBe(false) + }) + }) + + test('also enables Download data for a server-clustered event layer with no data yet', async () => { + const store = { + aggregations: {}, + } + + const layer = { + id: 'rainbowdash', + layer: 'event', + serverCluster: true, + } + + render( + + + + ) + + fireEvent.click(screen.getByLabelText('Toggle layer menu')) + + await waitFor(() => { + expect(screen.queryByText('Download data')).toBeTruthy() + expect( + screen + .queryByText('Download data') + .closest('li') + .classList.contains('disabled') + ).toBe(false) + }) + }) + test('renders three MenuItems WITH divider if passed toggleDataTable, onEdit, and onRemove', async () => { const store = { aggregations: {}, diff --git a/src/components/loaders/useLoaderAlerts.js b/src/components/loaders/useLoaderAlerts.js index 949bbf936f..da9b6fc4e3 100644 --- a/src/components/loaders/useLoaderAlerts.js +++ b/src/components/loaders/useLoaderAlerts.js @@ -7,6 +7,7 @@ import { WARNING_NO_OU_COORD, WARNING_NO_GEOMETRY_COORD, WARNING_OU_BOUNDARIES_FETCH_FAILED, + WARNING_EXTERNAL_LAYER_NOT_FOUND, ERROR_CRITICAL, CUSTOM_ALERT, } from '../../constants/alerts.js' @@ -44,6 +45,11 @@ function useLoaderAlerts(loaderAlertAction = Function.prototype) { onHidden: loaderAlertAction, }) + const externalLayerNotFoundAlert = useAlert(ALERT_MESSAGE_DYNAMIC, { + warning: true, + onHidden: loaderAlertAction, + }) + const showAlerts = (alerts) => { alerts.forEach(({ message: msg, code, warning, critical }) => { switch (code) { @@ -84,6 +90,14 @@ function useLoaderAlerts(loaderAlertAction = Function.prototype) { }) break } + case WARNING_EXTERNAL_LAYER_NOT_FOUND: { + externalLayerNotFoundAlert.show({ + msg: `${msg}: ${i18n.t( + 'External layer definition not found, showing last known settings' + )}`, + }) + break + } case ERROR_CRITICAL: { errorAlert.show({ msg: `${i18n.t('Error')}: ${msg}` }) break diff --git a/src/components/map/Map.jsx b/src/components/map/Map.jsx index 3358c2f28f..8269043eb2 100644 --- a/src/components/map/Map.jsx +++ b/src/components/map/Map.jsx @@ -58,6 +58,7 @@ class Map extends Component { resizeCount: PropTypes.number, selection: PropTypes.object, selectionFilter: PropTypes.array, + setActiveTimelinePeriod: PropTypes.func, setAggregations: PropTypes.func, setFeatureProfile: PropTypes.func, setMapObject: PropTypes.func, @@ -191,6 +192,7 @@ class Map extends Component { coordinatePopup: coordinates, closeCoordinatePopup, openContextMenu, + setActiveTimelinePeriod, setAggregations, setFeatureProfile, resizeCount, @@ -215,9 +217,10 @@ class Map extends Component { periodId={period.id} period={period} periods={timelineOverlay?.periods} - onChange={(period) => + onChange={(period) => { this.setState({ period }) - } + setActiveTimelinePeriod?.(period) + }} resizeCount={resizeCount} /> @@ -322,6 +325,7 @@ class Map extends Component { if (initialPeriod) { this.setState({ period: initialPeriod }) + this.props.setActiveTimelinePeriod?.(initialPeriod) } } } diff --git a/src/components/map/MapContainer.jsx b/src/components/map/MapContainer.jsx index 6db6795fed..3e0b018baf 100644 --- a/src/components/map/MapContainer.jsx +++ b/src/components/map/MapContainer.jsx @@ -2,6 +2,7 @@ import PropTypes from 'prop-types' import React, { useCallback } from 'react' import { useSelector, useDispatch } from 'react-redux' import { setAggregations } from '../../actions/aggregations.js' +import { setActiveTimelinePeriod } from '../../actions/dataTable.js' import { highlightFeature, setFeatureProfile, @@ -64,6 +65,9 @@ const MapContainer = ({ resizeCount, setMap }) => { closeCoordinatePopup={() => dispatch(closeCoordinatePopup())} setAggregations={(data) => dispatch(setAggregations(data))} setFeatureProfile={(val) => dispatch(setFeatureProfile(val))} + setActiveTimelinePeriod={(period) => + dispatch(setActiveTimelinePeriod(period)) + } resizeCount={resizeCount} setMapObject={setMap} layersSorting={layersSorting} diff --git a/src/components/map/layers/EventPopup.jsx b/src/components/map/layers/EventPopup.jsx index a6ffca021f..c9f1b7f4af 100644 --- a/src/components/map/layers/EventPopup.jsx +++ b/src/components/map/layers/EventPopup.jsx @@ -4,7 +4,7 @@ import PropTypes from 'prop-types' import React, { useEffect, useState } from 'react' import { EVENT_ID_FIELD } from '../../../util/geojson.js' import { - formatDatetime, + formatDate, formatCoordinate, formatValueForDisplay, } from '../../../util/helpers.js' @@ -177,8 +177,8 @@ const EventPopup = ({ )} {occurredAt && ( - {i18n.t('Event time')} - {formatDatetime(occurredAt)} + {i18n.t('Event date')} + {formatDate(occurredAt)} )} diff --git a/src/components/plugin/LayerLoader.jsx b/src/components/plugin/LayerLoader.jsx index bd20f0ddcb..e2fcdc75a7 100644 --- a/src/components/plugin/LayerLoader.jsx +++ b/src/components/plugin/LayerLoader.jsx @@ -31,6 +31,7 @@ const LayerLoader = ({ config, onLoad }) => { const { systemSettings: { keyAnalysisDigitGroupSeparator }, currentUser, + spatialSupport, } = useCachedData() const { keyAnalysisDisplayProperty, @@ -59,6 +60,7 @@ const LayerLoader = ({ config, onLoad }) => { analyticsEngine, // Thematic and Event loader periodTypeData, // Thematic and Event loader serverVersion, // Tracked entity loader + spatialSupport, // Event loader }).then((result) => { onLoad(result) }) @@ -74,6 +76,7 @@ const LayerLoader = ({ config, onLoad }) => { keyAnalysisDisplayProperty, keyAnalysisDigitGroupSeparator, serverVersion, + spatialSupport, ]) return null diff --git a/src/components/plugin/Plugin.jsx b/src/components/plugin/Plugin.jsx index 9d9de5780c..64fd9313be 100644 --- a/src/components/plugin/Plugin.jsx +++ b/src/components/plugin/Plugin.jsx @@ -23,9 +23,19 @@ const query = { fields: `${CURRENT_USER_FIELDS},settings[keyAnalysisDisplayProperty]`, }, }, + systemInfo: { + resource: 'system/info', + params: { + fields: 'databaseInfo[spatialSupport]', + }, + }, } -const providerDataTransformation = ({ systemSettings, currentUser }) => { +const providerDataTransformation = ({ + systemSettings, + currentUser, + systemInfo, +}) => { return { systemSettings: { ...DEFAULT_SYSTEM_SETTINGS, @@ -47,6 +57,7 @@ const providerDataTransformation = ({ systemSettings, currentUser }) => { currentUser.settings.keyAnalysisDisplayProperty === 'name' ? 'displayName' : 'displayShortName', + spatialSupport: systemInfo.databaseInfo?.spatialSupport, } } diff --git a/src/constants/actionTypes.js b/src/constants/actionTypes.js index ea24c6b490..cc05ef1559 100644 --- a/src/constants/actionTypes.js +++ b/src/constants/actionTypes.js @@ -35,6 +35,7 @@ export const LAYER_TOGGLE_EXPAND = 'LAYER_TOGGLE_EXPAND' export const LAYER_TOGGLE_VISIBILITY = 'LAYER_TOGGLE_VISIBILITY' export const LAYER_UPDATE = 'LAYER_UPDATE' export const LAYER_DRILL = 'LAYER_DRILL' +export const LAYER_FORCE_CLIENT_CLUSTER_SET = 'LAYER_FORCE_CLIENT_CLUSTER_SET' /* DATA TABLE */ export const DATA_TABLE_CLOSE = 'DATA_TABLE_CLOSE' @@ -46,6 +47,7 @@ export const SELECTION_FILTER_SET = 'SELECTION_FILTER_SET' 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' /* DATA FILTER */ export const DATA_FILTER_SET = 'DATA_FILTER_SET' diff --git a/src/constants/alerts.js b/src/constants/alerts.js index 682851742b..9280206e49 100644 --- a/src/constants/alerts.js +++ b/src/constants/alerts.js @@ -14,5 +14,7 @@ export const WARNING_NO_OU_COORD = 'WARNING_NO_OU_COORD' export const WARNING_NO_GEOMETRY_COORD = 'WARNING_NO_GEOMETRY_COORD' export const WARNING_OU_BOUNDARIES_FETCH_FAILED = 'WARNING_OU_BOUNDARIES_FETCH_FAILED' +export const WARNING_EXTERNAL_LAYER_NOT_FOUND = + 'WARNING_EXTERNAL_LAYER_NOT_FOUND' export const ERROR_CRITICAL = 'ERROR_CRITICAL' export const CUSTOM_ALERT = 'CUSTOM_ALERT' diff --git a/src/constants/dataTable.js b/src/constants/dataTable.js index 10d2e59675..0bac15662d 100644 --- a/src/constants/dataTable.js +++ b/src/constants/dataTable.js @@ -4,3 +4,15 @@ export const SENTINEL_SELECTED_ROW = '__selected__' export const SORT_ASCENDING = 'asc' export const SORT_DESCENDING = 'desc' + +export const RENDERER_COLOR = 'rendercolor' +export const RENDERER_ICON = 'rendericon' +export const RENDERER_DATE = 'renderdate' + +export const TYPE_NUMBER = 'number' +export const TYPE_STRING = 'string' +export const TYPE_DATE = 'date' +export const TYPE_DATETIME = 'datetime' +export const TYPE_TIME = 'time' + +export const DATE_GROUPS_GRANULARITY = 'date-groups' diff --git a/src/constants/layers.js b/src/constants/layers.js index bb31be7119..f88c1e1123 100644 --- a/src/constants/layers.js +++ b/src/constants/layers.js @@ -51,6 +51,7 @@ export const DATA_TABLE_LAYER_TYPES = [ THEMATIC_LAYER, ORG_UNIT_LAYER, EVENT_LAYER, + TRACKED_ENTITY_LAYER, EARTH_ENGINE_LAYER, GEOJSON_URL_LAYER, ] diff --git a/src/constants/valueTypes.js b/src/constants/valueTypes.js index 15831f9da8..d9dbf398ce 100644 --- a/src/constants/valueTypes.js +++ b/src/constants/valueTypes.js @@ -32,6 +32,9 @@ export const dateValueTypes = ['DATE', 'AGE'] // Date-time value types export const datetimeValueTypes = ['DATETIME'] +// Time-only value types +export const timeValueTypes = ['TIME'] + // Coordinate value types export const coordinateValueTypes = ['COORDINATE'] diff --git a/src/hooks/__tests__/useLayersLoader.spec.js b/src/hooks/__tests__/useLayersLoader.spec.js new file mode 100644 index 0000000000..19fec7e93b --- /dev/null +++ b/src/hooks/__tests__/useLayersLoader.spec.js @@ -0,0 +1,172 @@ +import { renderHook } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import configureMockStore from 'redux-mock-store' +import { EVENT_LAYER } from '../../constants/layers.js' +import eventLoader from '../../loaders/eventLoader.js' +import { useLayersLoader } from '../useLayersLoader.js' + +let mockCachedData + +jest.mock('../../components/map/MapApi.js', () => ({ + loadEarthEngineWorker: jest.fn(), +})) + +jest.mock('@dhis2/app-runtime', () => ({ + useDataEngine: () => ({}), + useConfig: () => ({ + baseUrl: 'https://example.org', + serverVersion: '2.42', + }), +})) + +jest.mock('@dhis2/app-service-alerts', () => ({ + useAlert: () => ({ show: jest.fn() }), +})) + +jest.mock('@dhis2/analytics', () => ({ + Analytics: { getAnalytics: jest.fn(() => ({})) }, + useDataOutputPeriodTypes: () => undefined, +})) + +jest.mock('../../components/cachedDataProvider/CachedDataProvider.jsx', () => ({ + useCachedData: () => mockCachedData, +})) + +// Never resolves - the reload-trigger tests only care about the synchronous +// setLayerLoading dispatch, not the loader's eventual result. +jest.mock('../../loaders/eventLoader.js', () => ({ + __esModule: true, + default: jest.fn(() => new Promise(() => {})), +})) + +const mockStore = configureMockStore() + +const renderWithStore = (state) => { + const store = mockStore(state) + const wrapper = ({ children }) => ( + {children} + ) + renderHook(() => useLayersLoader(), { wrapper }) + return { store } +} + +const baseLayer = { + id: 'a', + layer: EVENT_LAYER, + isLoaded: true, + isLoading: false, +} + +beforeEach(() => { + eventLoader.mockClear() + mockCachedData = { + systemSettings: { keyAnalysisDigitGroupSeparator: 'NONE' }, + currentUser: { + id: 'user1', + keyAnalysisDisplayProperty: 'name', + userOrgUnitIdsByKeyword: {}, + }, + spatialSupport: true, + } +}) + +describe('useLayersLoader - data table reload trigger', () => { + test('does not reload a server-clustered layer when the table opens and forceClientCluster is not set', () => { + const { store } = renderWithStore({ + map: { + mapViews: [ + { ...baseLayer, serverCluster: true, isExtended: false }, + ], + }, + dataTable: 'a', + }) + + expect(store.getActions()).toEqual([]) + }) + + test('reloads a server-clustered layer once forceClientCluster is set', () => { + const { store } = renderWithStore({ + map: { + mapViews: [ + { + ...baseLayer, + serverCluster: true, + isExtended: false, + forceClientCluster: true, + }, + ], + }, + dataTable: 'a', + }) + + expect(store.getActions()).toEqual([ + { type: 'LAYER_LOADING_SET', id: 'a' }, + ]) + }) + + test('does not reload again once isExtended is true, even with forceClientCluster set (no loop)', () => { + const { store } = renderWithStore({ + map: { + mapViews: [ + { + ...baseLayer, + serverCluster: false, + isExtended: true, + forceClientCluster: true, + }, + ], + }, + dataTable: 'a', + }) + + expect(store.getActions()).toEqual([]) + }) + + test('still reloads a non-clustered layer needing extended data (existing behavior preserved)', () => { + const { store } = renderWithStore({ + map: { + mapViews: [ + { ...baseLayer, serverCluster: false, isExtended: false }, + ], + }, + dataTable: 'a', + }) + + expect(store.getActions()).toEqual([ + { type: 'LAYER_LOADING_SET', id: 'a' }, + ]) + }) +}) + +describe('useLayersLoader - spatialSupport plumbing', () => { + test('passes spatialSupport from useCachedData into the event loader call', () => { + mockCachedData.spatialSupport = true + + renderWithStore({ + map: { + mapViews: [{ ...baseLayer, isLoaded: false }], + }, + dataTable: null, + }) + + expect(eventLoader).toHaveBeenCalledWith( + expect.objectContaining({ spatialSupport: true }) + ) + }) + + test('passes spatialSupport: false through unchanged', () => { + mockCachedData.spatialSupport = false + + renderWithStore({ + map: { + mapViews: [{ ...baseLayer, isLoaded: false }], + }, + dataTable: null, + }) + + expect(eventLoader).toHaveBeenCalledWith( + expect.objectContaining({ spatialSupport: false }) + ) + }) +}) diff --git a/src/hooks/useLayersLoader.js b/src/hooks/useLayersLoader.js index 194765c4cf..66b1083b56 100644 --- a/src/hooks/useLayersLoader.js +++ b/src/hooks/useLayersLoader.js @@ -34,6 +34,7 @@ export const useLayersLoader = () => { const { systemSettings: { keyAnalysisDigitGroupSeparator }, currentUser, + spatialSupport, } = useCachedData() const { showAlerts } = useLoaderAlerts() const allLayers = useSelector((state) => state.map.mapViews) @@ -65,6 +66,7 @@ export const useLayersLoader = () => { periodTypeData, // Thematic and Event loader serverVersion, // Tracked entity loader loadExtended: !!dataTable, // Event loader + spatialSupport, // Event loader }) if (result.alerts) { showAlerts(result.alerts) @@ -87,7 +89,7 @@ export const useLayersLoader = () => { layer.layer === EVENT_LAYER && layer.id === dataTable && !layer.isExtended && - !layer.serverCluster + (!layer.serverCluster || layer.forceClientCluster) ) { return true } @@ -128,5 +130,6 @@ export const useLayersLoader = () => { baseUrl, dataTable, serverVersion, + spatialSupport, ]) } diff --git a/src/loaders/__tests__/eventLoader.spec.js b/src/loaders/__tests__/eventLoader.spec.js index ab9af8ba2d..7a58552fea 100644 --- a/src/loaders/__tests__/eventLoader.spec.js +++ b/src/loaders/__tests__/eventLoader.spec.js @@ -4,12 +4,16 @@ import { USER_ORG_UNIT_GRANDCHILDREN, } from '@dhis2/analytics' import { WARNING_OU_BOUNDARIES_FETCH_FAILED } from '../../constants/alerts.js' +import { EVENT_SERVER_CLUSTER_COUNT } from '../../constants/layers.js' import { getUserOrgUnitIdsByKeyword } from '../../util/orgUnits.js' import { GEOFEATURES_QUERY, ORG_UNITS_PATHS_QUERY, } from '../../util/requests.js' -import { excludeEventsOutsideOrgUnits } from '../eventLoader.js' +import eventLoader, { + excludeEventsOutsideOrgUnits, + shouldUseServerCluster, +} from '../eventLoader.js' // [0,0]-[10,10] const SQUARE_A = [ @@ -729,3 +733,178 @@ describe('excludeEventsOutsideOrgUnits', () => { expect(config.legend.orgUnitsWithoutBoundaryCount).toBeUndefined() }) }) + +describe('shouldUseServerCluster', () => { + const overThreshold = EVENT_SERVER_CLUSTER_COUNT + 1 + + test('returns true when over threshold and the backend supports spatial clustering', () => { + expect( + shouldUseServerCluster({ + count: overThreshold, + countFeaturesWithoutCoordinates: false, + countEventsOutsideOrgUnits: false, + spatialSupport: true, + }) + ).toBe(true) + }) + + test('returns false when over threshold but the backend has no spatial support', () => { + expect( + shouldUseServerCluster({ + count: overThreshold, + countFeaturesWithoutCoordinates: false, + countEventsOutsideOrgUnits: false, + spatialSupport: false, + }) + ).toBe(false) + }) + + test('returns false when over threshold but spatialSupport is undefined (fails closed)', () => { + expect( + shouldUseServerCluster({ + count: overThreshold, + countFeaturesWithoutCoordinates: false, + countEventsOutsideOrgUnits: false, + spatialSupport: undefined, + }) + ).toBe(false) + }) + + test('returns false when under threshold, regardless of spatialSupport', () => { + expect( + shouldUseServerCluster({ + count: EVENT_SERVER_CLUSTER_COUNT, + countFeaturesWithoutCoordinates: false, + countEventsOutsideOrgUnits: false, + spatialSupport: true, + }) + ).toBe(false) + }) + + test('returns false when countFeaturesWithoutCoordinates is set, regardless of spatialSupport', () => { + expect( + shouldUseServerCluster({ + count: overThreshold, + countFeaturesWithoutCoordinates: true, + countEventsOutsideOrgUnits: false, + spatialSupport: true, + }) + ).toBe(false) + }) + + test('returns false when countEventsOutsideOrgUnits is set, regardless of spatialSupport', () => { + expect( + shouldUseServerCluster({ + count: overThreshold, + countFeaturesWithoutCoordinates: false, + countEventsOutsideOrgUnits: true, + spatialSupport: true, + }) + ).toBe(false) + }) +}) + +// A minimal chainable stand-in for the real analytics request builder +class FakeAnalyticsRequest { + withProgram() { + return this + } + withStage() { + return this + } + withCoordinatesOnly() { + return this + } + withStartDate() { + return this + } + withEndDate() { + return this + } + addPeriodFilter() { + return this + } + withRelativePeriodDate() { + return this + } + addOrgUnitDimension() { + return this + } + addDimension() { + return this + } + withCoordinateField() { + return this + } + withEventStatus() { + return this + } + withPageSize() { + return this + } +} + +describe('eventLoader - isExtended vs serverCluster', () => { + const overThreshold = EVENT_SERVER_CLUSTER_COUNT + 1 + + const baseConfig = () => ({ + program: { id: 'prog1' }, + programStage: { id: 'stage1', name: 'Stage 1' }, + columns: [], + filters: [], + rows: [], + eventClustering: true, + startDate: '2024-01-01', + endDate: '2024-01-31', + }) + + const makeArgs = (config) => ({ + config, + engine: { + query: jest.fn().mockResolvedValue({ + programStage: { programStageDataElements: [] }, + }), + }, + keyAnalysisDisplayProperty: 'name', + keyAnalysisDigitGroupSeparator: 'NONE', + analyticsEngine: { + request: FakeAnalyticsRequest, + events: { + getCount: jest + .fn() + .mockResolvedValue({ count: overThreshold, extent: null }), + getQuery: jest.fn().mockResolvedValue({ + headers: [], + metaData: { items: {}, pager: { total: 0 } }, + rows: [], + }), + }, + }, + periodTypeData: undefined, + loadExtended: true, + spatialSupport: true, + }) + + test('does not claim the table has extended data when the layer ends up server-clustered', async () => { + const result = await eventLoader(makeArgs(baseConfig())) + + expect(result.serverCluster).toBe(true) + expect(result.isExtended).toBe(false) + expect(result.data).toBeUndefined() + // Server clustering isn't capped - the legend shows the true total. + expect(result.legend.items[0].count).toBe(overThreshold) + }) + + test('forceClientCluster loads the extended dataset instead of staying server-clustered', async () => { + const result = await eventLoader( + makeArgs({ ...baseConfig(), forceClientCluster: true }) + ) + + expect(result.serverCluster).toBe(false) + expect(result.isExtended).toBe(true) + expect(result.data).toEqual([]) + // Once rendering client-side, the legend must reflect what was + // actually loaded (capped), not the raw analytics total. + expect(result.legend.items[0].count).toBe(0) + }) +}) diff --git a/src/loaders/__tests__/geoJsonUrlLoader.spec.js b/src/loaders/__tests__/geoJsonUrlLoader.spec.js new file mode 100644 index 0000000000..8eac7f2bb2 --- /dev/null +++ b/src/loaders/__tests__/geoJsonUrlLoader.spec.js @@ -0,0 +1,67 @@ +import { stampFeatureColors } from '../geoJsonUrlLoader.js' + +describe('stampFeatureColors', () => { + it('stamps each feature with its matching geometry-type color', () => { + const features = [ + { geometry: { type: 'Point' }, properties: { id: '1' } }, + { geometry: { type: 'Polygon' }, properties: { id: '2' } }, + ] + const legendItemsByType = { + Point: { color: '#ff0000' }, + Polygon: { color: '#00ff00' }, + } + + const result = stampFeatureColors(features, legendItemsByType) + + expect(result[0].properties.color).toBe('#ff0000') + expect(result[1].properties.color).toBe('#00ff00') + }) + + it('normalizes Multi* geometry types to their base type before matching', () => { + const features = [ + { geometry: { type: 'MultiPolygon' }, properties: { id: '1' } }, + ] + const legendItemsByType = { Polygon: { color: '#00ff00' } } + + const result = stampFeatureColors(features, legendItemsByType) + + expect(result[0].properties.color).toBe('#00ff00') + }) + + it('leaves a feature unchanged when its geometry type has no matching color', () => { + const features = [ + { geometry: { type: 'LineString' }, properties: { id: '1' } }, + ] + + const result = stampFeatureColors(features, {}) + + expect(result[0].properties.color).toBeUndefined() + expect(result[0]).toEqual(features[0]) + }) + + it('does not mutate the original feature objects', () => { + const features = [ + { geometry: { type: 'Point' }, properties: { id: '1' } }, + ] + const legendItemsByType = { Point: { color: '#ff0000' } } + + stampFeatureColors(features, legendItemsByType) + + expect(features[0].properties.color).toBeUndefined() + }) + + it('never overwrites a feature that already has its own color', () => { + const features = [ + { + geometry: { type: 'Point' }, + properties: { id: '1', color: '#123456' }, + }, + ] + const legendItemsByType = { Point: { color: '#ff0000' } } + + const result = stampFeatureColors(features, legendItemsByType) + + expect(result[0].properties.color).toBe('#123456') + expect(result[0]).toBe(features[0]) + }) +}) diff --git a/src/loaders/__tests__/trackedEntityLoader.spec.js b/src/loaders/__tests__/trackedEntityLoader.spec.js index 634049846e..a9a69cceea 100644 --- a/src/loaders/__tests__/trackedEntityLoader.spec.js +++ b/src/loaders/__tests__/trackedEntityLoader.spec.js @@ -1,10 +1,89 @@ -import { parseJsonConfig } from '../trackedEntityLoader.js' +import { + getAttributeHeaders, + getAttributeProperties, + applyParsedConfig, + toGeoJson, +} from '../trackedEntityLoader.js' jest.mock('../../components/map/MapApi.js', () => ({ loadEarthEngineWorker: jest.fn(), })) -describe('parseJsonConfig', () => { +describe('getAttributeProperties', () => { + it('maps each attribute uid to its value', () => { + const attributes = [ + { attribute: 'w75KJ2mc4zz', value: 'Gabrielle' }, + { attribute: 'zDhUuAYrxNC', value: 'Schmidt' }, + ] + expect(getAttributeProperties(attributes)).toEqual({ + w75KJ2mc4zz: 'Gabrielle', + zDhUuAYrxNC: 'Schmidt', + }) + }) + + it('returns an empty object when there are no attributes', () => { + expect(getAttributeProperties(undefined)).toEqual({}) + expect(getAttributeProperties([])).toEqual({}) + }) + + it('coerces a numeric-valueType attribute value to a real number', () => { + const attributes = [ + { attribute: 'ageUid', value: '34', valueType: 'INTEGER' }, + { attribute: 'nameUid', value: 'Gabrielle', valueType: 'TEXT' }, + ] + expect(getAttributeProperties(attributes)).toEqual({ + ageUid: 34, + nameUid: 'Gabrielle', + }) + }) + + it('leaves a numeric-valueType value with no data as undefined, not NaN', () => { + const attributes = [ + { attribute: 'ageUid', value: '', valueType: 'INTEGER' }, + ] + expect(getAttributeProperties(attributes).ageUid).toBeUndefined() + }) +}) + +describe('getAttributeHeaders', () => { + it('returns one header per unique attribute uid seen across instances', () => { + const instances = [ + { + attributes: [ + { + attribute: 'w75KJ2mc4zz', + displayName: 'First name', + valueType: 'TEXT', + }, + ], + }, + { + attributes: [ + { + attribute: 'w75KJ2mc4zz', + displayName: 'First name', + valueType: 'TEXT', + }, + { + attribute: 'zDhUuAYrxNC', + displayName: 'Last name', + valueType: 'TEXT', + }, + ], + }, + ] + expect(getAttributeHeaders(instances)).toEqual([ + { name: 'First name', dataKey: 'w75KJ2mc4zz', valueType: 'TEXT' }, + { name: 'Last name', dataKey: 'zDhUuAYrxNC', valueType: 'TEXT' }, + ]) + }) + + it('returns an empty array when no instance has attributes', () => { + expect(getAttributeHeaders([{ attributes: [] }, {}])).toEqual([]) + }) +}) + +describe('applyParsedConfig', () => { it('extracts periodType when relationships is null', () => { const config = { config: JSON.stringify({ @@ -12,7 +91,7 @@ describe('parseJsonConfig', () => { periodType: 'program', }), } - parseJsonConfig(config) + applyParsedConfig(config) expect(config.periodType).toBe('program') expect(config.relationshipType).toBeUndefined() expect(config.config).toBeUndefined() @@ -31,7 +110,7 @@ describe('parseJsonConfig', () => { periodType: 'program', }), } - parseJsonConfig(config) + applyParsedConfig(config) expect(config.periodType).toBe('program') expect(config.relationshipType).toBe('rel-type-id') expect(config.relatedPointColor).toBe('#ff0000') @@ -43,14 +122,40 @@ describe('parseJsonConfig', () => { it('does nothing when config.config is absent', () => { const config = { layer: 'trackedEntity' } - parseJsonConfig(config) + applyParsedConfig(config) expect(config).toEqual({ layer: 'trackedEntity' }) }) it('does not throw and leaves config intact on malformed JSON', () => { const config = { config: 'not-valid-json' } - expect(() => parseJsonConfig(config)).not.toThrow() + expect(() => applyParsedConfig(config)).not.toThrow() expect(config.periodType).toBeUndefined() expect(config.config).toBeUndefined() }) }) + +describe('toGeoJson', () => { + it('stamps the given color onto every instance, alongside its id and attributes', () => { + const instances = [ + { + id: 'tei-1', + geometry: { type: 'Point', coordinates: [1, 2] }, + attributes: [{ attribute: 'w75KJ2mc4zz', value: 'Gabrielle' }], + }, + ] + + const result = toGeoJson(instances, '#ff0000') + + expect(result).toEqual([ + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [1, 2] }, + properties: { + id: 'tei-1', + color: '#ff0000', + w75KJ2mc4zz: 'Gabrielle', + }, + }, + ]) + }) +}) diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index e5d517a341..275ded1dbf 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -58,12 +58,14 @@ const expandOrgUnitKeyword = (id, userOrgUnitIdsByKeyword) => { return [id] } -// Server clustering if more than 2000 events -const shouldUseServerCluster = ( +// Server clustering if more than 2000 events, and the backend supports it +export const shouldUseServerCluster = ({ count, countFeaturesWithoutCoordinates, - countEventsOutsideOrgUnits -) => + countEventsOutsideOrgUnits, + spatialSupport, +}) => + !!spatialSupport && !countFeaturesWithoutCoordinates && !countEventsOutsideOrgUnits && count > EVENT_SERVER_CLUSTER_COUNT @@ -97,6 +99,7 @@ const eventLoader = async ({ analyticsEngine, periodTypeData, loadExtended, + spatialSupport, }) => { const config = { ...layerConfig, @@ -117,6 +120,7 @@ const eventLoader = async ({ analyticsEngine, periodTypeData, loadExtended, + spatialSupport, }) } catch (e) { if ( @@ -149,6 +153,7 @@ const loadEventLayer = async ({ analyticsEngine, periodTypeData, loadExtended, + spatialSupport, }) => { // Config normalization // ----- @@ -239,13 +244,15 @@ const loadEventLayer = async ({ const dataFilters = getFiltersFromColumns(columns) - config.isExtended = loadExtended - - const analyticsRequest = await getAnalyticsRequest(config, { - analyticsEngine, - nameProperty: displayNameProp, - engine, - }) + // Request setup only - config.isExtended is set further down + const analyticsRequest = await getAnalyticsRequest( + { ...config, isExtended: loadExtended }, + { + analyticsEngine, + nameProperty: displayNameProp, + engine, + } + ) const alerts = [] // Legend skeleton @@ -280,14 +287,22 @@ const loadEventLayer = async ({ if (eventClustering && !styleDataItem) { const response = await analyticsEngine.events.getCount(analyticsRequest) config.bounds = getBounds(response.extent) - config.serverCluster = shouldUseServerCluster( - response.count, - config.countFeaturesWithoutCoordinates, - config.countEventsOutsideOrgUnits - ) + config.serverCluster = config.forceClientCluster + ? false + : shouldUseServerCluster({ + count: response.count, + countFeaturesWithoutCoordinates: + config.countFeaturesWithoutCoordinates, + countEventsOutsideOrgUnits: config.countEventsOutsideOrgUnits, + spatialSupport, + }) serverCount = response.count } + // The extended (data table) dataset is only actually loaded below when + // server clustering isn't in effect - don't claim it's ready otherwise + config.isExtended = loadExtended && !config.serverCluster + // Load event data // ----- @@ -425,15 +440,20 @@ const loadEventLayer = async ({ const color = cssColor(eventPointColor) || EVENT_COLOR const strokeColor = getContrastColor(color) + let count = 0 + if (config.serverCluster) { + count = serverCount + } else if (Array.isArray(config?.data)) { + count = config.data.length + } + config.legend.items = [ { name: i18n.t('Event'), color, strokeColor, radius: eventPointRadius || EVENT_RADIUS, - count: - serverCount || - (Array.isArray(config?.data) ? config.data.length : 0), + count, }, ] } diff --git a/src/loaders/externalLoader.js b/src/loaders/externalLoader.js index 0e031c9dde..1cac31e94d 100644 --- a/src/loaders/externalLoader.js +++ b/src/loaders/externalLoader.js @@ -1,3 +1,4 @@ +import { WARNING_EXTERNAL_LAYER_NOT_FOUND } from '../constants/alerts.js' import { EXTERNAL_LAYER } from '../constants/layers.js' import { parseLayerConfig } from '../util/external.js' import { getPredefinedLegendItems } from '../util/legend.js' @@ -5,9 +6,17 @@ import { LEGEND_SET_QUERY } from '../util/requests.js' const externalLoader = async ({ config: layer, engine }) => { let config + const alerts = [] if (typeof layer.config === 'string') { // External layer is loaded in analytical object - config = await parseLayerConfig(layer.config, engine) + const parsed = await parseLayerConfig(layer.config, engine) + config = parsed.config + if (parsed.notFound) { + alerts.push({ + code: WARNING_EXTERNAL_LAYER_NOT_FOUND, + message: layer.name, + }) + } } else { config = { ...layer.config } } @@ -37,6 +46,7 @@ const externalLoader = async ({ config: layer, engine }) => { isLoaded: true, isLoading: false, isExpanded: true, + ...(alerts.length ? { alerts } : {}), } } diff --git a/src/loaders/geoJsonUrlLoader.js b/src/loaders/geoJsonUrlLoader.js index ed26dbce8a..ee372c35c1 100644 --- a/src/loaders/geoJsonUrlLoader.js +++ b/src/loaders/geoJsonUrlLoader.js @@ -1,4 +1,5 @@ import i18n from '@dhis2/d2-i18n' +import { WARNING_EXTERNAL_LAYER_NOT_FOUND } from '../constants/alerts.js' import { parseLayerConfig } from '../util/external.js' import { buildGeoJsonFeatures, @@ -7,6 +8,18 @@ import { GEO_TYPE_POLYGON, } from '../util/geojson.js' +// Stamps each feature with its geometry type's legend color, unless the feature already has its own +// (maps-gl's colorExpr prefers a per-feature color, so the data table must match). +export const stampFeatureColors = (features, legendItemsByType) => + features.map((f) => { + if (f.properties.color != null) { + return f + } + const nonMultiType = f.geometry.type.replaceAll('Multi', '') + const color = legendItemsByType[nonMultiType]?.color + return color ? { ...f, properties: { ...f.properties, color } } : f + }) + const fetchData = async (url, engine, baseUrl) => { if (url.includes(baseUrl)) { // API route, use engine @@ -60,10 +73,18 @@ const geoJsonUrlLoader = async ({ let newConfig let featureStyle let dataTableColumnConfig + const alerts = [] // keep featureStyle and dataTableColumnConfig properties outside of config while in app if (typeof config === 'string') { // External layer is loaded in analytical object - newConfig = await parseLayerConfig(config, engine) + const parsed = await parseLayerConfig(config, engine) + newConfig = parsed.config + if (parsed.notFound) { + alerts.push({ + code: WARNING_EXTERNAL_LAYER_NOT_FOUND, + message: layer.name, + }) + } featureStyle = { ...newConfig.featureStyle } || EMPTY_FEATURE_STYLE dataTableColumnConfig = newConfig.dataTableColumnConfig delete newConfig.featureStyle @@ -92,9 +113,9 @@ const geoJsonUrlLoader = async ({ } if (!loadError) { const { featureCollection, types } = buildGeoJsonFeatures(geoJson) - data = featureCollection const oneType = types.length === 1 + const legendItemsByType = {} types.forEach((type) => { let legendItem @@ -122,7 +143,10 @@ const geoJsonUrlLoader = async ({ } } legend.items.push(legendItem) + legendItemsByType[type] = legendItem }) + + data = stampFeatureColors(featureCollection, legendItemsByType) } return { @@ -138,6 +162,7 @@ const geoJsonUrlLoader = async ({ isLoading: false, isExpanded: true, loadError, + ...(alerts.length ? { alerts } : {}), } } diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js index 1e3dba4a08..fbe8feac80 100644 --- a/src/loaders/thematicLoader.js +++ b/src/loaders/thematicLoader.js @@ -179,6 +179,7 @@ const thematicLoader = async ({ legend: null, isLoaded: true, isLoading: false, + isExpanded: true, loadError, } } @@ -544,6 +545,10 @@ const thematicLoader = async ({ isNoData, isUnclassified, }), + ...getFeatureLegend(legendItem, { + isNoData, + isUnclassified, + }), ...getFeatureRadius( legendItem, { isNoData, isUnclassified }, diff --git a/src/loaders/trackedEntityLoader.js b/src/loaders/trackedEntityLoader.js index 0a30a3bdce..b50e5b8e51 100644 --- a/src/loaders/trackedEntityLoader.js +++ b/src/loaders/trackedEntityLoader.js @@ -8,7 +8,9 @@ import { TEI_RELATIONSHIP_LINE_COLOR, } from '../constants/layers.js' import { getProgramStatuses } from '../constants/programStatuses.js' +import { numberValueTypes } from '../constants/valueTypes.js' import { getOrgUnitsFromRows } from '../util/analytics.js' +import { parseJsonConfig } from '../util/config.js' import { GEO_TYPE_POINT, GEO_TYPE_POLYGON, @@ -16,10 +18,11 @@ import { GEO_TYPE_LINE, GEO_TYPE_FEATURE, } from '../util/geojson.js' +import { parseWithSeparator } from '../util/numbers.js' import { getDataWithRelationships } from '../util/teiRelationshipsParser.js' import { trimTime, formatStartEndDate, getDateArray } from '../util/time.js' -const fields = ['trackedEntity~rename(id)', 'geometry'] +const fields = ['trackedEntity~rename(id)', 'geometry', 'attributes'] // Valid geometry types for TEIs const teiGeometryTypes = new Set([ @@ -100,41 +103,62 @@ const TRACKED_ENTITY_TYPES_QUERY = { }, } -const toGeoJson = (instances) => - instances.map(({ id, geometry }) => ({ +export const getAttributeProperties = (attributes) => + Object.fromEntries( + (attributes ?? []).map(({ attribute, value, valueType }) => [ + attribute, + numberValueTypes.includes(valueType) + ? parseWithSeparator(value) + : value, + ]) + ) + +export const getAttributeHeaders = (instances) => { + const headersByAttribute = new Map() + instances.forEach(({ attributes }) => { + ;(attributes ?? []).forEach(({ attribute, displayName, valueType }) => { + if (!headersByAttribute.has(attribute)) { + headersByAttribute.set(attribute, { + name: displayName, + dataKey: attribute, + valueType, + }) + } + }) + }) + return [...headersByAttribute.values()] +} + +// The main tracked entity marker's own color is currently fixed still +// stamped here for when data table's Color column has real data +export const toGeoJson = (instances, color) => + instances.map(({ id, geometry, attributes }) => ({ type: GEO_TYPE_FEATURE, geometry, properties: { id, + color, + ...getAttributeProperties(attributes), }, })) -export const parseJsonConfig = (config) => { - if (!config.config || typeof config.config !== 'string') { - return +export const applyParsedConfig = (config) => { + const { relationships, periodType, dataTableColumnConfig } = + parseJsonConfig(config.config) + + if (relationships) { + config.relationshipType = relationships.type + config.relatedPointColor = relationships.pointColor + config.relatedPointRadius = relationships.pointRadius + config.relationshipLineColor = relationships.lineColor + config.relationshipOutsideProgram = + relationships.relationshipOutsideProgram } - try { - const { relationships, periodType, dataTableColumnConfig } = JSON.parse( - config.config - ) - - if (relationships) { - config.relationshipType = relationships.type - config.relatedPointColor = relationships.pointColor - config.relatedPointRadius = relationships.pointRadius - config.relationshipLineColor = relationships.lineColor - config.relationshipOutsideProgram = - relationships.relationshipOutsideProgram - } - - config.periodType = periodType + config.periodType = periodType - if (dataTableColumnConfig) { - config.dataTableColumnConfig = dataTableColumnConfig - } - } catch (e) { - // Malformed config JSON + if (dataTableColumnConfig) { + config.dataTableColumnConfig = dataTableColumnConfig } delete config.config @@ -150,6 +174,7 @@ const fetchRelationshipData = async ({ relatedPointColor, relatedPointRadius, relationshipLineColor, + pointColor, legend, }) => { const { relationshipType } = await engine.query( @@ -198,7 +223,7 @@ const fetchRelationshipData = async ({ }) return { - data: toGeoJson(dataWithRels.primary), + data: toGeoJson(dataWithRels.primary, pointColor), relationships: dataWithRels.relationships, secondaryData: toGeoJson(dataWithRels.secondary), } @@ -244,7 +269,7 @@ const trackedEntityLoader = async ({ keyAnalysisDigitGroupSeparator, serverVersion, }) => { - parseJsonConfig(config) + applyParsedConfig(config) const { trackedEntityType, @@ -266,6 +291,7 @@ const trackedEntityLoader = async ({ } = config const name = program ? program.name : i18n.t('Tracked entity') + const pointColor = eventPointColor || TEI_COLOR const legend = { title: name, @@ -278,7 +304,7 @@ const trackedEntityLoader = async ({ name: trackedEntityType.name + (areaRadius ? ` + ${areaRadius} ${'m'} ${'buffer'}` : ''), - color: eventPointColor || TEI_COLOR, + color: pointColor, radius: eventPointRadius || TEI_RADIUS, }, ], @@ -326,6 +352,8 @@ const trackedEntityLoader = async ({ instance.geometry?.coordinates ) + const headers = getAttributeHeaders(instances) + let alert if (!instances.length) { @@ -348,10 +376,11 @@ const trackedEntityLoader = async ({ relatedPointColor, relatedPointRadius, relationshipLineColor, + pointColor, legend, })) } else { - data = toGeoJson(instances) + data = toGeoJson(instances, pointColor) } if (explanation) { @@ -362,6 +391,7 @@ const trackedEntityLoader = async ({ ...config, name, data, + headers, keyAnalysisDigitGroupSeparator, relationships, secondaryData, diff --git a/src/reducers/__tests__/map.spec.js b/src/reducers/__tests__/map.spec.js index 1c70810eab..24087e96df 100644 --- a/src/reducers/__tests__/map.spec.js +++ b/src/reducers/__tests__/map.spec.js @@ -334,6 +334,56 @@ describe('map reducer - per-layer delegation', () => { }) expect(result.mapViews[1]).toBe(other) }) + + it("keeps the live dataTableColumnConfig/dataFilters instead of an async loader payload's stale snapshot", () => { + const state = { + ...defaultState, + mapViews: [ + { + id: 'layer1', + name: 'Old', + dataTableColumnConfig: { visibleKeys: ['name'] }, + dataFilters: { name: 'foo' }, + }, + ], + } + + const result = map(state, { + type: types.LAYER_UPDATE, + payload: { + id: 'layer1', + name: 'New', + // Stale: captured before the user's edits above + dataTableColumnConfig: undefined, + dataFilters: undefined, + }, + }) + + expect(result.mapViews[0].dataTableColumnConfig).toEqual({ + visibleKeys: ['name'], + }) + expect(result.mapViews[0].dataFilters).toEqual({ name: 'foo' }) + }) + + it("uses the payload's dataTableColumnConfig/dataFilters when the layer has none live yet (first load)", () => { + const state = { + ...defaultState, + mapViews: [{ id: 'layer1', name: 'Old' }], + } + + const result = map(state, { + type: types.LAYER_UPDATE, + payload: { + id: 'layer1', + name: 'New', + dataTableColumnConfig: { visibleKeys: ['id'] }, + }, + }) + + expect(result.mapViews[0].dataTableColumnConfig).toEqual({ + visibleKeys: ['id'], + }) + }) }) describe('LAYER_EDIT', () => { @@ -406,6 +456,24 @@ describe('map reducer - per-layer delegation', () => { }) }) + describe('LAYER_FORCE_CLIENT_CLUSTER_SET', () => { + it('sets forceClientCluster on the matching layer only', () => { + const other = { id: 'layer2' } + const state = { + ...defaultState, + mapViews: [{ id: 'layer1' }, other], + } + + const result = map(state, { + type: types.LAYER_FORCE_CLIENT_CLUSTER_SET, + id: 'layer1', + }) + + expect(result.mapViews[0].forceClientCluster).toBe(true) + expect(result.mapViews[1]).toBe(other) + }) + }) + describe('LAYER_TOGGLE_EXPAND', () => { it('toggles isExpanded on the matching layer only', () => { const other = { id: 'layer2', isExpanded: true } diff --git a/src/reducers/__tests__/ui.spec.js b/src/reducers/__tests__/ui.spec.js index e3e53fb768..b0025f4354 100644 --- a/src/reducers/__tests__/ui.spec.js +++ b/src/reducers/__tests__/ui.spec.js @@ -81,3 +81,19 @@ describe('ui reducer — lastClickedFeature', () => { expect(state.lastClickedFeature).toBe(null) }) }) + +describe('ui reducer — activeTimelinePeriod', () => { + it('defaults to null', () => { + expect(ui(undefined, {}).activeTimelinePeriod).toBe(null) + }) + + it('sets the active timeline period on ACTIVE_TIMELINE_PERIOD_SET', () => { + const period = { id: '202301', name: 'January 2023' } + const state = ui(undefined, { + type: types.ACTIVE_TIMELINE_PERIOD_SET, + period, + }) + + expect(state.activeTimelinePeriod).toEqual(period) + }) +}) diff --git a/src/reducers/map.js b/src/reducers/map.js index d32818a36d..a4167a9314 100644 --- a/src/reducers/map.js +++ b/src/reducers/map.js @@ -90,6 +90,10 @@ const layer = (state, action) => { return { ...action.payload, + dataTableColumnConfig: + state.dataTableColumnConfig ?? + action.payload.dataTableColumnConfig, + dataFilters: state.dataFilters ?? action.payload.dataFilters, } case types.LAYER_CHANGE_OPACITY: @@ -122,6 +126,16 @@ const layer = (state, action) => { isVisible: !state.isVisible, } + case types.LAYER_FORCE_CLIENT_CLUSTER_SET: + if (state.id !== action.id) { + return state + } + + return { + ...state, + forceClientCluster: true, + } + case types.LAYER_TOGGLE_EXPAND: if (state.id !== action.id) { return state @@ -318,6 +332,7 @@ const map = (state = defaultState, action) => { case types.LAYER_CHANGE_OPACITY: case types.LAYER_TOGGLE_VISIBILITY: case types.LAYER_TOGGLE_EXPAND: + case types.LAYER_FORCE_CLIENT_CLUSTER_SET: case types.DATA_FILTER_SET: case types.DATA_FILTER_CLEAR: case types.DATA_FILTERS_CLEAR_ALL: diff --git a/src/reducers/ui.js b/src/reducers/ui.js index 45fb37be3c..5789ed6e57 100644 --- a/src/reducers/ui.js +++ b/src/reducers/ui.js @@ -14,6 +14,7 @@ const defaultState = { selectionFilter: [], highlightColor: null, lastClickedFeature: null, + activeTimelinePeriod: null, } const ui = (state = defaultState, action) => { @@ -121,6 +122,12 @@ const ui = (state = defaultState, action) => { lastClickedFeature: action.payload, } + case types.ACTIVE_TIMELINE_PERIOD_SET: + return { + ...state, + activeTimelinePeriod: action.period, + } + default: return state } diff --git a/src/util/__tests__/app.spec.js b/src/util/__tests__/app.spec.js index 305cde18f1..6a88ec9f3b 100644 --- a/src/util/__tests__/app.spec.js +++ b/src/util/__tests__/app.spec.js @@ -114,6 +114,7 @@ describe('utils/app - providerDataTransformation', () => { } const systemInfo = { calendar: 'gregory', + databaseInfo: { spatialSupport: true }, } const cfg = await providerDataTransformation({ @@ -124,6 +125,7 @@ describe('utils/app - providerDataTransformation', () => { systemInfo, }) + expect(cfg.spatialSupport).toBe(true) expect(cfg.basemaps).toHaveLength(12) expect(cfg.nameProperty).toEqual('displayName') expect(cfg.defaultLayerSources).toHaveLength(6) @@ -180,6 +182,7 @@ describe('utils/app - providerDataTransformation', () => { systemInfo, }) + expect(cfg.spatialSupport).toBeUndefined() expect(cfg.basemaps).toHaveLength(8) expect(cfg.nameProperty).toEqual('displayShortName') expect(cfg.defaultLayerSources).toHaveLength(6) diff --git a/src/util/__tests__/dataTable.spec.js b/src/util/__tests__/dataTable.spec.js index 94d7c6026d..27484a9981 100644 --- a/src/util/__tests__/dataTable.spec.js +++ b/src/util/__tests__/dataTable.spec.js @@ -1,7 +1,10 @@ import { + buildFeatureIndex, getNextSorting, + getPanelHeights, getRowClickAction, getRowId, + hasActiveDataTableFilters, isFilterable, shouldClearFeatureHighlight, } from '../dataTable.js' @@ -129,3 +132,118 @@ describe('isFilterable', () => { expect(isFilterable('someKey', undefined)).toBe(false) }) }) + +describe('hasActiveDataTableFilters', () => { + const empty = { + dataFilters: {}, + globalSearch: '', + selectionFilter: [], + showOnlyFeaturesInView: false, + } + + test('is false when nothing is filtered', () => { + expect(hasActiveDataTableFilters(empty)).toBe(false) + }) + + test('is true when a column filter is set', () => { + expect( + hasActiveDataTableFilters({ + ...empty, + dataFilters: { name: 'foo' }, + }) + ).toBe(true) + }) + + test('is true for a non-blank global search, trimmed', () => { + expect( + hasActiveDataTableFilters({ ...empty, globalSearch: ' ' }) + ).toBe(false) + expect( + hasActiveDataTableFilters({ ...empty, globalSearch: ' foo ' }) + ).toBe(true) + }) + + test('is true when a selection filter is applied', () => { + expect( + hasActiveDataTableFilters({ + ...empty, + selectionFilter: ['selected'], + }) + ).toBe(true) + }) + + test('is true when showOnlyFeaturesInView is on, even with nothing else set', () => { + expect( + hasActiveDataTableFilters({ + ...empty, + showOnlyFeaturesInView: true, + }) + ).toBe(true) + }) +}) + +describe('buildFeatureIndex', () => { + test('indexes features by properties.id when present', () => { + const data = [{ properties: { id: 'a' } }, { properties: { id: 'b' } }] + const index = buildFeatureIndex(data) + expect(index.get('a')).toBe(data[0]) + expect(index.get('b')).toBe(data[1]) + }) + + test('falls back to the feature’s own top-level id', () => { + const feature = { id: 'a', properties: {} } + expect(buildFeatureIndex([feature]).get('a')).toBe(feature) + }) + + test('skips features with no id anywhere', () => { + const index = buildFeatureIndex([{ properties: {} }]) + expect(index.size).toBe(0) + }) + + test('returns an empty index for missing/empty data', () => { + expect(buildFeatureIndex(undefined).size).toBe(0) + expect(buildFeatureIndex([]).size).toBe(0) + }) +}) + +describe('getPanelHeights', () => { + test('clamps the table height to the window, minus header/toolbar', () => { + const result = getPanelHeights({ + windowHeight: 800, + dataTableHeight: 1000, + isCollapsed: false, + headerHeight: 50, + toolbarHeight: 50, + controlsHeight: 32, + }) + expect(result).toEqual({ + maxHeight: 700, + collapsedHeight: 32, + displayHeight: 700, + }) + }) + + test('uses the saved height as-is when it already fits', () => { + const result = getPanelHeights({ + windowHeight: 800, + dataTableHeight: 300, + isCollapsed: false, + headerHeight: 50, + toolbarHeight: 50, + controlsHeight: 32, + }) + expect(result.displayHeight).toBe(300) + }) + + test('collapses to just the controls height, regardless of the saved height', () => { + const result = getPanelHeights({ + windowHeight: 800, + dataTableHeight: 300, + isCollapsed: true, + headerHeight: 50, + toolbarHeight: 50, + controlsHeight: 32, + }) + expect(result.displayHeight).toBe(32) + }) +}) diff --git a/src/util/__tests__/external.spec.js b/src/util/__tests__/external.spec.js index 3089e956e0..30f850280c 100644 --- a/src/util/__tests__/external.spec.js +++ b/src/util/__tests__/external.spec.js @@ -9,6 +9,7 @@ import { import { createExternalBasemapLayer, createExternalOverlayLayer, + parseLayerConfig, } from '../external.js' describe('createExternalBasemapLayer', () => { @@ -254,3 +255,68 @@ describe('createExternalOverlayLayer', () => { }) }) }) + +describe('parseLayerConfig', () => { + test('returns an empty config instead of throwing on malformed JSON', async () => { + await expect(parseLayerConfig('not-valid-json', {})).resolves.toEqual({ + config: {}, + }) + }) + + test('does not throw when the JSON parses to null', async () => { + await expect(parseLayerConfig('null', {})).resolves.toEqual({ + config: null, + }) + }) + + test('returns the local config unchanged when it has no id (nothing to refresh)', async () => { + const config = { url: 'https://path-to-geojson', name: 'Local' } + await expect( + parseLayerConfig(JSON.stringify(config), {}) + ).resolves.toEqual({ config }) + }) + + test('returns a freshly-fetched config on success, carrying featureStyle/dataTableColumnConfig forward', async () => { + const localConfig = { + id: 'ext-1', + featureStyle: { color: '#ff0000' }, + dataTableColumnConfig: { visibleKeys: ['name'] }, + } + const engine = { + query: jest.fn().mockResolvedValue({ + externalLayer: { + id: 'ext-1', + name: 'Fresh name', + url: 'https://fresh-url', + mapService: 'XYZ', + imageFormat: 'PNG', + }, + }), + } + + const result = await parseLayerConfig( + JSON.stringify(localConfig), + engine + ) + + expect(result.notFound).toBeUndefined() + expect(result.config).toMatchObject({ + id: 'ext-1', + name: 'Fresh name', + url: 'https://fresh-url', + featureStyle: { color: '#ff0000' }, + dataTableColumnConfig: { visibleKeys: ['name'] }, + }) + }) + + test('falls back to the local config and flags notFound when the API fetch fails', async () => { + const localConfig = { id: 'deleted-layer', url: 'https://stale-url' } + const engine = { + query: jest.fn().mockRejectedValue(new Error('404')), + } + + await expect( + parseLayerConfig(JSON.stringify(localConfig), engine) + ).resolves.toEqual({ config: localConfig, notFound: true }) + }) +}) diff --git a/src/util/__tests__/favorites.spec.js b/src/util/__tests__/favorites.spec.js index 92d9d97642..e1fdcb9f17 100644 --- a/src/util/__tests__/favorites.spec.js +++ b/src/util/__tests__/favorites.spec.js @@ -501,6 +501,26 @@ describe('cleanMapConfig', () => { ) }) + test('excludes forceClientCluster (session-only, not a valid layer property)', () => { + const cleanedConfig = cleanMapConfig({ + config: { + mapViews: [ + { + layer: 'event', + name: 'Event layer', + opacity: 1, + serverCluster: true, + forceClientCluster: true, + }, + ], + }, + defaultBasemapId: 'thedefaultBasemap', + }) + expect(cleanedConfig.mapViews[0]).not.toHaveProperty( + 'forceClientCluster' + ) + }) + test('writes hidden: true for a layer with isVisible: false', () => { const cleanedConfig = cleanMapConfig({ config: { diff --git a/src/util/__tests__/filter.spec.js b/src/util/__tests__/filter.spec.js index 9f376df8ea..5dc7069838 100644 --- a/src/util/__tests__/filter.spec.js +++ b/src/util/__tests__/filter.spec.js @@ -1,4 +1,8 @@ -import { SENTINEL_ANY_VALUE } from '../../constants/dataTable.js' +import { + SENTINEL_ANY_VALUE, + SENTINEL_NO_VALUE, + DATE_GROUPS_GRANULARITY, +} from '../../constants/dataTable.js' import { filterByGlobalSearch, filterData } from '../filter.js' describe('filterData', () => { @@ -109,6 +113,96 @@ describe('filterData', () => { const filters = { a: [SENTINEL_ANY_VALUE, ''] } expect(filterData(data, filters)).toEqual(data) }) + + describe('date-group filter ({ granularity, prefixes })', () => { + const data = [ + { a: '2023-05-15 00:00:00.0' }, + { a: '2023-05-16 03:00:00.0' }, + { a: '2024-01-01 00:00:00.0' }, + { a: null }, + ] + + it('matches every row under a single year prefix', () => { + const filters = { + a: { granularity: DATE_GROUPS_GRANULARITY, prefixes: ['2023'] }, + } + expect(filterData(data, filters)).toEqual([ + { a: '2023-05-15 00:00:00.0' }, + { a: '2023-05-16 03:00:00.0' }, + ]) + }) + + it('matches only the selected day prefix', () => { + const filters = { + a: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: ['2023-05-16'], + }, + } + expect(filterData(data, filters)).toEqual([ + { a: '2023-05-16 03:00:00.0' }, + ]) + }) + + it('ORs across prefixes of different granularities', () => { + const filters = { + a: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: ['2023-05-15', '2024'], + }, + } + expect(filterData(data, filters)).toEqual([ + { a: '2023-05-15 00:00:00.0' }, + { a: '2024-01-01 00:00:00.0' }, + ]) + }) + + it('does not treat an empty prefix list as "match nothing" (mirrors the empty-array convention: match everything)', () => { + const filters = { + a: { granularity: DATE_GROUPS_GRANULARITY, prefixes: [] }, + } + expect(filterData(data, filters)).toEqual(data) + }) + + it('SENTINEL_NO_VALUE only matches null/missing values, never startsWith("")-matching everything', () => { + const filters = { + a: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: [SENTINEL_NO_VALUE], + }, + } + expect(filterData(data, filters)).toEqual([{ a: null }]) + }) + + it('SENTINEL_ANY_VALUE matches every non-blank value', () => { + const filters = { + a: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: [SENTINEL_ANY_VALUE], + }, + } + expect(filterData(data, filters)).toEqual([ + { a: '2023-05-15 00:00:00.0' }, + { a: '2023-05-16 03:00:00.0' }, + { a: '2024-01-01 00:00:00.0' }, + ]) + }) + + it('does not throw and combines (AND) correctly with an unrelated string filter on another field', () => { + const mixedData = [ + { a: '2023-05-15 00:00:00.0', b: 'apple' }, + { a: '2023-05-16 00:00:00.0', b: 'banana' }, + { a: '2024-01-01 00:00:00.0', b: 'apple' }, + ] + const filters = { + a: { granularity: DATE_GROUPS_GRANULARITY, prefixes: ['2023'] }, + b: 'apple', + } + expect(filterData(mixedData, filters)).toEqual([ + { a: '2023-05-15 00:00:00.0', b: 'apple' }, + ]) + }) + }) }) describe('filterByGlobalSearch', () => { diff --git a/src/util/__tests__/filterInput.spec.js b/src/util/__tests__/filterInput.spec.js index 48c894fa6c..62440eb572 100644 --- a/src/util/__tests__/filterInput.spec.js +++ b/src/util/__tests__/filterInput.spec.js @@ -1,9 +1,12 @@ import { + getCyclicIndex, getDisplayValue, getFilteredOptions, getPopoverWidth, getSelectedAndAppliedString, measureMaxTextWidth, + toHighlightedIndex, + toOptionIndex, } from '../filterInput.js' describe('getSelectedAndAppliedString', () => { @@ -136,3 +139,37 @@ describe('getPopoverWidth', () => { expect(getPopoverWidth(100)).toBe(156) }) }) + +describe('getCyclicIndex', () => { + it('moves forward within range', () => { + expect(getCyclicIndex(0, 3, 1)).toBe(1) + }) + + it('wraps from the last index back to the first when moving forward', () => { + expect(getCyclicIndex(2, 3, 1)).toBe(0) + }) + + it('moving backward from -1 (nothing highlighted) lands on index 1, matching the pre-existing arithmetic', () => { + expect(getCyclicIndex(-1, 3, -1)).toBe(1) + }) + + it('moves backward within range', () => { + expect(getCyclicIndex(2, 3, -1)).toBe(1) + }) + + it('returns -1 when there is nothing to highlight', () => { + expect(getCyclicIndex(0, 0, 1)).toBe(-1) + }) +}) + +describe('toOptionIndex / toHighlightedIndex', () => { + it('are unchanged when the custom-filter row is not shown', () => { + expect(toOptionIndex(2, false)).toBe(2) + expect(toHighlightedIndex(2, false)).toBe(2) + }) + + it('are offset by one, and invert each other, when the custom-filter row is shown', () => { + expect(toOptionIndex(1, true)).toBe(0) + expect(toHighlightedIndex(0, true)).toBe(1) + }) +}) diff --git a/src/util/__tests__/orgUnits.spec.js b/src/util/__tests__/orgUnits.spec.js index 3a7f54155b..55362e6e8d 100644 --- a/src/util/__tests__/orgUnits.spec.js +++ b/src/util/__tests__/orgUnits.spec.js @@ -358,6 +358,7 @@ describe('getStyledOrgUnits', () => { expect(result.legend.items).toContainEqual( expect.objectContaining({ name: 'Unclassified', color: '#cccccc' }) ) + expect(result.styledFeatures[0].properties.group).toBe('Group1') }) it('should include unclassified orgunit with unclassifiedLegend color when set', () => { diff --git a/src/util/__tests__/tableColumns.spec.js b/src/util/__tests__/tableColumns.spec.js index 77db56a701..39efc86bd9 100644 --- a/src/util/__tests__/tableColumns.spec.js +++ b/src/util/__tests__/tableColumns.spec.js @@ -1,9 +1,16 @@ +import { SENTINEL_NO_VALUE, TYPE_NUMBER } from '../../constants/dataTable.js' import { + buildRowCells, + filterHeadersByName, + getColumnDistinctValues, + getDefaultVisibleKeys, + getOrderedHeaders, getPinnedCellProps, getPinnedCount, getPinnedLeftOffsets, getVisibleHeaders, isPinnedGroupEnd, + reorderHeaderKeys, reverseVisibleKeys, togglePinnedKey, toggleVisibleKey, @@ -16,6 +23,38 @@ const headers = [ { name: 'Legend', dataKey: 'legend' }, ] +describe('getOrderedHeaders', () => { + it('returns every header, ordered/pinned but never filtered by visibility - even defaultHidden ones', () => { + const withHiddenColumn = [ + ...headers, + { + name: 'Value (Jan 2023)', + dataKey: 'period_202301_rawValue', + defaultHidden: true, + }, + ] + const result = getOrderedHeaders(withHiddenColumn, {}) + expect(result).toEqual(withHiddenColumn) + }) + + it('still applies ordering and pinning', () => { + const result = getOrderedHeaders(headers, { + orderedKeys: ['legend', 'name', 'id', 'rawValue'], + pinnedKeys: ['rawValue'], + }) + expect(result.map((h) => h.dataKey)).toEqual([ + 'rawValue', + 'legend', + 'name', + 'id', + ]) + }) + + it('passes through a null/undefined headers list', () => { + expect(getOrderedHeaders(null)).toBe(null) + }) +}) + describe('getVisibleHeaders', () => { it('returns all headers unchanged when there is no saved config', () => { expect(getVisibleHeaders(headers, null)).toEqual(headers) @@ -124,6 +163,42 @@ describe('getVisibleHeaders', () => { 'name', ]) }) + + it('excludes defaultHidden headers when there is no saved config yet', () => { + const withPeriodColumn = [ + ...headers, + { + name: 'Value (Jan 2023)', + dataKey: 'period_202301_rawValue', + defaultHidden: true, + }, + ] + const result = getVisibleHeaders(withPeriodColumn, null) + expect(result.map((h) => h.dataKey)).toEqual([ + 'name', + 'id', + 'rawValue', + 'legend', + ]) + }) + + it('shows a defaultHidden header once explicitly added to visibleKeys', () => { + const withPeriodColumn = [ + ...headers, + { + name: 'Value (Jan 2023)', + dataKey: 'period_202301_rawValue', + defaultHidden: true, + }, + ] + const result = getVisibleHeaders(withPeriodColumn, { + visibleKeys: ['name', 'period_202301_rawValue'], + }) + expect(result.map((h) => h.dataKey)).toEqual([ + 'name', + 'period_202301_rawValue', + ]) + }) }) describe('getPinnedLeftOffsets', () => { @@ -301,3 +376,123 @@ describe('getPinnedCellProps', () => { }) }) }) + +describe('getDefaultVisibleKeys', () => { + it('includes every header dataKey when none are marked defaultHidden', () => { + expect(getDefaultVisibleKeys(headers)).toEqual([ + 'name', + 'id', + 'rawValue', + 'legend', + ]) + }) + + it('excludes headers marked defaultHidden', () => { + const withHidden = [ + ...headers, + { + name: 'Value (Jan 2023)', + dataKey: 'period_202301_rawValue', + defaultHidden: true, + }, + ] + expect(getDefaultVisibleKeys(withHidden)).toEqual([ + 'name', + 'id', + 'rawValue', + 'legend', + ]) + }) +}) + +describe('getColumnDistinctValues', () => { + const typedHeaders = [ + { dataKey: 'name', type: 'string' }, + { dataKey: 'rawValue', type: TYPE_NUMBER }, + ] + + it('returns null when there are no headers or no data yet', () => { + expect(getColumnDistinctValues([], [{ name: 'A' }])).toBe(null) + expect(getColumnDistinctValues(typedHeaders, [])).toBe(null) + }) + + it('collects the distinct string value of each column across all rows', () => { + const data = [ + { name: 'A', rawValue: 1 }, + { name: 'B', rawValue: 2 }, + { name: 'A', rawValue: 1 }, + ] + const result = getColumnDistinctValues(typedHeaders, data) + expect(result.name).toEqual({ values: ['A', 'B'], type: 'string' }) + expect(result.rawValue).toEqual({ + values: ['1', '2'], + type: TYPE_NUMBER, + }) + }) + + it('coalesces undefined/null/empty-string values to the sentinel and omits a column with none at all', () => { + const data = [{ name: '' }, { name: null }, { rawValue: 5 }] + const result = getColumnDistinctValues(typedHeaders, data) + expect(result.name.values).toEqual([SENTINEL_NO_VALUE]) + expect(result.rawValue.values).toEqual([SENTINEL_NO_VALUE, '5']) + }) +}) + +describe('buildRowCells', () => { + const rowHeaders = [ + { dataKey: 'name', type: 'string' }, + { dataKey: 'rawValue', type: TYPE_NUMBER }, + ] + + it('builds one cell per header, aligning numbers right and everything else left', () => { + const item = { id: 'a', name: 'Alpha', rawValue: 5 } + expect(buildRowCells(item, rowHeaders)).toEqual([ + { dataKey: 'name', value: 'Alpha', align: 'left', itemId: 'a' }, + { dataKey: 'rawValue', value: 5, align: 'right', itemId: 'a' }, + ]) + }) + + it('applies a column roundFn before returning the value', () => { + const item = { id: 'a', rawValue: 1.23456 } + const withRoundFn = [ + { dataKey: 'rawValue', type: TYPE_NUMBER, roundFn: Math.round }, + ] + expect(buildRowCells(item, withRoundFn)[0].value).toBe(1) + }) + + it('nulls out a non-numeric value in a number column instead of returning NaN', () => { + const item = { id: 'a', rawValue: 'not-a-number' } + expect(buildRowCells(item, rowHeaders)[1].value).toBe(null) + }) +}) + +describe('filterHeadersByName', () => { + it('keeps headers whose name contains the search text, case-insensitively', () => { + const result = filterHeadersByName(headers, 'AME') + expect(result.map((h) => h.dataKey)).toEqual(['name']) + }) + + it('trims the search text before matching', () => { + const result = filterHeadersByName(headers, ' id ') + expect(result.map((h) => h.dataKey)).toEqual(['id']) + }) + + it('returns every header when the search text is empty', () => { + expect(filterHeadersByName(headers, '')).toEqual(headers) + }) +}) + +describe('reorderHeaderKeys', () => { + it('moves the active header to the dropped-on header’s position', () => { + const result = reorderHeaderKeys(headers, 'name', 'legend') + expect(result).toEqual(['id', 'rawValue', 'legend', 'name']) + }) + + it('returns null when the active header can no longer be found', () => { + expect(reorderHeaderKeys(headers, 'deletedColumn', 'legend')).toBe(null) + }) + + it('returns null when the drop-target header can no longer be found', () => { + expect(reorderHeaderKeys(headers, 'name', 'deletedColumn')).toBe(null) + }) +}) diff --git a/src/util/__tests__/tableHeaders.spec.js b/src/util/__tests__/tableHeaders.spec.js new file mode 100644 index 0000000000..b130fe4d24 --- /dev/null +++ b/src/util/__tests__/tableHeaders.spec.js @@ -0,0 +1,313 @@ +import { RENDERER_DATE } from '../../constants/dataTable.js' +import { + EVENT_LAYER, + THEMATIC_LAYER, + ORG_UNIT_LAYER, + EARTH_ENGINE_LAYER, + FACILITY_LAYER, + GEOJSON_URL_LAYER, + TRACKED_ENTITY_LAYER, +} from '../../constants/layers.js' +import { + ERROR_NON_HOMOGENOUS_FEATURES, + getHeadersForLayer, + TYPE_NUMBER, + TYPE_STRING, + TYPE_DATE, + TYPE_DATETIME, + TYPE_TIME, +} from '../tableHeaders.js' + +jest.mock('../../components/map/MapApi.js', () => ({ + loadEarthEngineWorker: jest.fn(), +})) + +const dataKeys = (result) => result.headers.map((h) => h.dataKey) + +describe('getHeadersForLayer - thematic', () => { + test('single-period: fixed fields plus legend/range/color', () => { + const result = getHeadersForLayer(THEMATIC_LAYER, { + isMultiPeriodThematic: false, + }) + expect(dataKeys(result)).toEqual([ + 'name', + 'id', + 'rawValue', + 'level', + 'parentName', + 'type', + 'legend', + 'range', + 'color', + ]) + }) + + test('multi-period, non-timeline: org unit headers plus one column per other period', () => { + const periods = [ + { id: 'p1', name: 'Jan' }, + { id: 'p2', name: 'Feb' }, + ] + const result = getHeadersForLayer(THEMATIC_LAYER, { + isMultiPeriodThematic: true, + isTimelineThematic: false, + periods, + }) + expect(dataKeys(result)).toEqual( + expect.arrayContaining([ + 'name', + 'id', + 'level', + 'parentName', + 'type', + 'period_p1_rawValue', + 'period_p2_rawValue', + ]) + ) + }) + + test('multi-period timeline: excludes the external period from the extra columns and labels value/legend/range/color with it', () => { + const periods = [ + { id: 'p1', name: 'Jan' }, + { id: 'p2', name: 'Feb' }, + ] + const externalPeriod = { id: 'p1', name: 'Jan' } + const result = getHeadersForLayer(THEMATIC_LAYER, { + isMultiPeriodThematic: true, + isTimelineThematic: true, + periods, + externalPeriod, + }) + expect(dataKeys(result)).not.toContain('period_p1_rawValue') + expect(dataKeys(result)).toContain('period_p2_rawValue') + const valueHeader = result.headers.find((h) => h.dataKey === 'rawValue') + expect(valueHeader.name).toContain('Jan') + }) +}) + +describe('getHeadersForLayer - event', () => { + test('fixed org unit/id/eventdate fields plus valid-uid custom fields from layerHeaders', () => { + const layerHeaders = [ + { + name: 'w75KJ2mc4zz', + column: 'Age', + valueType: 'INTEGER', + }, + { name: 'not-a-uid', column: 'Ignored', valueType: 'TEXT' }, + ] + const result = getHeadersForLayer(EVENT_LAYER, { layerHeaders }) + expect(dataKeys(result)).toEqual( + expect.arrayContaining(['ouname', 'id', 'eventdate', 'w75KJ2mc4zz']) + ) + expect(dataKeys(result)).not.toContain('not-a-uid') + const ageHeader = result.headers.find( + (h) => h.dataKey === 'w75KJ2mc4zz' + ) + expect(ageHeader.type).toBe(TYPE_NUMBER) + const eventdateHeader = result.headers.find( + (h) => h.dataKey === 'eventdate' + ) + expect(eventdateHeader.type).toBe(TYPE_DATE) + }) + + test('custom DATE/DATETIME/TIME/AGE fields get their matching type, option-set-backed fields stay TYPE_STRING', () => { + const layerHeaders = [ + { name: 'w75KJ2mc4zz', column: 'Date of birth', valueType: 'DATE' }, + { + name: 'zDhUuAYrxNC', + column: 'Registered at', + valueType: 'DATETIME', + }, + { name: 'oZg33kd9taw', column: 'Visit time', valueType: 'TIME' }, + { name: 'a1b2c3d4e5f', column: 'Age', valueType: 'AGE' }, + { + name: 'b2c3d4e5f6a', + column: 'Gender', + valueType: 'TEXT', + optionSet: { id: 'os1' }, + }, + ] + const result = getHeadersForLayer(EVENT_LAYER, { layerHeaders }) + const headerFor = (dataKey) => + result.headers.find((h) => h.dataKey === dataKey) + const typeOf = (dataKey) => headerFor(dataKey).type + expect(typeOf('w75KJ2mc4zz')).toBe(TYPE_DATE) + expect(typeOf('zDhUuAYrxNC')).toBe(TYPE_DATETIME) + expect(typeOf('oZg33kd9taw')).toBe(TYPE_TIME) + expect(typeOf('a1b2c3d4e5f')).toBe(TYPE_DATE) + expect(typeOf('b2c3d4e5f6a')).toBe(TYPE_STRING) + expect(headerFor('w75KJ2mc4zz').renderer).toBe(RENDERER_DATE) + expect(headerFor('zDhUuAYrxNC').renderer).toBe(RENDERER_DATE) + expect(headerFor('oZg33kd9taw').renderer).toBe(RENDERER_DATE) + expect(headerFor('a1b2c3d4e5f').renderer).toBe(RENDERER_DATE) + expect(headerFor('b2c3d4e5f6a').renderer).toBeUndefined() + }) + + test('adds the org unit boundary column only when countEventsOutsideOrgUnits is set', () => { + const without = getHeadersForLayer(EVENT_LAYER, { layerHeaders: [] }) + const withBoundary = getHeadersForLayer(EVENT_LAYER, { + layerHeaders: [], + countEventsOutsideOrgUnits: true, + }) + expect(dataKeys(without)).not.toContain('ouBoundary') + expect(dataKeys(withBoundary)).toContain('ouBoundary') + }) + + test('adds legend/range/color only when styled by a data item', () => { + const unstyled = getHeadersForLayer(EVENT_LAYER, { layerHeaders: [] }) + const styled = getHeadersForLayer(EVENT_LAYER, { + layerHeaders: [], + styleDataItem: { id: 'abc' }, + }) + expect(dataKeys(unstyled)).not.toContain('color') + expect(dataKeys(styled)).toEqual( + expect.arrayContaining(['legend', 'range', 'color']) + ) + }) +}) + +describe('getHeadersForLayer - org unit / facility', () => { + test('org unit: fixed fields plus whichever style columns the data actually has', () => { + const result = getHeadersForLayer(ORG_UNIT_LAYER, { + data: [{ color: '#fff' }, { iconUrl: 'x.png' }], + }) + expect(dataKeys(result)).toEqual( + expect.arrayContaining([ + 'name', + 'id', + 'level', + 'parentName', + 'type', + 'color', + 'iconUrl', + ]) + ) + expect(dataKeys(result)).not.toContain('group') + }) + + test('facility: same style-detection behavior as org unit, with a smaller fixed field set', () => { + const result = getHeadersForLayer(FACILITY_LAYER, { + data: [{ group: 'g1' }], + }) + expect(dataKeys(result)).toEqual(['name', 'id', 'type', 'group']) + }) +}) + +describe('getHeadersForLayer - tracked entity', () => { + test('id field plus valid-uid custom fields from layerHeaders, always with a color column', () => { + const layerHeaders = [ + { name: 'First name', dataKey: 'w75KJ2mc4zz', valueType: 'TEXT' }, + { name: 'Bad', dataKey: 'not-a-uid', valueType: 'TEXT' }, + ] + const result = getHeadersForLayer(TRACKED_ENTITY_LAYER, { + layerHeaders, + }) + expect(dataKeys(result)).toEqual(['id', 'w75KJ2mc4zz', 'color']) + const nameHeader = result.headers.find( + (h) => h.dataKey === 'w75KJ2mc4zz' + ) + expect(nameHeader.type).toBe(TYPE_STRING) + }) + + test('custom DATE/DATETIME/TIME attributes get their matching type', () => { + const layerHeaders = [ + { + name: 'Date of birth', + dataKey: 'w75KJ2mc4zz', + valueType: 'DATE', + }, + { + name: 'Enrolled at', + dataKey: 'zDhUuAYrxNC', + valueType: 'DATETIME', + }, + { name: 'Visit time', dataKey: 'oZg33kd9taw', valueType: 'TIME' }, + ] + const result = getHeadersForLayer(TRACKED_ENTITY_LAYER, { + layerHeaders, + }) + const headerFor = (dataKey) => + result.headers.find((h) => h.dataKey === dataKey) + const typeOf = (dataKey) => headerFor(dataKey).type + expect(typeOf('w75KJ2mc4zz')).toBe(TYPE_DATE) + expect(typeOf('zDhUuAYrxNC')).toBe(TYPE_DATETIME) + expect(typeOf('oZg33kd9taw')).toBe(TYPE_TIME) + expect(headerFor('w75KJ2mc4zz').renderer).toBe(RENDERER_DATE) + expect(headerFor('zDhUuAYrxNC').renderer).toBe(RENDERER_DATE) + expect(headerFor('oZg33kd9taw').renderer).toBe(RENDERER_DATE) + }) +}) + +describe('getHeadersForLayer - earth engine', () => { + test('class-based aggregation: one column per legend item, rounded to 2 decimal places', () => { + const result = getHeadersForLayer(EARTH_ENGINE_LAYER, { + aggregationType: 'percentage', + legend: { + title: 'Land cover', + items: [{ value: 1, name: 'Forest' }], + }, + }) + expect(dataKeys(result)).toEqual( + expect.arrayContaining(['name', 'id', 'type', '1']) + ) + const classHeader = result.headers.find((h) => h.dataKey === '1') + expect(classHeader.name).toBe('Forest') + expect(classHeader.roundFn(1.23456)).toBe(1.23) + }) + + test('non-class aggregation array: one title-cased column per aggregation type', () => { + const result = getHeadersForLayer(EARTH_ENGINE_LAYER, { + aggregationType: ['mean'], + legend: { title: 'Rainfall', items: [] }, + data: [{ mean: 12.3456 }], + }) + const meanHeader = result.headers.find((h) => h.dataKey === 'mean') + expect(meanHeader.name).toBe('Mean Rainfall') + expect(meanHeader.type).toBe(TYPE_NUMBER) + }) +}) + +describe('getHeadersForLayer - geoJsonUrl', () => { + test('homogenous features: derives headers from the first feature', () => { + const rawData = [ + { + geometry: { type: 'Point' }, + properties: { name: 'A', color: '#f00' }, + }, + { + geometry: { type: 'Point' }, + properties: { name: 'B', color: '#0f0' }, + }, + ] + const result = getHeadersForLayer(GEOJSON_URL_LAYER, { rawData }) + expect(dataKeys(result)).toEqual( + expect.arrayContaining(['name', 'color']) + ) + }) + + test('non-homogenous geometry types: returns an error code instead of headers', () => { + const rawData = [ + { geometry: { type: 'Point' }, properties: {} }, + { geometry: { type: 'LineString' }, properties: {} }, + ] + const result = getHeadersForLayer(GEOJSON_URL_LAYER, { rawData }) + expect(result).toEqual({ errorCode: ERROR_NON_HOMOGENOUS_FEATURES }) + }) + + test('a Polygon/MultiPolygon mix is homogenous (matches the loader’s own Multi-normalization)', () => { + const rawData = [ + { geometry: { type: 'Polygon' }, properties: { name: 'A' } }, + { geometry: { type: 'MultiPolygon' }, properties: { name: 'B' } }, + ] + const result = getHeadersForLayer(GEOJSON_URL_LAYER, { rawData }) + expect(result.errorCode).toBeUndefined() + expect(dataKeys(result)).toEqual(expect.arrayContaining(['name'])) + }) +}) + +describe('getHeadersForLayer - unknown layer type', () => { + test('returns null headers rather than throwing', () => { + expect(getHeadersForLayer('somethingElse', {})).toEqual({ + headers: null, + }) + }) +}) diff --git a/src/util/__tests__/tableRows.spec.js b/src/util/__tests__/tableRows.spec.js new file mode 100644 index 0000000000..c66d39a5dd --- /dev/null +++ b/src/util/__tests__/tableRows.spec.js @@ -0,0 +1,226 @@ +import { + GEOJSON_URL_LAYER, + THEMATIC_LAYER, + TRACKED_ENTITY_LAYER, +} from '../../constants/layers.js' +import { buildTableData, ERROR_NO_VALID_DATA } from '../tableRows.js' + +const feature = (id, extraProperties = {}, coordinates = [10, 10]) => ({ + id, + geometry: { type: 'Point', coordinates }, + properties: { id, ...extraProperties }, +}) + +describe('buildTableData - error paths', () => { + test('server-clustered layers return empty data instead of an error', () => { + expect(buildTableData(THEMATIC_LAYER, { serverCluster: true })).toEqual( + { data: [] } + ) + }) + + test('no data and no dataWithoutCoords returns a no-valid-data error', () => { + expect( + buildTableData(THEMATIC_LAYER, { data: [], dataWithoutCoords: [] }) + ).toEqual({ errorCode: ERROR_NO_VALID_DATA }) + expect(buildTableData(THEMATIC_LAYER, {})).toEqual({ + errorCode: ERROR_NO_VALID_DATA, + }) + }) +}) + +describe('buildTableData - geoJsonUrl layer', () => { + test('returns each feature’s properties as a row, bypassing the hasAdditionalGeometry filter', () => { + const data = [ + feature('a', { name: 'A', hasAdditionalGeometry: true }), + feature('b', { name: 'B' }), + ] + const result = buildTableData(GEOJSON_URL_LAYER, { data }) + expect(result.data).toEqual([ + { id: 'a', name: 'A', hasAdditionalGeometry: true, index: 0 }, + { id: 'b', name: 'B', index: 1 }, + ]) + }) + + test('stamps a row-order index so clearing a sort restores natural order', () => { + const data = [feature('a'), feature('b'), feature('c')] + const result = buildTableData(GEOJSON_URL_LAYER, { data }) + expect(result.data.map((r) => r.index)).toEqual([0, 1, 2]) + }) +}) + +describe('buildTableData - showOnlyFeaturesInView', () => { + const inBounds = feature('in', {}, [10, 10]) + const outOfBounds = feature('out', {}, [100, 100]) + const bounds = [0, 0, 20, 20] + + test('keeps all features when showOnlyFeaturesInView is off', () => { + const result = buildTableData(THEMATIC_LAYER, { + data: [inBounds, outOfBounds], + showOnlyFeaturesInView: false, + mapBounds: bounds, + aggregations: {}, + }) + expect(result.data.map((r) => r.id)).toEqual(['in', 'out']) + }) + + test('filters out features outside the given bounds when showOnlyFeaturesInView is on', () => { + const result = buildTableData(THEMATIC_LAYER, { + data: [inBounds, outOfBounds], + showOnlyFeaturesInView: true, + mapBounds: bounds, + aggregations: {}, + }) + expect(result.data.map((r) => r.id)).toEqual(['in']) + }) +}) + +describe('buildTableData - generic layer', () => { + test('merges data and dataWithoutCoords, drops features with hasAdditionalGeometry, merges aggregations and stamps a row-order index', () => { + const data = [feature('a', { name: 'A' })] + const dataWithoutCoords = [ + feature('b', { name: 'B', hasAdditionalGeometry: true }), + feature('c', { name: 'C' }), + ] + const result = buildTableData(THEMATIC_LAYER, { + data, + dataWithoutCoords, + aggregations: { a: { count: 5 } }, + }) + expect(result.data).toEqual([ + { id: 'a', name: 'A', count: 5, index: 0 }, + { id: 'c', name: 'C', index: 1 }, + ]) + }) +}) + +describe('buildTableData - tracked entity layer', () => { + test('merges data and dataWithoutCoords, drops features with hasAdditionalGeometry, merges aggregations and stamps a row-order index', () => { + const data = [feature('a', { w75KJ2mc4zz: 'Gabrielle' })] + const dataWithoutCoords = [ + feature('b', { + w75KJ2mc4zz: 'Hidden', + hasAdditionalGeometry: true, + }), + feature('c', { w75KJ2mc4zz: 'Charlie' }), + ] + const result = buildTableData(TRACKED_ENTITY_LAYER, { + data, + dataWithoutCoords, + aggregations: { a: { count: 5 } }, + }) + expect(result.data).toEqual([ + { id: 'a', w75KJ2mc4zz: 'Gabrielle', count: 5, index: 0 }, + { id: 'c', w75KJ2mc4zz: 'Charlie', index: 1 }, + ]) + }) + + test('filters out-of-view features when showOnlyFeaturesInView is on', () => { + const inBounds = feature('in', {}, [10, 10]) + const outOfBounds = feature('out', {}, [100, 100]) + const result = buildTableData(TRACKED_ENTITY_LAYER, { + data: [inBounds, outOfBounds], + showOnlyFeaturesInView: true, + mapBounds: [0, 0, 20, 20], + aggregations: {}, + }) + expect(result.data.map((r) => r.id)).toEqual(['in']) + }) +}) + +describe('buildTableData - styled event layer', () => { + test('derives legend name and a formatted range from the matching legend item', () => { + const data = [feature('a', { colorGroup: 0 })] + const legend = { + items: { + 0: { name: 'Low', startValue: 0, endValue: 10 }, + }, + } + const result = buildTableData('event', { + data, + aggregations: {}, + isStyledEvent: true, + legend, + keyAnalysisDigitGroupSeparator: 'SPACE', + }) + expect(result.data[0].legend).toBe('Low') + expect(result.data[0].range).toBe('0 – 10') + }) + + test('leaves range undefined when the matched legend item has no start/end value', () => { + const data = [feature('a', { colorGroup: 0 })] + const legend = { items: { 0: { name: 'Uncategorized' } } } + const result = buildTableData('event', { + data, + aggregations: {}, + isStyledEvent: true, + legend, + }) + expect(result.data[0].legend).toBe('Uncategorized') + expect(result.data[0].range).toBeUndefined() + }) +}) + +describe('buildTableData - multi-period thematic layer', () => { + const periods = [ + { id: 'p1', name: 'Jan' }, + { id: 'p2', name: 'Feb' }, + ] + const valuesByPeriod = { + p1: { a: { value: 1, color: '#f00', legend: 'Low', range: '0-1' } }, + p2: { a: { value: 2 } }, + } + + test('timeline: overlays the external period’s value/color/legend/range and adds one column per other period', () => { + const data = [feature('a')] + const result = buildTableData(THEMATIC_LAYER, { + data, + aggregations: {}, + isMultiPeriodThematic: true, + isTimelineThematic: true, + valuesByPeriod, + externalPeriod: periods[0], + periods, + }) + expect(result.data[0]).toMatchObject({ + id: 'a', + rawValue: 1, + color: '#f00', + legend: 'Low', + range: '0-1', + period_p2_rawValue: 2, + }) + expect(result.data[0].period_p1_rawValue).toBeUndefined() + }) + + test('split (non-timeline): adds one column per period, with no current-period overlay', () => { + const data = [feature('a')] + const result = buildTableData(THEMATIC_LAYER, { + data, + aggregations: {}, + isMultiPeriodThematic: true, + isTimelineThematic: false, + valuesByPeriod, + periods, + }) + expect(result.data[0]).toMatchObject({ + id: 'a', + period_p1_rawValue: 1, + period_p2_rawValue: 2, + }) + expect(result.data[0].rawValue).toBeUndefined() + }) + + test('falls back to null for a period with no recorded value for that org unit', () => { + const data = [feature('a')] + const result = buildTableData(THEMATIC_LAYER, { + data, + aggregations: {}, + isMultiPeriodThematic: true, + isTimelineThematic: false, + valuesByPeriod: { p1: {} }, + periods, + }) + expect(result.data[0].period_p1_rawValue).toBeNull() + expect(result.data[0].period_p2_rawValue).toBeNull() + }) +}) diff --git a/src/util/__tests__/tableSort.spec.js b/src/util/__tests__/tableSort.spec.js index b34c46350b..76cf355527 100644 --- a/src/util/__tests__/tableSort.spec.js +++ b/src/util/__tests__/tableSort.spec.js @@ -50,6 +50,24 @@ describe('compareFieldValues', () => { ).toBe(0) }) + it('sorts null values to the end too, without throwing (e.g. a period column with no data for some rows)', () => { + expect( + compareFieldValues(null, 5, { sortDirection: 'asc' }) + ).toBeGreaterThan(0) + expect( + compareFieldValues(5, null, { sortDirection: 'desc' }) + ).toBeLessThan(0) + }) + + it('treats null and undefined as equally "no value"', () => { + expect( + compareFieldValues(null, undefined, { sortDirection: 'asc' }) + ).toBe(0) + expect( + compareFieldValues(undefined, null, { sortDirection: 'asc' }) + ).toBe(0) + }) + it('delegates to compareRangeValues for the Range column', () => { expect( compareFieldValues('5-10', '1-3', { diff --git a/src/util/app.js b/src/util/app.js index 62a88b5806..d9d45b5f8c 100644 --- a/src/util/app.js +++ b/src/util/app.js @@ -28,7 +28,7 @@ export const appQueries = { systemInfo: { resource: 'system/info', params: { - fields: 'calendar,dateFormat', + fields: 'calendar,dateFormat,databaseInfo[spatialSupport]', }, }, } @@ -76,6 +76,7 @@ export const providerDataTransformation = async ({ calendar: systemInfo.calendar, dateFormat: systemInfo.dateFormat, }, + spatialSupport: systemInfo.databaseInfo?.spatialSupport, basemaps: await getBasemapList({ externalMapLayers: externalMapLayers.externalMapLayers, systemSettings, diff --git a/src/util/dataTable.js b/src/util/dataTable.js index 3760d7d45f..1eba91fa02 100644 --- a/src/util/dataTable.js +++ b/src/util/dataTable.js @@ -42,3 +42,42 @@ export const getRowClickAction = ( return null } + +export const hasActiveDataTableFilters = ({ + dataFilters, + globalSearch, + selectionFilter, + showOnlyFeaturesInView, +}) => + Object.keys(dataFilters ?? {}).length > 0 || + !!globalSearch?.trim() || + selectionFilter?.length > 0 || + !!showOnlyFeaturesInView + +export const buildFeatureIndex = (data) => { + const index = new Map() + data?.forEach((f) => { + const id = f.properties?.id ?? f.id + if (id != null) { + index.set(id, f) + } + }) + return index +} + +export const getPanelHeights = ({ + windowHeight, + dataTableHeight, + isCollapsed, + headerHeight, + toolbarHeight, + controlsHeight, +}) => { + const maxHeight = windowHeight - headerHeight - toolbarHeight + const tableHeight = Math.min(dataTableHeight, maxHeight) + return { + maxHeight, + collapsedHeight: controlsHeight, + displayHeight: isCollapsed ? controlsHeight : tableHeight, + } +} diff --git a/src/util/external.js b/src/util/external.js index 4387d85d44..34cb6cd3be 100644 --- a/src/util/external.js +++ b/src/util/external.js @@ -73,19 +73,18 @@ const createExternalLayerConfig = (model) => { } } -// Parse external layer config returned as a string in ao export const parseLayerConfig = async (layerConfig, engine) => { let config try { config = JSON.parse(layerConfig) } catch (error_) { - return + return { config: {} } } // We could use the config object as stored, but better to // use a fresh layer config from the API - if (config.id) { + if (config?.id) { try { const { externalLayer } = await engine.query( { externalLayer: EXTERNAL_MAP_LAYER_QUERY }, @@ -97,10 +96,12 @@ export const parseLayerConfig = async (layerConfig, engine) => { ) const newConfig = createExternalLayerConfig(externalLayer) newConfig.featureStyle = { ...config.featureStyle } + newConfig.dataTableColumnConfig = config.dataTableColumnConfig + return { config: newConfig } } catch (error_) { - return config + return { config, notFound: true } } } - return config + return { config } } diff --git a/src/util/filter.js b/src/util/filter.js index f4793a2964..ca6fb5c7ef 100644 --- a/src/util/filter.js +++ b/src/util/filter.js @@ -1,4 +1,31 @@ -import { SENTINEL_ANY_VALUE } from '../constants/dataTable.js' +import { + SENTINEL_ANY_VALUE, + SENTINEL_NO_VALUE, + DATE_GROUPS_GRANULARITY, +} from '../constants/dataTable.js' + +// Distinguishes a date-groups filter +export const isDateGroupFilter = (filter) => + filter != null && + typeof filter === 'object' && + !Array.isArray(filter) && + filter.granularity === DATE_GROUPS_GRANULARITY + +export const dateGroupFilter = (value, { prefixes }) => { + if (!prefixes?.length) { + return true + } + const stringValue = value == null ? SENTINEL_NO_VALUE : String(value) + return prefixes.some((prefix) => { + if (prefix === SENTINEL_NO_VALUE) { + return stringValue === SENTINEL_NO_VALUE + } + if (prefix === SENTINEL_ANY_VALUE) { + return stringValue !== SENTINEL_NO_VALUE + } + return stringValue.startsWith(prefix) + }) +} // Filters an array of object with a set of filters export const filterData = (data, filters) => { @@ -17,13 +44,19 @@ export const filterData = (data, filters) => { const props = d.properties || d // GeoJSON or plain object const value = props[field] + if (isDateGroupFilter(filter)) { + return dateGroupFilter(value, filter) + } + if (Array.isArray(filter)) { // Multi-select: OR match against the raw stored value - const stringValue = value == null ? '' : String(value) + const stringValue = + value == null ? SENTINEL_NO_VALUE : String(value) return ( filter.length === 0 || filter.includes(stringValue) || - (stringValue !== '' && filter.includes(SENTINEL_ANY_VALUE)) + (stringValue !== SENTINEL_NO_VALUE && + filter.includes(SENTINEL_ANY_VALUE)) ) } diff --git a/src/util/filterInput.js b/src/util/filterInput.js index dc86ddd3ec..bb7773b636 100644 --- a/src/util/filterInput.js +++ b/src/util/filterInput.js @@ -1,10 +1,16 @@ import i18n from '@dhis2/d2-i18n' +import { TYPE_NUMBER } from '../constants/dataTable.js' import { numericFilter } from './filter.js' const POPOVER_ROW_NON_LABEL_WIDTH = 56 const MIN_POPOVER_WIDTH = 140 const MAX_POPOVER_WIDTH = 280 +// Shared between FilterInput.jsx's SearchableFilterPopover and +// DateGroupFilterInput.jsx's tree - both virtualize a list of fixed-height rows +export const OPTION_ROW_HEIGHT = 28 +export const MAX_LIST_HEIGHT = 260 + export const getSelectedAndAppliedString = (filterValue) => ({ selected: Array.isArray(filterValue) ? filterValue : [], appliedString: typeof filterValue === 'string' ? filterValue : '', @@ -35,7 +41,7 @@ export const getFilteredOptions = ({ if (!trimmedSearch) { return realOptions } - if (type === 'number') { + if (type === TYPE_NUMBER) { return realOptions.filter(({ value }) => numericFilter(Number(value), trimmedSearch) ) @@ -66,3 +72,12 @@ export const getPopoverWidth = (maxLabelWidth) => ), MAX_POPOVER_WIDTH ) + +export const getCyclicIndex = (current, total, delta) => + total ? (current + delta + total) % total : -1 + +export const toOptionIndex = (highlightedIndex, showCustomFilterRow) => + showCustomFilterRow ? highlightedIndex - 1 : highlightedIndex + +export const toHighlightedIndex = (optionIndex, showCustomFilterRow) => + showCustomFilterRow ? optionIndex + 1 : optionIndex diff --git a/src/util/helpers.js b/src/util/helpers.js index d69c0d2c61..3ada5f9837 100644 --- a/src/util/helpers.js +++ b/src/util/helpers.js @@ -167,7 +167,7 @@ const formatBoolean = (value) => { } // Formats a DHIS2 date string value -const formatDate = (value) => { +export const formatDate = (value) => { const datePattern = /^(\d{4}-\d{2}-\d{2})/ const match = value.match(datePattern) return match ? match[1] : value diff --git a/src/util/orgUnits.js b/src/util/orgUnits.js index 2c6d4dbfcb..746217ae19 100644 --- a/src/util/orgUnits.js +++ b/src/util/orgUnits.js @@ -159,10 +159,11 @@ export const getStyledOrgUnits = ({ .map((f) => { const isPoint = f.geometry.type === 'Point' const { hasAdditionalGeometry } = f.properties - const { color, symbol } = getOrgUnitStyle( - f.properties.dimensions, - groupSet - ) + const { + name: groupName, + color, + symbol, + } = getOrgUnitStyle(f.properties.dimensions, groupSet) const isUnclassified = !!groupSet.id && !color && !symbol let radius @@ -187,6 +188,10 @@ export const getStyledOrgUnits = ({ properties.iconUrl = `${baseUrl}/images/orgunitgroup/${symbol}` } + if (groupName) { + properties.group = groupName + } + if (properties.level && levelWeight) { properties.weight = levelWeight(f.properties.level) } diff --git a/src/util/tableColumns.js b/src/util/tableColumns.js index b6e86f87c3..5579d28d3b 100644 --- a/src/util/tableColumns.js +++ b/src/util/tableColumns.js @@ -1,31 +1,29 @@ +import { arrayMoveImmutable } from 'array-move' +import { SENTINEL_NO_VALUE, TYPE_NUMBER } from '../constants/dataTable.js' + const CHECKBOX_COLUMN_WIDTH = 76 -const getOrderIndex = (dataKey, orderedKeys) => { - const index = orderedKeys.indexOf(dataKey) - return index === -1 ? orderedKeys.length : index -} +export const getDefaultVisibleKeys = (headers) => + headers.filter((h) => !h.defaultHidden).map((h) => h.dataKey) -export const getVisibleHeaders = (headers, columnConfig) => { +export const getOrderedHeaders = (headers, config) => { if (!headers) { return headers } - const { visibleKeys, orderedKeys } = columnConfig ?? {} - const pinnedKeys = columnConfig?.pinnedKeys ?? [] + const { orderedKeys, pinnedKeys } = config ?? {} - let result = orderedKeys - ? [...headers].sort( - (a, b) => - getOrderIndex(a.dataKey, orderedKeys) - - getOrderIndex(b.dataKey, orderedKeys) - ) - : headers - - if (visibleKeys) { - result = result.filter((h) => visibleKeys.includes(h.dataKey)) + let result = headers + if (orderedKeys) { + const orderIndex = new Map(orderedKeys.map((key, i) => [key, i])) + const getOrderIndex = (dataKey) => + orderIndex.get(dataKey) ?? orderedKeys.length + result = [...headers].sort( + (a, b) => getOrderIndex(a.dataKey) - getOrderIndex(b.dataKey) + ) } - if (pinnedKeys.length) { + if (pinnedKeys?.length) { const pinned = result.filter((h) => pinnedKeys.includes(h.dataKey)) const rest = result.filter((h) => !pinnedKeys.includes(h.dataKey)) result = [...pinned, ...rest] @@ -34,6 +32,19 @@ export const getVisibleHeaders = (headers, columnConfig) => { return result } +export const getVisibleHeaders = (headers, columnConfig) => { + if (!headers) { + return headers + } + + const visibleKeys = + columnConfig?.visibleKeys ?? getDefaultVisibleKeys(headers) + + return getOrderedHeaders(headers, columnConfig).filter((h) => + visibleKeys.includes(h.dataKey) + ) +} + export const getPinnedCount = (orderedHeaders, pinnedKeys) => { if (!orderedHeaders?.length || !pinnedKeys?.length) { return 0 @@ -103,3 +114,69 @@ export const getPinnedLeftOffsets = ( }) return offsets } + +// Expensive: scans every row once per column +export const getColumnDistinctValues = (headers, data) => { + if (!headers?.length || !data?.length) { + return null + } + + const result = {} + headers.forEach(({ dataKey, type }) => { + const seen = new Set() + for (const item of data) { + const val = item[dataKey] + seen.add( + val === undefined || val === null || val === SENTINEL_NO_VALUE + ? SENTINEL_NO_VALUE + : String(val) + ) + } + + if (seen.size > 0) { + result[dataKey] = { values: Array.from(seen), type } + } + }) + + return result +} + +export const buildRowCells = (item, headers) => + headers.map(({ dataKey, roundFn, type }) => { + const value = roundFn ? roundFn(item[dataKey]) : item[dataKey] + return { + dataKey, + value: + type === TYPE_NUMBER && Number.isNaN(Number(value)) + ? null + : value, + align: type === TYPE_NUMBER ? 'right' : 'left', + itemId: item.id, + } + }) + +export const filterHeadersByName = (headers, search) => { + const normalizedSearch = search.trim().toLowerCase() + return headers.filter((h) => + h.name.toLowerCase().includes(normalizedSearch) + ) +} + +export const reorderHeaderKeys = ( + orderedHeaders, + activeDataKey, + overDataKey +) => { + const oldIndex = orderedHeaders.findIndex( + (h) => h.dataKey === activeDataKey + ) + const newIndex = orderedHeaders.findIndex((h) => h.dataKey === overDataKey) + + if (oldIndex === -1 || newIndex === -1) { + return null + } + + return arrayMoveImmutable(orderedHeaders, oldIndex, newIndex).map( + (h) => h.dataKey + ) +} diff --git a/src/util/tableHeaders.js b/src/util/tableHeaders.js new file mode 100644 index 0000000000..7e6be114cf --- /dev/null +++ b/src/util/tableHeaders.js @@ -0,0 +1,370 @@ +import i18n from '@dhis2/d2-i18n' +import { + RENDERER_COLOR, + RENDERER_ICON, + RENDERER_DATE, + TYPE_NUMBER, + TYPE_STRING, + TYPE_DATE, + TYPE_DATETIME, + TYPE_TIME, +} from '../constants/dataTable.js' +import { + EVENT_LAYER, + THEMATIC_LAYER, + ORG_UNIT_LAYER, + EARTH_ENGINE_LAYER, + FACILITY_LAYER, + GEOJSON_URL_LAYER, + TRACKED_ENTITY_LAYER, +} from '../constants/layers.js' +import { + numberValueTypes, + dateValueTypes, + datetimeValueTypes, + timeValueTypes, +} from '../constants/valueTypes.js' +import { hasClasses } from './earthEngine.js' +import { getGeojsonDisplayData } from './geojson.js' +import { getRoundToPrecisionFn, getPrecision } from './numbers.js' +import { isValidUid } from './uid.js' + +export { TYPE_NUMBER, TYPE_STRING, TYPE_DATE, TYPE_DATETIME, TYPE_TIME } + +const getCustomFieldType = (valueType, hasOptionSet) => { + if (hasOptionSet) { + return TYPE_STRING + } + if (numberValueTypes.includes(valueType)) { + return TYPE_NUMBER + } + if (dateValueTypes.includes(valueType)) { + return TYPE_DATE + } + if (datetimeValueTypes.includes(valueType)) { + return TYPE_DATETIME + } + if (timeValueTypes.includes(valueType)) { + return TYPE_TIME + } + return TYPE_STRING +} + +const DATE_LIKE_TYPES = new Set([TYPE_DATE, TYPE_DATETIME, TYPE_TIME]) + +const getCustomFieldRenderer = (type) => + DATE_LIKE_TYPES.has(type) ? RENDERER_DATE : undefined + +const NAME = 'name' +const ID = 'id' +const VALUE = 'rawValue' +const LEGEND = 'legend' +const RANGE = 'range' +const LEVEL = 'level' +const PARENT_NAME = 'parentName' +const TYPE = 'type' +const COLOR = 'color' +const GROUP = 'group' +const ICON = 'iconUrl' +const OUNAME = 'ouname' +const OUBOUNDARY = 'ouBoundary' +const EVENTDATE = 'eventdate' + +export const ERROR_NON_HOMOGENOUS_FEATURES = 'NON_HOMOGENOUS_FEATURES' + +const defaultFieldsMap = () => ({ + [NAME]: { name: i18n.t('Name'), dataKey: NAME, type: TYPE_STRING }, + [ID]: { name: i18n.t('Id'), dataKey: ID, type: TYPE_STRING }, + [LEVEL]: { name: i18n.t('Level'), dataKey: LEVEL, type: TYPE_NUMBER }, + [PARENT_NAME]: { + name: i18n.t('Parent'), + dataKey: PARENT_NAME, + type: TYPE_STRING, + }, + [TYPE]: { name: i18n.t('Type'), dataKey: TYPE, type: TYPE_STRING }, + [VALUE]: { name: i18n.t('Value'), dataKey: VALUE, type: TYPE_NUMBER }, + [LEGEND]: { name: i18n.t('Legend'), dataKey: LEGEND, type: TYPE_STRING }, + [RANGE]: { name: i18n.t('Range'), dataKey: RANGE, type: TYPE_STRING }, + [OUNAME]: { name: i18n.t('Org unit'), dataKey: OUNAME, type: TYPE_STRING }, + [OUBOUNDARY]: { + name: i18n.t('Org unit boundary'), + dataKey: OUBOUNDARY, + type: TYPE_STRING, + }, + [EVENTDATE]: { + name: i18n.t('Event date'), + dataKey: EVENTDATE, + type: TYPE_DATE, + renderer: RENDERER_DATE, + }, + [COLOR]: { + name: i18n.t('Color'), + dataKey: COLOR, + type: TYPE_STRING, + renderer: RENDERER_COLOR, + }, + [GROUP]: { name: i18n.t('Group'), dataKey: GROUP, type: TYPE_STRING }, + [ICON]: { + name: i18n.t('Icon'), + dataKey: ICON, + type: TYPE_STRING, + renderer: RENDERER_ICON, + }, +}) + +const getStyleHeaders = ({ + hasLegend, + hasRange, + hasGroup, + hasColor, + hasIcon, +}) => { + const headers = [] + if (hasLegend) { + headers.push(defaultFieldsMap()[LEGEND]) + } + if (hasRange) { + headers.push(defaultFieldsMap()[RANGE]) + } + if (hasGroup) { + headers.push(defaultFieldsMap()[GROUP]) + } + if (hasColor) { + headers.push(defaultFieldsMap()[COLOR]) + } + if (hasIcon) { + headers.push(defaultFieldsMap()[ICON]) + } + return headers +} + +const getThematicHeaders = () => + [NAME, ID, VALUE, LEVEL, PARENT_NAME, TYPE] + .map((field) => defaultFieldsMap()[field]) + .concat( + getStyleHeaders({ hasLegend: true, hasRange: true, hasColor: true }) + ) + +const getMultiPeriodThematicHeaders = ({ + isTimelineThematic, + externalPeriod, + periods, +}) => { + const headers = isTimelineThematic + ? getThematicHeaders().map((header) => + [VALUE, LEGEND, RANGE, COLOR].includes(header.dataKey) + ? { + ...header, + name: `${header.name} (${ + externalPeriod?.name ?? i18n.t('Current period') + })`, + } + : header + ) + : getOrgUnitHeaders() + + const otherPeriods = isTimelineThematic + ? (periods ?? []).filter((p) => p.id !== externalPeriod?.id) + : periods ?? [] + + otherPeriods.forEach((period) => { + headers.push({ + name: i18n.t('Value ({{period}})', { period: period.name }), + dataKey: `period_${period.id}_rawValue`, + type: TYPE_NUMBER, + defaultHidden: true, + }) + }) + + return headers +} + +const getEventHeaders = ({ + layerHeaders = [], + styleDataItem, + countEventsOutsideOrgUnits, +}) => { + const fields = [OUNAME, ID, EVENTDATE].map( + (field) => defaultFieldsMap()[field] + ) + + if (countEventsOutsideOrgUnits) { + fields.push(defaultFieldsMap()[OUBOUNDARY]) + } + + const customFields = layerHeaders + .filter(({ name }) => isValidUid(name)) + .map(({ name: dataKey, column: name, valueType, optionSet }) => { + const type = getCustomFieldType(valueType, !!optionSet) + return { + name, + dataKey, + type, + renderer: getCustomFieldRenderer(type), + optionSet: optionSet || null, + } + }) + + customFields.push( + defaultFieldsMap()[TYPE], + ...getStyleHeaders({ + hasLegend: !!styleDataItem, + hasRange: !!styleDataItem, + hasColor: !!styleDataItem, + }) + ) + + return fields.concat(customFields) +} + +const getOrgUnitStyleHeaders = (data) => { + let hasGroup = false + let hasColor = false + let hasIcon = false + + for (const d of data ?? []) { + hasGroup ||= d.group != null + hasColor ||= d.color != null + hasIcon ||= d.iconUrl != null + + if (hasGroup && hasColor && hasIcon) { + break + } + } + + return getStyleHeaders({ hasGroup, hasColor, hasIcon }) +} + +// Org unit and facility headers share the same shape +const getFixedFieldsWithOrgUnitStyle = (fields, data) => + fields + .map((field) => defaultFieldsMap()[field]) + .concat(getOrgUnitStyleHeaders(data)) + +const getOrgUnitHeaders = (data) => + getFixedFieldsWithOrgUnitStyle([NAME, ID, LEVEL, PARENT_NAME, TYPE], data) + +const getTrackedEntityHeaders = ({ layerHeaders = [] }) => { + const fields = [ID].map((field) => defaultFieldsMap()[field]) + + const customFields = layerHeaders + .filter(({ dataKey }) => isValidUid(dataKey)) + .map(({ name, dataKey, valueType }) => { + const type = getCustomFieldType(valueType, false) + return { + name, + dataKey, + type, + renderer: getCustomFieldRenderer(type), + } + }) + + customFields.push(...getStyleHeaders({ hasColor: true })) + + return fields.concat(customFields) +} + +const getFacilityHeaders = (data) => + getFixedFieldsWithOrgUnitStyle([NAME, ID, TYPE], data) + +const toTitleCase = (str) => + str.replace( + /\w\S*/g, + (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() + ) + +const getEarthEngineHeaders = ({ aggregationType, legend, data }) => { + const { title, items } = legend + + let customFields = [] + + if (hasClasses(aggregationType) && items) { + customFields = items.map(({ value, name }) => ({ + name, + dataKey: String(value), + roundFn: getRoundToPrecisionFn(2), + type: TYPE_NUMBER, + })) + } else if (Array.isArray(aggregationType) && aggregationType.length) { + customFields = aggregationType.map((type) => { + let roundFn = null + if (data?.length) { + const precision = getPrecision(data.map((d) => d[type])) + roundFn = getRoundToPrecisionFn(precision) + } + return { + name: toTitleCase(`${type} ${title}`), + dataKey: type, + roundFn, + type: TYPE_NUMBER, + } + }) + } + + return [NAME, ID, TYPE] + .map((field) => defaultFieldsMap()[field]) + .concat(customFields) +} + +const getGeoJsonUrlHeaders = (firstDataItem) => + getGeojsonDisplayData(firstDataItem).map((header) => + header.dataKey === COLOR ? defaultFieldsMap()[COLOR] : header + ) + +export const getHeadersForLayer = (layerType, ctx) => { + switch (layerType) { + case THEMATIC_LAYER: + return { + headers: ctx.isMultiPeriodThematic + ? getMultiPeriodThematicHeaders({ + isTimelineThematic: ctx.isTimelineThematic, + externalPeriod: ctx.externalPeriod, + periods: ctx.periods, + }) + : getThematicHeaders(), + } + case EVENT_LAYER: + return { + headers: getEventHeaders({ + layerHeaders: ctx.layerHeaders, + styleDataItem: ctx.styleDataItem, + countEventsOutsideOrgUnits: ctx.countEventsOutsideOrgUnits, + }), + } + case ORG_UNIT_LAYER: + return { headers: getOrgUnitHeaders(ctx.data) } + case TRACKED_ENTITY_LAYER: + return { + headers: getTrackedEntityHeaders({ + layerHeaders: ctx.layerHeaders, + }), + } + case EARTH_ENGINE_LAYER: + return { + headers: getEarthEngineHeaders({ + aggregationType: ctx.aggregationType, + legend: ctx.legend, + data: ctx.data, + }), + } + case FACILITY_LAYER: + return { headers: getFacilityHeaders(ctx.data) } + case GEOJSON_URL_LAYER: { + // Unlike the other cases, this reads the raw layer data + // rather than dataWithAggregations + const rawData = ctx.rawData ?? [] + const nonMultiType = (type) => type.replaceAll('Multi', '') + const isHomogenous = rawData.every( + (feature) => + nonMultiType(feature.geometry.type) === + nonMultiType(rawData[0]?.geometry.type ?? '') + ) + if (!isHomogenous) { + return { errorCode: ERROR_NON_HOMOGENOUS_FEATURES } + } + return { headers: getGeoJsonUrlHeaders(rawData[0]) } + } + default: + return { headers: null } + } +} diff --git a/src/util/tableRows.js b/src/util/tableRows.js new file mode 100644 index 0000000000..60719159d3 --- /dev/null +++ b/src/util/tableRows.js @@ -0,0 +1,113 @@ +import { GEOJSON_URL_LAYER } from '../constants/layers.js' +import { isFeatureInBounds } from './geojson.js' +import { formatRangeWithSeparator } from './numbers.js' + +export const ERROR_NO_VALID_DATA = 'NO_VALID_DATA' + +export const buildTableData = ( + layerType, + { + data, + dataWithoutCoords, + serverCluster, + showOnlyFeaturesInView, + mapBounds, + aggregations, + isStyledEvent, + isMultiPeriodThematic, + isTimelineThematic, + legend, + valuesByPeriod, + externalPeriod, + periods, + keyAnalysisDigitGroupSeparator, + legendDecimalPlaces, + } +) => { + if (serverCluster) { + return { data: [] } + } + + const allData = dataWithoutCoords?.length + ? [...(data || []), ...dataWithoutCoords] + : data + + if (!allData?.length) { + return { errorCode: ERROR_NO_VALID_DATA } + } + + const inViewData = showOnlyFeaturesInView + ? allData.filter((d) => isFeatureInBounds(d, mapBounds)) + : allData + + if (layerType === GEOJSON_URL_LAYER) { + return { + data: inViewData.map((d, index) => ({ + ...d.properties, + // Row-order tie-breaker for compareRows when no sortField is set + index, + })), + } + } + + const rows = inViewData + .filter((d) => !d.properties.hasAdditionalGeometry) + .map((d, index) => { + const properties = d.properties || d + + if (isStyledEvent) { + const legendItem = legend?.items?.[properties.colorGroup] + return { + ...properties, + legend: legendItem?.name, + range: + legendItem && 'startValue' in legendItem + ? formatRangeWithSeparator( + legendItem, + keyAnalysisDigitGroupSeparator, + { precision: legendDecimalPlaces } + ) + : undefined, + ...aggregations[d.id], + index, + } + } + + if (!isMultiPeriodThematic) { + return { + ...properties, + ...aggregations[d.id], + // Row-order tie-breaker for compareRows when no sortField is set + index, + } + } + + const orgUnitId = properties.id + const currentPeriodItem = isTimelineThematic + ? valuesByPeriod?.[externalPeriod?.id]?.[orgUnitId] + : null + const otherPeriodValues = {} + ;(periods ?? []).forEach((period) => { + if (isTimelineThematic && period.id === externalPeriod?.id) { + return + } + otherPeriodValues[`period_${period.id}_rawValue`] = + valuesByPeriod?.[period.id]?.[orgUnitId]?.value ?? null + }) + + return { + ...properties, + ...(currentPeriodItem && { + rawValue: currentPeriodItem.value, + color: currentPeriodItem.color, + legend: currentPeriodItem.legend, + range: currentPeriodItem.range, + }), + ...otherPeriodValues, + ...aggregations[d.id], + index, + } + }) + + return { data: rows } +} diff --git a/src/util/tableSort.js b/src/util/tableSort.js index 6af6052b89..ac53e00f6b 100644 --- a/src/util/tableSort.js +++ b/src/util/tableSort.js @@ -2,6 +2,7 @@ import { SENTINEL_NO_VALUE, SENTINEL_SELECTED_ROW, SORT_ASCENDING, + TYPE_NUMBER, } from '../constants/dataTable.js' import { parseRange } from './legend.js' @@ -32,7 +33,7 @@ export const compareColumnOptionValues = ( return compareRangeValues(a, b, direction) } const comparison = - type === 'number' ? Number(a) - Number(b) : compareStrings(a, b) + type === TYPE_NUMBER ? Number(a) - Number(b) : compareStrings(a, b) return direction === SORT_ASCENDING ? comparison : -comparison } @@ -56,19 +57,21 @@ export const compareRangeValues = (aVal, bVal, sortDirection) => { return sortDirection === SORT_ASCENDING ? aEnd - bEnd : bEnd - aEnd } +const isNoValue = (val) => val === undefined || val === null + export const compareFieldValues = ( aVal, bVal, { sortField, sortDirection } ) => { - // All undefined values should be sorted to the end - if (aVal === undefined && bVal === undefined) { + // All missing values should be sorted to the end + if (isNoValue(aVal) && isNoValue(bVal)) { return 0 } - if (aVal === undefined) { + if (isNoValue(aVal)) { return 1 } - if (bVal === undefined) { + if (isNoValue(bVal)) { return -1 } if (typeof aVal === 'number') { diff --git a/src/util/time.js b/src/util/time.js index e0d6821109..196130aecd 100644 --- a/src/util/time.js +++ b/src/util/time.js @@ -3,7 +3,7 @@ import i18n from '@dhis2/d2-i18n' const DEFAULT_LOCALE = 'en' // BCP 47 locale format -const dateLocale = (locale) => +export const dateLocale = (locale) => locale?.includes('_') ? locale.replaceAll('_', '-') : locale /** diff --git a/yarn.lock b/yarn.lock index 82fce786f0..c15648905d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2371,9 +2371,9 @@ resolved "https://registry.yarnpkg.com/@dhis2/data-engine/-/data-engine-3.17.3.tgz#0347416e9919efbf4d9739c4141fa543f89669ad" integrity sha512-hLXt7LFrFitR7QgKfGQ3ComTLrY5IAdtERonhdo/SIrsRYWoeVaMiCOkUUzC48pEaeo1/BL5qwA7Tw7jZgROQw== -"@dhis2/maps-gl@git+https://github.com/d2-ci/maps-gl.git#e89c7e9bf5634da8b13684c314eb22838f629ed6": +"@dhis2/maps-gl@git+https://github.com/d2-ci/maps-gl.git#6758ac621ff7ed582ad458bb4c9f90900358cedc": version "4.4.3" - resolved "git+https://github.com/d2-ci/maps-gl.git#e89c7e9bf5634da8b13684c314eb22838f629ed6" + resolved "git+https://github.com/d2-ci/maps-gl.git#6758ac621ff7ed582ad458bb4c9f90900358cedc" dependencies: "@mapbox/sphericalmercator" "^1.2.0" "@turf/area" "^7.3.5"