Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions i18n/en.pot
Original file line number Diff line number Diff line change
Expand Up @@ -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-04-28T15:36:36.629Z\n"
"PO-Revision-Date: 2026-04-28T15:36:36.629Z\n"
"POT-Creation-Date: 2026-05-01T10:18:47.999Z\n"
"PO-Revision-Date: 2026-05-01T10:18:48.000Z\n"

msgid "2020"
msgstr "2020"
Expand Down Expand Up @@ -988,6 +988,12 @@ msgstr "Click to unpin legend"
msgid "Click to pin legend"
msgstr "Click to pin legend"

msgid "Hide layer"
msgstr "Hide layer"

msgid "Show layer"
msgstr "Show layer"

msgid "No program"
msgstr "No program"

Expand Down
6 changes: 5 additions & 1 deletion src/components/app/FileMenu.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
preparePayloadForSaveAs,
VIS_TYPE_MAP,
} from '@dhis2/analytics'
import { useDataMutation, useDataEngine } from '@dhis2/app-runtime'
import { useDataMutation, useDataEngine, useConfig } from '@dhis2/app-runtime'
import { useAlert } from '@dhis2/app-service-alerts'
import i18n from '@dhis2/d2-i18n'
import PropTypes from 'prop-types'
Expand Down Expand Up @@ -67,6 +67,7 @@ const FileMenu = ({ onFileMenuAction }) => {
const map = useSelector((state) => state.map)
const dispatch = useDispatch()
const engine = useDataEngine()
const { serverVersion } = useConfig()
const { systemSettings, currentUser } = useCachedData()
const defaultBasemap = systemSettings.keyDefaultBaseMap
//alerts
Expand Down Expand Up @@ -119,6 +120,7 @@ const FileMenu = ({ onFileMenuAction }) => {
const cleanedMap = cleanMapConfig({
config: map,
defaultBasemapId: defaultBasemap,
serverVersion,
})

const config = preparePayloadForSave({
Expand Down Expand Up @@ -160,6 +162,7 @@ const FileMenu = ({ onFileMenuAction }) => {
config: latestMap,
defaultBasemapId: defaultBasemap,
cleanMapviewConfig: false,
serverVersion,
})

const config = preparePayloadForSave({
Expand Down Expand Up @@ -189,6 +192,7 @@ const FileMenu = ({ onFileMenuAction }) => {
const cleanedMap = cleanMapConfig({
config: map,
defaultBasemapId: defaultBasemap,
serverVersion,
})

const config = preparePayloadForSaveAs({
Expand Down
1 change: 1 addition & 0 deletions src/components/map/layers/EventLayer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ class EventLayer extends Layer {
this.layer = map.createLayer(config)

map.addLayer(this.layer)
this.setLayerVisibility()

// Fit map to layer bounds once (when first created)
this.fitBoundsOnce()
Expand Down
1 change: 1 addition & 0 deletions src/components/map/layers/ExternalLayer.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ export default class ExternalLayer extends Layer {
})

map.addLayer(this.layer)
this.setLayerVisibility()
}
}
1 change: 1 addition & 0 deletions src/components/map/layers/FacilityLayer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ class FacilityLayer extends Layer {
group.addLayer(config)
this.layer = group
map.addLayer(this.layer).catch(this.onError.bind(this))
this.setLayerVisibility()

// Fit map to layer bounds once (when first created)
this.fitBoundsOnce()
Expand Down
1 change: 1 addition & 0 deletions src/components/map/layers/GeoJsonLayer.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class GeoJsonLayer extends Layer {
})

map.addLayer(this.layer)
this.setLayerVisibility()

// Fit map to layer bounds once (when first created)
this.fitBoundsOnce()
Expand Down
1 change: 1 addition & 0 deletions src/components/map/layers/Layer.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ class Layer extends PureComponent {
})

await map.addLayer(this.layer)
this.setLayerVisibility()
}

async updateLayer() {
Expand Down
1 change: 1 addition & 0 deletions src/components/map/layers/OrgUnitLayer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export default class OrgUnitLayer extends Layer {

this.layer = map.createLayer(config)
map.addLayer(this.layer)
this.setLayerVisibility()

// Fit map to layer bounds once (when first created)
this.fitBoundsOnce()
Expand Down
1 change: 1 addition & 0 deletions src/components/map/layers/ThematicLayer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ class ThematicLayer extends Layer {
}

map.addLayer(this.layer)
this.setLayerVisibility()

const options = {}
if (renderingStrategy === RENDERING_STRATEGY_TIMELINE) {
Expand Down
1 change: 1 addition & 0 deletions src/components/map/layers/TrackedEntityLayer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ class TrackedEntityLayer extends Layer {

this.layer = group
map.addLayer(this.layer)
this.setLayerVisibility()

// Fit map to layer bounds once (when first created)
this.fitBoundsOnce()
Expand Down
1 change: 1 addition & 0 deletions src/components/map/layers/earthEngine/EarthEngineLayer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export default class EarthEngineLayer extends Layer {
try {
this.layer = map.createLayer(config)
await map.addLayer(this.layer)
this.setLayerVisibility()
} catch (error) {
this.onError(error)
}
Expand Down
9 changes: 7 additions & 2 deletions src/components/plugin/Legend.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import LegendLayer from './LegendLayer.jsx'
import './styles/Legend.css'

// Renders a legend for all map layers
const Legend = ({ layers }) => {
const Legend = ({ layers, toggleLayerVisibility }) => {
const [isOpen, setIsOpen] = useState(false)
const [isPinned, setIsPinned] = useState(false)

Expand All @@ -30,7 +30,11 @@ const Legend = ({ layers }) => {
onClick={() => setIsPinned(!isPinned)}
>
{legendLayers.map((layer) => (
<LegendLayer key={layer.id} {...layer} />
<LegendLayer
key={layer.id}
{...layer}
toggleLayerVisibility={toggleLayerVisibility}
/>
))}
</div>
</div>
Expand All @@ -47,6 +51,7 @@ const Legend = ({ layers }) => {

Legend.propTypes = {
layers: PropTypes.array.isRequired,
toggleLayerVisibility: PropTypes.func,
}

export default Legend
32 changes: 28 additions & 4 deletions src/components/plugin/LegendLayer.jsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import i18n from '@dhis2/d2-i18n'
import { IconView24, IconViewOff24 } from '@dhis2/ui'
import PropTypes from 'prop-types'
import React, { Fragment } from 'react'
import { getRenderingLabel } from '../../util/legend.js'
Expand All @@ -11,16 +13,36 @@ const LegendLayer = ({
legend,
renderingStrategy,
alerts = DEFAULT_NO_ALERTS,
isVisible = true,
toggleLayerVisibility,
}) => (
<div key={id}>
{legend && (
<Fragment>
<h2 className="dhis2-map-legend-title">
{legend.title}
<span className="dhis2-map-legend-period">
{legend.period}
{getRenderingLabel(renderingStrategy)}
<span className="dhis2-map-legend-title-text">
{legend.title}
<span className="dhis2-map-legend-period">
{legend.period}
{getRenderingLabel(renderingStrategy)}
</span>
</span>
{toggleLayerVisibility && (
<button
className="dhis2-map-legend-visibility-btn"
title={
isVisible
? i18n.t('Hide layer')
: i18n.t('Show layer')
}
onClick={(e) => {
e.stopPropagation()
toggleLayerVisibility(id)
}}
>
{isVisible ? <IconView24 /> : <IconViewOff24 />}
</button>
)}
</h2>
<LayerLegend isPlugin={true} {...legend} />
</Fragment>
Expand All @@ -37,10 +59,12 @@ LegendLayer.propTypes = {
id: PropTypes.string.isRequired,
alerts: PropTypes.array,
data: PropTypes.array,
isVisible: PropTypes.bool,
layer: PropTypes.string,
legend: PropTypes.object,
renderingStrategy: PropTypes.string,
serverCluster: PropTypes.bool,
toggleLayerVisibility: PropTypes.func,
}

export default LegendLayer
27 changes: 24 additions & 3 deletions src/components/plugin/Map.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,28 @@ const Map = forwardRef((props, ref) => {
useEffect(() => {
if (didViewsChange(layers.current, mapViews)) {
layers.current = mapViews.map((v) => ({ ...v, isLoaded: false }))

setVisibilityOverrides({})
setMapIsLoaded(false)
}
}, [mapViews])

const [mapIsLoaded, setMapIsLoaded] = useState(mapViews.length === 0)
const [contextMenu, setContextMenu] = useState()
const [visibilityOverrides, setVisibilityOverrides] = useState({})
const [resizeCount, setResizeCount] = useState(0)

const onResize = () => setResizeCount((state) => state + 1)

const toggleLayerVisibility = useCallback((id) => {
setVisibilityOverrides((prev) => {
const current =
prev[id] ??
layers.current.find((l) => l.id === id)?.isVisible ??
true
return { ...prev, [id]: !current }
})
}, [])

const onLayerLoad = useCallback((layer) => {
layers.current = layers.current.map((l) =>
layer.id === l.id ? layer : l
Expand Down Expand Up @@ -126,6 +137,11 @@ const Map = forwardRef((props, ref) => {
)
}

const layersWithVisibility = layers.current.map((l) => ({
...l,
isVisible: visibilityOverrides[l.id] ?? l.isVisible ?? true,
}))

return (
<div ref={ref} className={`dhis2-map-plugin ${styles.map}`}>
<CssReset />
Expand All @@ -134,13 +150,18 @@ const Map = forwardRef((props, ref) => {
isPlugin={true}
isFullscreen={false}
basemap={basemap}
layers={layers.current}
layers={layersWithVisibility}
controls={controls}
bounds={defaultBounds}
openContextMenu={setContextMenu}
resizeCount={resizeCount}
/>
{mapViews.length > 0 && <Legend layers={layers.current} />}
{mapViews.length > 0 && (
<Legend
layers={layersWithVisibility}
toggleLayerVisibility={toggleLayerVisibility}
/>
)}
{contextMenu && (
<ContextMenu
{...contextMenu}
Expand Down
103 changes: 103 additions & 0 deletions src/components/plugin/__tests__/LegendLayer.spec.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { render, screen, fireEvent } from '@testing-library/react'
import React from 'react'
import LegendLayer from '../LegendLayer.jsx'

jest.mock('@dhis2/d2-i18n', () => ({ t: (s) => s }))
jest.mock('@dhis2/ui', () => ({
IconView24: () => <span>eye-open</span>,
IconViewOff24: () => <span>eye-off</span>,
}))
jest.mock('../../legend/Legend.jsx', () => () => null)
jest.mock('../../../util/legend.js', () => ({
getRenderingLabel: () => '',
}))

const legend = { title: 'My Layer', period: '2023' }

describe('LegendLayer', () => {
test('renders legend title and period', () => {
render(<LegendLayer id="layer-1" legend={legend} />)
expect(screen.getByText('My Layer')).toBeInTheDocument()
expect(screen.getByText('2023')).toBeInTheDocument()
})

test('renders nothing when legend is not provided', () => {
render(<LegendLayer id="layer-1" />)
expect(screen.queryByText('My Layer')).not.toBeInTheDocument()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})

test('shows eye-open button when layer is visible', () => {
render(
<LegendLayer
id="layer-1"
legend={legend}
isVisible={true}
toggleLayerVisibility={jest.fn()}
/>
)
expect(screen.getByTitle('Hide layer')).toBeInTheDocument()
expect(screen.getByText('eye-open')).toBeInTheDocument()
})

test('shows eye-off button when layer is hidden', () => {
render(
<LegendLayer
id="layer-1"
legend={legend}
isVisible={false}
toggleLayerVisibility={jest.fn()}
/>
)
expect(screen.getByTitle('Show layer')).toBeInTheDocument()
expect(screen.getByText('eye-off')).toBeInTheDocument()
})

test('defaults to visible (eye-open) when isVisible is not provided', () => {
render(
<LegendLayer
id="layer-1"
legend={legend}
toggleLayerVisibility={jest.fn()}
/>
)
expect(screen.getByTitle('Hide layer')).toBeInTheDocument()
})

test('does not render visibility button when toggleLayerVisibility is not provided', () => {
render(<LegendLayer id="layer-1" legend={legend} isVisible={true} />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})

test('calls toggleLayerVisibility with the layer id on button click', () => {
const toggle = jest.fn()
render(
<LegendLayer
id="layer-1"
legend={legend}
isVisible={true}
toggleLayerVisibility={toggle}
/>
)
fireEvent.click(screen.getByRole('button'))
expect(toggle).toHaveBeenCalledWith('layer-1')
expect(toggle).toHaveBeenCalledTimes(1)
})

test('button click does not propagate to parent', () => {
const toggle = jest.fn()
const parentClick = jest.fn()
render(
<button onClick={parentClick}>
<LegendLayer
id="layer-1"
legend={legend}
isVisible={true}
toggleLayerVisibility={toggle}
/>
</button>
)
fireEvent.click(screen.getByTitle('Hide layer'))
expect(parentClick).not.toHaveBeenCalled()
})
})
Loading
Loading