diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index d28ed5769..bbdabed72 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -1,11 +1,13 @@ import { IconCross16 } from '@dhis2/ui' -import React, { useRef, useCallback } from 'react' +import React, { + useRef, + useCallback, + useState, + useEffect, + useLayoutEffect, +} from 'react' import { useSelector, useDispatch } from 'react-redux' import { closeDataTable, resizeDataTable } from '../../actions/dataTable.js' -import { - LAYERS_PANEL_WIDTH, - RIGHT_PANEL_WIDTH, -} from '../../constants/layout.js' import useKeyDown from '../../hooks/useKeyDown.js' import { getCssVar } from '../../util/helpers.js' import { useWindowDimensions } from '../WindowDimensionsProvider.jsx' @@ -17,26 +19,49 @@ import styles from './styles/BottomPanel.module.css' // Container for DataTable const BottomPanel = () => { const dataTableHeight = useSelector((state) => state.ui.dataTableHeight) - const layersPanelOpen = useSelector((state) => state.ui.layersPanelOpen) - const rightPanelOpen = useSelector((state) => state.ui.rightPanelOpen) const dispatch = useDispatch() - const { width, height } = useWindowDimensions() + const { height } = useWindowDimensions() const panelRef = useRef(null) - - const onResize = useCallback( - (h) => (panelRef.current.style.height = `${h}px`), - [panelRef] - ) + const [panelWidth, setPanelWidth] = useState(0) const maxHeight = height - getCssVar('--header-height') - getCssVar('--toolbar-height') const tableHeight = dataTableHeight < maxHeight ? dataTableHeight : maxHeight - const layersWidth = layersPanelOpen ? LAYERS_PANEL_WIDTH : 0 - const rightPanelWidth = rightPanelOpen ? RIGHT_PANEL_WIDTH : 0 - const tableWidth = width - layersWidth - rightPanelWidth - const dataTableControlsHeight = 20 + const onResize = useCallback((h) => { + document.documentElement.style.setProperty( + '--data-table-height', + `${h}px` + ) + }, []) + + useLayoutEffect(() => { + document.documentElement.style.setProperty( + '--data-table-height', + `${tableHeight}px` + ) + }, [tableHeight]) + + useLayoutEffect( + () => () => + document.documentElement.style.removeProperty( + '--data-table-height' + ), + [] + ) + + useEffect(() => { + const observer = new ResizeObserver(() => { + if (panelRef.current) { + setPanelWidth(panelRef.current.getBoundingClientRect().width) + } + }) + if (panelRef.current) { + observer.observe(panelRef.current) + } + return () => observer.disconnect() + }, []) useKeyDown('Escape', () => dispatch(closeDataTable()), true) @@ -44,7 +69,6 @@ const BottomPanel = () => {
@@ -60,12 +84,11 @@ const BottomPanel = () => {
- - - +
+ + + +
) } diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index d0090238a..26598f799 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -89,13 +89,14 @@ const TableComponents = { ), } -const Table = ({ availableHeight, availableWidth }) => { +const Table = ({ availableWidth }) => { const { systemSettings: { keyAnalysisDigitGroupSeparator }, } = useCachedData() const headerRowRef = useRef(null) const [columnWidths, setColumnWidths] = useState([]) + const minColumnWidthsRef = useRef([]) const { mapViews } = useSelector((state) => state.map) const activeLayerId = useSelector((state) => state.dataTable) @@ -206,12 +207,7 @@ const Table = ({ availableHeight, availableWidth }) => { }) useEffect(() => { - /* The combination of automatic table layout and virtual scrolling - * causes a content shift when scrolling and filtering because the - * cells in the DOM have a different content length which causes the - * columns to have a different width. To avoid that we measure the - * initial column widths and switch to a fixed layout based on these - * measured widths */ + // Measure column widths in auto layout, then switch to fixed to prevent content shift during virtual scrolling if (columnWidths.length === 0 && headerRowRef.current) { requestAnimationFrame(() => { const measuredColumnWidths = [] @@ -221,20 +217,41 @@ const Table = ({ availableHeight, availableWidth }) => { measuredColumnWidths.push(Math.floor(rect.width)) } + minColumnWidthsRef.current = measuredColumnWidths setColumnWidths(measuredColumnWidths) }) } }, [columnWidths]) useEffect(() => { - /* When the window is resized, the sidebar opens, or the table - * headers change, the table needs to switch back to its - * automatic layout so that the cells can subsequently can be - * measured again in the useEffect hook above */ + // Reset to auto layout for re-measurement when headers change if (!error) { + minColumnWidthsRef.current = [] setColumnWidths([]) } - }, [availableWidth, headers, error]) + }, [headers, error]) + + useEffect(() => { + // Scale column widths proportionally on resize, clamped to initial measured widths + if (!error) { + setColumnWidths((prev) => { + if (prev.length === 0) { + return prev + } + const prevTotal = prev.reduce((sum, w) => sum + w, 0) + if (prevTotal === 0 || availableWidth === 0) { + return [] + } + const minWidths = minColumnWidthsRef.current + return prev.map((w, i) => + Math.max( + minWidths[i] ?? 0, + Math.round((w / prevTotal) * availableWidth) + ) + ) + }) + } + }, [availableWidth, error]) if (error) { return

{error}

@@ -246,7 +263,7 @@ const Table = ({ availableHeight, availableWidth }) => { context={tableContext} components={TableComponents} style={{ - height: availableHeight, + height: '100%', width: '100%', }} data={rows} @@ -321,7 +338,6 @@ const Table = ({ availableHeight, availableWidth }) => { } Table.propTypes = { - availableHeight: PropTypes.number, availableWidth: PropTypes.number, } diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index 19e3c0f83..68ce43eab 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -2,8 +2,18 @@ position: absolute; left: 0; bottom: 0; + width: 100%; + height: var(--data-table-height); z-index: 1040; background: #fff; + display: flex; + flex-direction: column; +} + +.tableContainer { + flex: 1; + min-height: 0; + position: relative; } .dataTableControls { diff --git a/src/components/download/OverviewMapOutline.js b/src/components/download/OverviewMapOutline.js index a0d58a9d6..3482466a6 100644 --- a/src/components/download/OverviewMapOutline.js +++ b/src/components/download/OverviewMapOutline.js @@ -1,6 +1,7 @@ import PropTypes from 'prop-types' -import { useState, useCallback, useEffect } from 'react' +import { useState, useRef, useCallback, useEffect } from 'react' import { GEOJSON_LAYER } from '../../constants/layers.js' +import { getCssColor } from '../../util/colors.js' import { getCoordinatesBounds } from '../../util/geojson.js' const layerId = 'overview-outline' @@ -30,7 +31,8 @@ const getMapOutline = (map) => { const OverviewMapOutline = ({ mainMap, overviewMap, isDark = false }) => { const [outline, setOutline] = useState(getMapOutline(mainMap)) - const [sourceId, setSourceId] = useState() + const sourceIdRef = useRef() + const layerRef = useRef() const onMainMapMove = useCallback(() => { setOutline(getMapOutline(mainMap)) @@ -45,6 +47,9 @@ const OverviewMapOutline = ({ mainMap, overviewMap, isDark = false }) => { useEffect(() => { if (outline) { + const strokeColor = isDark + ? getCssColor('--colors-grey300') + : getCssColor('--colors-grey900') const config = { type: GEOJSON_LAYER, id: layerId, @@ -52,20 +57,30 @@ const OverviewMapOutline = ({ mainMap, overviewMap, isDark = false }) => { data: [outline], style: { color: 'transparent', - strokeColor: isDark ? 'orange' : '#333', + strokeColor, weight: 3, }, } - if (!sourceId) { - const layer = overviewMap.createLayer(config) - overviewMap.addLayer(layer) - setSourceId(layer.getId()) - } else { - const source = overviewMap.getMapGL().getSource(sourceId) + if (sourceIdRef.current) { + const mapGl = overviewMap.getMapGL() + const source = mapGl.getSource(sourceIdRef.current) if (source) { source.setData(outline) } + const outlineLayerId = `${sourceIdRef.current}-outline` + if (mapGl.getLayer(outlineLayerId)) { + mapGl.setPaintProperty( + outlineLayerId, + 'line-color', + strokeColor + ) + } + } else { + const layer = overviewMap.createLayer(config) + overviewMap.addLayer(layer) + sourceIdRef.current = layer.getId() + layerRef.current = layer } // Make sure outline bounds is inside overview map bounds @@ -83,7 +98,17 @@ const OverviewMapOutline = ({ mainMap, overviewMap, isDark = false }) => { overviewMap.getMapGL().fitBounds(outlineBounds, { padding: 80 }) } } - }, [overviewMap, outline, sourceId, isDark]) + }, [overviewMap, outline, isDark]) + + useEffect(() => { + return () => { + if (layerRef.current) { + overviewMap.removeLayer(layerRef.current) + layerRef.current = undefined + sourceIdRef.current = undefined + } + } + }, [overviewMap]) return null } diff --git a/src/components/map/MapPosition.jsx b/src/components/map/MapPosition.jsx index cbc420aa3..b66119caf 100644 --- a/src/components/map/MapPosition.jsx +++ b/src/components/map/MapPosition.jsx @@ -1,5 +1,5 @@ import cx from 'classnames' -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useRef } from 'react' import { useSelector } from 'react-redux' import { getSplitViewLayer } from '../../util/helpers.js' import DownloadMapInfo from '../download/DownloadMapInfo.jsx' @@ -7,9 +7,12 @@ import NorthArrow from '../download/NorthArrow.jsx' import MapContainer from '../map/MapContainer.jsx' import styles from './styles/MapPosition.module.css' +const incrementCount = (c) => c + 1 + const MapPosition = () => { const [map, setMap] = useState() const [resizeCount, setResizeCount] = useState(0) + const outerRef = useRef(null) const { showName, showDescription, @@ -31,10 +34,11 @@ const MapPosition = () => { showOverviewMap) const isSplitView = !!getSplitViewLayer(layers) + const mapGL = map?.getMapGL() // Trigger map resize when panels are expanded, collapsed or dragged useEffect(() => { - setResizeCount((count) => count + 1) + setResizeCount(incrementCount) }, [ dataTableOpen, dataTableHeight, @@ -43,6 +47,30 @@ const MapPosition = () => { rightPanelOpen, ]) + // Trigger map resize continuously during ResizeHandle drag (CSS variable drives height, not Redux) + useEffect(() => { + if (!outerRef.current) { + return + } + let frameId = null + const observer = new ResizeObserver(() => { + if (frameId !== null) { + return + } + frameId = requestAnimationFrame(() => { + setResizeCount(incrementCount) + frameId = null + }) + }) + observer.observe(outerRef.current) + return () => { + observer.disconnect() + if (frameId !== null) { + cancelAnimationFrame(frameId) + } + } + }, []) + // Reset bearing and pitch when new map (mapId changed) useEffect(() => { if (map) { @@ -60,6 +88,10 @@ const MapPosition = () => { if (map) { const mapgl = map.getMapGL() + if (!mapgl) { + return + } + mapgl.once('resize', () => { map.fitBounds(map.getLayersBounds(), { padding: 40, @@ -77,17 +109,12 @@ const MapPosition = () => { return (
{ })} > - {downloadMode && map && ( + {downloadMode && mapGL && ( <> {downloadMapInfoOpen && ( )} {showNorthArrow && !isSplitView && ( )} diff --git a/src/components/map/styles/MapPosition.module.css b/src/components/map/styles/MapPosition.module.css index 3cebf17a5..c01f7d60d 100644 --- a/src/components/map/styles/MapPosition.module.css +++ b/src/components/map/styles/MapPosition.module.css @@ -32,6 +32,13 @@ height: calc(100vh - var(--header-height) - var(--toolbar-height)); } +.mapWithDataTable { + height: calc( + 100vh - var(--header-height) - var(--toolbar-height) - + var(--data-table-height) + ); +} + .mapDownload { height: calc(100vh - var(--downloadheader-height)); border: var(--spacers-dp24) solid #fff; diff --git a/src/constants/basemaps.js b/src/constants/basemaps.js index 1b136bc6e..b7e9fff6a 100644 --- a/src/constants/basemaps.js +++ b/src/constants/basemaps.js @@ -44,7 +44,7 @@ export const defaultBasemaps = () => [ attribution: '© Sentinel-2 cloudless by EOX IT Services GmbH (Contains modified Copernicus Sentinel data 2024)', }, - isDark: false, + isDark: true, }, { id: 'bingLight', diff --git a/src/loaders/__tests__/trackedEntityLoader.spec.js b/src/loaders/__tests__/trackedEntityLoader.spec.js new file mode 100644 index 000000000..634049846 --- /dev/null +++ b/src/loaders/__tests__/trackedEntityLoader.spec.js @@ -0,0 +1,56 @@ +import { parseJsonConfig } from '../trackedEntityLoader.js' + +jest.mock('../../components/map/MapApi.js', () => ({ + loadEarthEngineWorker: jest.fn(), +})) + +describe('parseJsonConfig', () => { + it('extracts periodType when relationships is null', () => { + const config = { + config: JSON.stringify({ + relationships: null, + periodType: 'program', + }), + } + parseJsonConfig(config) + expect(config.periodType).toBe('program') + expect(config.relationshipType).toBeUndefined() + expect(config.config).toBeUndefined() + }) + + it('extracts both periodType and relationship fields when relationships is set', () => { + const config = { + config: JSON.stringify({ + relationships: { + type: 'rel-type-id', + pointColor: '#ff0000', + pointRadius: 5, + lineColor: '#0000ff', + relationshipOutsideProgram: true, + }, + periodType: 'program', + }), + } + parseJsonConfig(config) + expect(config.periodType).toBe('program') + expect(config.relationshipType).toBe('rel-type-id') + expect(config.relatedPointColor).toBe('#ff0000') + expect(config.relatedPointRadius).toBe(5) + expect(config.relationshipLineColor).toBe('#0000ff') + expect(config.relationshipOutsideProgram).toBe(true) + expect(config.config).toBeUndefined() + }) + + it('does nothing when config.config is absent', () => { + const config = { layer: 'trackedEntity' } + parseJsonConfig(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(config.periodType).toBeUndefined() + expect(config.config).toBeUndefined() + }) +}) diff --git a/src/loaders/trackedEntityLoader.js b/src/loaders/trackedEntityLoader.js index 17e907eae..70591960a 100644 --- a/src/loaders/trackedEntityLoader.js +++ b/src/loaders/trackedEntityLoader.js @@ -109,22 +109,29 @@ const toGeoJson = (instances) => }, })) -const parseJsonConfig = (config) => { - if (config.config && typeof config.config === 'string') { - try { - const customConfig = JSON.parse(config.config) - config.relationshipType = customConfig.relationships.type - config.relatedPointColor = customConfig.relationships.pointColor - config.relatedPointRadius = customConfig.relationships.pointRadius - config.relationshipLineColor = customConfig.relationships.lineColor +export const parseJsonConfig = (config) => { + if (!config.config || typeof config.config !== 'string') { + return + } + + try { + const { relationships, periodType } = JSON.parse(config.config) + + if (relationships) { + config.relationshipType = relationships.type + config.relatedPointColor = relationships.pointColor + config.relatedPointRadius = relationships.pointRadius + config.relationshipLineColor = relationships.lineColor config.relationshipOutsideProgram = - customConfig.relationships.relationshipOutsideProgram - config.periodType = customConfig.periodType - } catch (e) { - // Failed to load JSON relationship config, assuming no relationships + relationships.relationshipOutsideProgram } - delete config.config + + config.periodType = periodType + } catch (e) { + // Malformed config JSON } + + delete config.config } const fetchRelationshipData = async ({ diff --git a/src/util/colors.js b/src/util/colors.js index 5fafda63c..925781d12 100644 --- a/src/util/colors.js +++ b/src/util/colors.js @@ -91,6 +91,9 @@ export const getUniqueColor = (defaultColors) => { return (index) => colors[index] || randomColor() } +export const getCssColor = (cssVar) => + getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim() + // Returns true if a color is dark export const isDarkColor = (color) => hcl(color).l < 70