diff --git a/cypress/integration/fetcherrors.cy.js b/cypress/integration/fetcherrors.cy.js
index 8f6e045cbc..e8d29515c6 100644
--- a/cypress/integration/fetcherrors.cy.js
+++ b/cypress/integration/fetcherrors.cy.js
@@ -67,11 +67,9 @@ describe('Fetch errors', () => {
cy.get('canvas', EXTENDED_TIMEOUT).should('be.visible')
cy.getByDataTest('basemapcard', EXTENDED_TIMEOUT).should('be.visible')
- cy.getByDataTest('load-error-noticebox', EXTENDED_TIMEOUT).within(
- () => {
- cy.contains('Failed to load layer').should('be.visible')
- }
- )
+ cy.getByDataTest('layer-alert-error', EXTENDED_TIMEOUT).within(() => {
+ cy.contains('Failed to load layer').should('be.visible')
+ })
})
// TODO - need to make changes in analytics CachedDataProvider to make this test pass
diff --git a/cypress/integration/layers/geojsonlayer.cy.js b/cypress/integration/layers/geojsonlayer.cy.js
index 3cacd25903..6c69fd8d43 100644
--- a/cypress/integration/layers/geojsonlayer.cy.js
+++ b/cypress/integration/layers/geojsonlayer.cy.js
@@ -53,7 +53,7 @@ describe('GeoJSON URL Layer', () => {
.contains(OVERLAY_TITLE)
.should('be.visible')
- cy.getByDataTest('load-error-noticebox').should('not.exist')
+ cy.getByDataTest('layer-alert-error').should('not.exist')
// open the data table
cy.getByDataTest('moremenubutton').first().click()
@@ -127,11 +127,11 @@ describe('GeoJSON URL Layer', () => {
.should('be.visible')
// check that an error is displayed in the layer card
- cy.getByDataTest('load-error-noticebox').should('be.visible')
- cy.getByDataTest('load-error-noticebox')
+ cy.getByDataTest('layer-alert-error').should('be.visible')
+ cy.getByDataTest('layer-alert-error')
.contains('Failed to load layer')
.should('be.visible')
- cy.getByDataTest('load-error-noticebox')
+ cy.getByDataTest('layer-alert-error')
.contains(
'There was a problem with this layer. Contact a system administrator.'
)
@@ -191,11 +191,11 @@ describe('GeoJSON URL Layer', () => {
.should('be.visible')
// check that an error is displayed in the layer card
- cy.getByDataTest('load-error-noticebox').should('be.visible')
- cy.getByDataTest('load-error-noticebox')
+ cy.getByDataTest('layer-alert-error').should('be.visible')
+ cy.getByDataTest('layer-alert-error')
.contains('Failed to load layer')
.should('be.visible')
- cy.getByDataTest('load-error-noticebox')
+ cy.getByDataTest('layer-alert-error')
.contains(
'There was a problem with this layer. Contact a system administrator.'
)
diff --git a/cypress/integration/layers/thematiclayer.cy.js b/cypress/integration/layers/thematiclayer.cy.js
index c7953b05bc..ef8b167190 100644
--- a/cypress/integration/layers/thematiclayer.cy.js
+++ b/cypress/integration/layers/thematiclayer.cy.js
@@ -804,11 +804,11 @@ context('Thematic Layers', () => {
.should('be.visible')
// check that an error is displayed in the layer card
- cy.getByDataTest('load-error-noticebox').should('be.visible')
- cy.getByDataTest('load-error-noticebox')
+ cy.getByDataTest('layer-alert-error').should('be.visible')
+ cy.getByDataTest('layer-alert-error')
.contains('Failed to load layer')
.should('be.visible')
- cy.getByDataTest('load-error-noticebox')
+ cy.getByDataTest('layer-alert-error')
.contains(
'Organisation unit or organisation unit level is not valid'
)
diff --git a/cypress/integration/layers/trackedentitylayer.cy.js b/cypress/integration/layers/trackedentitylayer.cy.js
index 807bcf2064..54600ba5e2 100644
--- a/cypress/integration/layers/trackedentitylayer.cy.js
+++ b/cypress/integration/layers/trackedentitylayer.cy.js
@@ -39,6 +39,25 @@ describe('Tracked Entity Layers', () => {
Layer.validateCardItems(['Malaria Entity'])
})
+ it('shows a warning in the layer card when there is no data', () => {
+ selectTeTypeAndProgram(
+ Layer,
+ 'Malaria Entity',
+ 'Malaria case diagnosis, treatment and investigation'
+ )
+
+ Layer.selectTab('Period')
+ .typeStartDate('1990-01-01')
+ .typeEndDate('1990-12-31')
+ .addToMap()
+
+ Layer.validateDialogClosed(true)
+
+ cy.getByDataTest('layer-alert-warning', EXTENDED_TIMEOUT)
+ .should('contain', 'No data found')
+ .and('contain', "Data you don't have access to is not shown")
+ })
+
it('opens a tracked entity layer popup', () => {
selectTeTypeAndProgram(
Layer,
diff --git a/cypress/integration/requestsErrors.cy.js b/cypress/integration/requestsErrors.cy.js
index 2a17829cdd..4752759a70 100644
--- a/cypress/integration/requestsErrors.cy.js
+++ b/cypress/integration/requestsErrors.cy.js
@@ -45,7 +45,7 @@ describe('Error handling check for all layer types', () => {
409: 'Simulated error with status code 409',
}
- cy.getByDataTest('load-error-noticebox', EXTENDED_TIMEOUT).within(
+ cy.getByDataTest('layer-alert-error', EXTENDED_TIMEOUT).within(
() => {
cy.contains('Failed to load layer').should('be.visible')
cy.contains(errorMessage[error]).should('be.visible')
diff --git a/i18n/en.pot b/i18n/en.pot
index 12d82e100e..0c38b98d07 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-10-05T12:46:47.530Z\n"
-"PO-Revision-Date: 2026-10-05T12:46:47.531Z\n"
+"POT-Creation-Date: 2026-10-06T11:43:15.235Z\n"
+"PO-Revision-Date: 2026-10-06T11:43:15.235Z\n"
msgid "2020"
msgstr "2020"
@@ -710,6 +710,18 @@ msgstr "Duplicate layer"
msgid "Remove layer"
msgstr "Remove layer"
+msgid "Hide details"
+msgstr "Hide details"
+
+msgid "Details"
+msgstr "Details"
+
+msgid "Copied"
+msgstr "Copied"
+
+msgid "Copy"
+msgstr "Copy"
+
msgid "Filters"
msgstr "Filters"
@@ -1064,6 +1076,12 @@ msgstr "End date"
msgid "Not available offline"
msgstr "Not available offline"
+msgid "Legend (a layer has an error)"
+msgstr "Legend (a layer has an error)"
+
+msgid "Legend (a layer has a warning)"
+msgstr "Legend (a layer has a warning)"
+
msgid "Click to unpin legend"
msgstr "Click to unpin legend"
@@ -1732,6 +1750,44 @@ msgstr "Active"
msgid "Completed"
msgstr "Completed"
+msgid ""
+"It may have been deleted, or your access to it may have changed. Contact "
+"your system administrator if you need it."
+msgstr ""
+"It may have been deleted, or your access to it may have changed. Contact "
+"your system administrator if you need it."
+
+msgid "You don't have access to this layer's data"
+msgstr "You don't have access to this layer's data"
+
+msgid "Your access may have changed. Contact your system administrator."
+msgstr "Your access may have changed. Contact your system administrator."
+
+msgid "This layer's program is not available"
+msgstr "This layer's program is not available"
+
+msgid "This layer's tracked entity type is not available"
+msgstr "This layer's tracked entity type is not available"
+
+msgid "This layer's org units are not available"
+msgstr "This layer's org units are not available"
+
+msgid ""
+"Check the period, filters and org units. Data you don't have access to is "
+"not shown."
+msgstr ""
+"Check the period, filters and org units. Data you don't have access to is "
+"not shown."
+
+msgid "Showing the first {{limit}} tracked entities"
+msgstr "Showing the first {{limit}} tracked entities"
+
+msgid "Narrow the period or org units to see all of them."
+msgstr "Narrow the period or org units to see all of them."
+
+msgid "Relationships could not be loaded"
+msgstr "Relationships could not be loaded"
+
msgid "Choropleth"
msgstr "Choropleth"
@@ -1819,6 +1875,9 @@ msgstr "Start/end dates"
msgid "Cancelled"
msgstr "Cancelled"
+msgid "Layer"
+msgstr "Layer"
+
msgid "Could not count org units without coordinates"
msgstr "Could not count org units without coordinates"
@@ -1885,9 +1944,6 @@ msgstr "related"
msgid "Tracked entity"
msgstr "Tracked entity"
-msgid "Displaying first {{pageSize}} tracked entities"
-msgstr "Displaying first {{pageSize}} tracked entities"
-
msgid "not one of"
msgstr "not one of"
@@ -1934,6 +1990,33 @@ msgstr "Tracked entities"
msgid "Org units"
msgstr "Org units"
+msgid "HTTP status"
+msgstr "HTTP status"
+
+msgid "Error code"
+msgstr "Error code"
+
+msgid "Server message"
+msgstr "Server message"
+
+msgid "Request"
+msgstr "Request"
+
+msgid "Problem"
+msgstr "Problem"
+
+msgid "App version"
+msgstr "App version"
+
+msgid "Server version"
+msgstr "Server version"
+
+msgid "Time"
+msgstr "Time"
+
+msgid "Alert"
+msgstr "Alert"
+
msgid "Facility"
msgstr "Facility"
diff --git a/src/components/app/App.jsx b/src/components/app/App.jsx
index 91a8f4ab13..372f273675 100644
--- a/src/components/app/App.jsx
+++ b/src/components/app/App.jsx
@@ -1,7 +1,7 @@
import cx from 'classnames'
import React, { useEffect, useState } from 'react'
import { useSelector } from 'react-redux'
-import { useLayersLoader } from '../../hooks/useLayersLoader.js'
+import { useLoadLayerWithRedux } from '../../hooks/useLoadLayerWithRedux.js'
import BottomPanel from '../datatable/BottomPanel.jsx'
import DownloadModeMenu from '../download/DownloadMenubar.jsx'
import DownloadSettings from '../download/DownloadSettings.jsx'
@@ -30,7 +30,7 @@ const App = () => {
useLoadMap()
useLoadDataStore()
- useLayersLoader()
+ useLoadLayerWithRedux()
const [interpretationsRenderCount, setInterpretationsRenderCount] =
useState(1)
diff --git a/src/components/layers/overlays/OverlayCard.jsx b/src/components/layers/overlays/OverlayCard.jsx
index a490f28cda..0b7242f0a4 100644
--- a/src/components/layers/overlays/OverlayCard.jsx
+++ b/src/components/layers/overlays/OverlayCard.jsx
@@ -17,7 +17,6 @@ import {
import {
ALERT_SUCCESS,
ALERT_MESSAGE_DYNAMIC,
- ERROR_CRITICAL,
} from '../../../constants/alerts.js'
import {
DOWNLOADABLE_LAYER_TYPES,
@@ -30,8 +29,9 @@ import {
APP_URLS,
CURRENT_AO_KEY,
} from '../../../util/analyticalObject.js'
+import { getLayerAlerts, hasLayerError } from '../../../util/layerAlerts.js'
+import LayerAlerts from '../../legend/LayerAlerts.jsx'
import Legend from '../../legend/Legend.jsx'
-import LegendAlert from '../../legend/LegendAlert.jsx'
import DataDownloadDialog from '../download/DataDownloadDialog.jsx'
import LayerCard from '../LayerCard.jsx'
import styles from './styles/OverlayCard.module.css'
@@ -60,35 +60,30 @@ const OverlayCard = ({
isVisible,
layer: layerType,
isLoaded,
- loadError,
} = layer
+ const hasError = hasLayerError(layer)
const canEdit = layerType !== EXTERNAL_LAYER
const canToggleDataTable = DATA_TABLE_LAYER_TYPES.includes(layerType)
const canDownload = DOWNLOADABLE_LAYER_TYPES.includes(layerType)
const canOpenAs = OPEN_AS_LAYER_TYPES.includes(layerType)
- const getCardContent = () => {
- if (loadError) {
- return (
-
-
-
- )
- }
- return (
- legend && (
+ const getCardContent = () => (
+
+
+
+
+ {legend && (
- )
- )
- }
+ )}
+
+ )
return (
<>
@@ -140,7 +135,7 @@ const OverlayCard = ({
}
: undefined
}
- hasError={!!loadError}
+ hasError={hasError}
>
{getCardContent()}
diff --git a/src/components/layers/overlays/__tests__/OverlayCard.spec.jsx b/src/components/layers/overlays/__tests__/OverlayCard.spec.jsx
index 4da6e6ae81..d65e088b81 100644
--- a/src/components/layers/overlays/__tests__/OverlayCard.spec.jsx
+++ b/src/components/layers/overlays/__tests__/OverlayCard.spec.jsx
@@ -2,6 +2,11 @@ import { render, fireEvent, screen } from '@testing-library/react'
import React from 'react'
import { Provider } from 'react-redux'
import configureMockStore from 'redux-mock-store'
+import {
+ LAYER_ALERT_LOAD_FAILED,
+ LAYER_ALERT_NO_DATA,
+} from '../../../../constants/layerAlerts.js'
+import { createLayerAlert } from '../../../../util/layerAlerts.js'
import OverlayCard from '../OverlayCard.jsx'
const mockShow = jest.fn()
@@ -10,9 +15,21 @@ const mockShow = jest.fn()
// is not loadable in jsdom and is irrelevant to this test.
jest.mock('../../download/DataDownloadDialog.jsx', () => () => null)
+jest.mock('../../../legend/Legend.jsx', () => {
+ const PropTypes = require('prop-types')
+ const MockLegend = ({ items }) => (
+ {items?.map((item) => item.name).join(', ')}
+ )
+ MockLegend.propTypes = { items: PropTypes.array }
+ return MockLegend
+})
+
jest.mock('@dhis2/app-runtime', () => ({
...jest.requireActual('@dhis2/app-runtime'),
- useConfig: () => ({ baseUrl: 'http://localhost' }),
+ useConfig: () => ({
+ baseUrl: 'http://localhost',
+ serverVersion: { major: 2, minor: 43 },
+ }),
}))
jest.mock('@dhis2/app-service-datastore', () => ({
@@ -58,4 +75,77 @@ describe('OverlayCard', () => {
msg: 'Children < 5y & "others" deleted.',
})
})
+
+ describe('layer alerts', () => {
+ const renderWithAlerts = (alerts) =>
+ render(
+
+
+
+ )
+
+ test('shows warnings above the legend', () => {
+ const { container } = renderWithAlerts([
+ createLayerAlert(LAYER_ALERT_NO_DATA),
+ ])
+ const text = container.textContent
+
+ expect(text.indexOf('No data found')).toBeGreaterThan(-1)
+ expect(text.indexOf('No data found')).toBeLessThan(
+ text.indexOf('Malaria Entity')
+ )
+ })
+
+ test('shows an older loadError the same way', () => {
+ render(
+
+
+
+ )
+
+ expect(screen.getByTestId('layer-alert-error')).toHaveTextContent(
+ 'Failed to load layerData item was not found'
+ )
+ })
+
+ test('keeps the legend under an error', () => {
+ const { container } = renderWithAlerts([
+ createLayerAlert(LAYER_ALERT_LOAD_FAILED),
+ ])
+ const text = container.textContent
+
+ expect(text.indexOf('Failed to load layer')).toBeGreaterThan(-1)
+ expect(text.indexOf('Failed to load layer')).toBeLessThan(
+ text.indexOf('Malaria Entity')
+ )
+ })
+ })
})
diff --git a/src/components/layers/overlays/styles/OverlayCard.module.css b/src/components/layers/overlays/styles/OverlayCard.module.css
index 24c946363e..5b514039b4 100644
--- a/src/components/layers/overlays/styles/OverlayCard.module.css
+++ b/src/components/layers/overlays/styles/OverlayCard.module.css
@@ -1,11 +1,16 @@
-.legend {
- padding-left: var(--spacers-dp32);
+/* One scroll area for alerts and legend: the card can't be taller than the
+ screen */
+.content {
max-height: 400px;
overflow-y: auto;
overflow-x: hidden;
}
-.loadError {
- padding: var(--spacers-dp8);
+/* Icons centered under the drag handle (left: dp4, 24px wide) */
+.alerts {
+ padding: 0 var(--spacers-dp8) 0 var(--spacers-dp4);
+}
+
+.legend {
padding-left: var(--spacers-dp32);
}
diff --git a/src/components/legend/LayerAlerts.jsx b/src/components/legend/LayerAlerts.jsx
new file mode 100644
index 0000000000..14caee9153
--- /dev/null
+++ b/src/components/legend/LayerAlerts.jsx
@@ -0,0 +1,166 @@
+import { useConfig } from '@dhis2/app-runtime'
+import i18n from '@dhis2/d2-i18n'
+import {
+ IconErrorFilled16,
+ IconInfoFilled16,
+ IconWarningFilled16,
+} from '@dhis2/ui'
+import cx from 'classnames'
+import PropTypes from 'prop-types'
+import React, { useEffect, useState } from 'react'
+import { ALERT_SEVERITY } from '../../constants/layerAlerts.js'
+import {
+ formatAlertDetails,
+ getDetailRows,
+ isLayerAlert,
+ sortBySeverity,
+} from '../../util/layerAlerts.js'
+import styles from './styles/LayerAlerts.module.css'
+
+export const ALERT_ICONS = {
+ [ALERT_SEVERITY.ERROR]: ,
+ [ALERT_SEVERITY.WARNING]: ,
+ [ALERT_SEVERITY.INFO]: ,
+}
+
+const COPIED_DURATION = 2000
+
+// Not available outside HTTPS, or in an iframe without clipboard permission
+const canCopy = () => Boolean(navigator.clipboard?.writeText)
+
+// Clicks must not reach the plugin legend, which pins on click
+const handleClick = (onClick) => (event) => {
+ event.stopPropagation()
+ onClick()
+}
+
+const LayerAlert = ({ alert, layerName, layerType }) => {
+ const { serverVersion, appVersion } = useConfig()
+ const [showDetails, setShowDetails] = useState(false)
+ const [isCopied, setIsCopied] = useState(false)
+ const { severity, title, description, details } = alert
+
+ useEffect(() => {
+ if (isCopied) {
+ const timeout = setTimeout(
+ () => setIsCopied(false),
+ COPIED_DURATION
+ )
+ return () => clearTimeout(timeout)
+ }
+ }, [isCopied])
+
+ const copyDetails = () =>
+ navigator.clipboard
+ .writeText(
+ formatAlertDetails(alert, {
+ layerName,
+ layerType,
+ serverVersion,
+ appVersion,
+ })
+ )
+ .then(() => setIsCopied(true))
+ .catch(() => {}) // The details can still be selected and copied
+
+ return (
+
+
{ALERT_ICONS[severity]}
+
+
{title}
+ {description && (
+
{description}
+ )}
+ {details && (
+ <>
+
+
+ {showDetails && canCopy() && (
+
+ )}
+
+ {showDetails && (
+
+ {getDetailRows(details).map(
+ ([label, value]) => (
+
+
- {label}:
- {value}
+
+ )
+ )}
+
+ )}
+ >
+ )}
+
+
+ )
+}
+
+LayerAlert.propTypes = {
+ alert: PropTypes.shape({
+ severity: PropTypes.oneOf(Object.values(ALERT_SEVERITY)).isRequired,
+ title: PropTypes.string.isRequired,
+ description: PropTypes.string,
+ details: PropTypes.object,
+ id: PropTypes.string,
+ }).isRequired,
+ layerName: PropTypes.string,
+ layerType: PropTypes.string,
+}
+
+const NO_ALERTS = []
+
+const LayerAlerts = ({ alerts = NO_ALERTS, layerName, layerType }) => {
+ const layerAlerts = sortBySeverity(alerts.filter(isLayerAlert))
+
+ if (!layerAlerts.length) {
+ return null
+ }
+
+ return (
+
+ {layerAlerts.map((alert, index) => (
+
+ ))}
+
+ )
+}
+
+LayerAlerts.propTypes = {
+ alerts: PropTypes.array,
+ layerName: PropTypes.string,
+ layerType: PropTypes.string,
+}
+
+export default LayerAlerts
diff --git a/src/components/legend/__tests__/LayerAlerts.spec.jsx b/src/components/legend/__tests__/LayerAlerts.spec.jsx
new file mode 100644
index 0000000000..322987ce4f
--- /dev/null
+++ b/src/components/legend/__tests__/LayerAlerts.spec.jsx
@@ -0,0 +1,118 @@
+import { act, render, fireEvent, screen } from '@testing-library/react'
+import React from 'react'
+import {
+ LAYER_ALERT_NO_DATA,
+ LAYER_ALERT_PROGRAM_UNAVAILABLE,
+} from '../../../constants/layerAlerts.js'
+import { createLayerAlert } from '../../../util/layerAlerts.js'
+import LayerAlerts from '../LayerAlerts.jsx'
+
+jest.mock('@dhis2/app-runtime', () => ({
+ ...jest.requireActual('@dhis2/app-runtime'),
+ useConfig: () => ({
+ serverVersion: { major: 2, minor: 43, patch: 1 },
+ appVersion: { full: '101.17.5', major: 101, minor: 17 },
+ }),
+}))
+
+const error = createLayerAlert(LAYER_ALERT_PROGRAM_UNAVAILABLE, {
+ details: {
+ httpStatusCode: 409,
+ errorCode: 'E7129',
+ message: 'Program not found',
+ },
+})
+const warning = createLayerAlert(LAYER_ALERT_NO_DATA)
+
+describe('LayerAlerts', () => {
+ afterEach(() => {
+ delete navigator.clipboard
+ jest.useRealTimers()
+ })
+
+ it('renders nothing without alerts in this format', () => {
+ const { container } = render(
+
+ )
+ expect(container).toBeEmptyDOMElement()
+ })
+
+ it('renders errors first, with title and description', () => {
+ render()
+
+ const alerts = screen.getAllByTestId(/^layer-alert-/)
+ expect(alerts.map((alert) => alert.dataset.test)).toEqual([
+ 'layer-alert-error',
+ 'layer-alert-warning',
+ ])
+ expect(
+ screen.getByText("This layer's program is not available")
+ ).toBeInTheDocument()
+ expect(screen.getByText(/may have been deleted/)).toBeInTheDocument()
+ })
+
+ it('only offers details when the alert has them', () => {
+ render()
+ expect(screen.queryByText('Details')).not.toBeInTheDocument()
+ })
+
+ it('shows and copies the details', async () => {
+ const writeText = jest.fn().mockResolvedValue()
+ Object.assign(navigator, { clipboard: { writeText } })
+ const onParentClick = jest.fn()
+
+ render(
+
+
+
+ )
+
+ expect(screen.queryByText('Copy')).not.toBeInTheDocument()
+ fireEvent.click(screen.getByText('Details'))
+ expect(screen.getByText('Hide details')).toHaveAttribute(
+ 'aria-expanded',
+ 'true'
+ )
+ const details = screen.getByTestId('layer-alert-details')
+ expect(details).toHaveTextContent('E7129')
+ expect(details).toHaveTextContent('Program not found')
+
+ fireEvent.click(screen.getByText('Copy'))
+ // The copy has everything an administrator needs
+ const copied = writeText.mock.calls[0][0]
+ expect(copied).toContain('Layer: Malaria (trackedEntity)')
+ expect(copied).toContain('App version: 101.17.5')
+ expect(copied).toContain('Server version: 2.43.1')
+ expect(copied).toContain('Server message: Program not found')
+ expect(await screen.findByText('Copied')).toBeInTheDocument()
+ // The plugin legend pins on click
+ expect(onParentClick).not.toHaveBeenCalled()
+ })
+
+ it('shows "Copied" for a moment only', async () => {
+ jest.useFakeTimers()
+ Object.assign(navigator, {
+ clipboard: { writeText: jest.fn().mockResolvedValue() },
+ })
+ render()
+
+ fireEvent.click(screen.getByText('Details'))
+ fireEvent.click(screen.getByText('Copy'))
+ expect(await screen.findByText('Copied')).toBeInTheDocument()
+
+ act(() => jest.advanceTimersByTime(2000))
+ expect(screen.getByText('Copy')).toBeInTheDocument()
+ })
+
+ it('offers no copy without clipboard access', () => {
+ render()
+
+ fireEvent.click(screen.getByText('Details'))
+ expect(screen.getByTestId('layer-alert-details')).toBeInTheDocument()
+ expect(screen.queryByText('Copy')).not.toBeInTheDocument()
+ })
+})
diff --git a/src/components/legend/styles/LayerAlerts.module.css b/src/components/legend/styles/LayerAlerts.module.css
new file mode 100644
index 0000000000..7498a9b22a
--- /dev/null
+++ b/src/components/legend/styles/LayerAlerts.module.css
@@ -0,0 +1,86 @@
+.alerts {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin-bottom: 8px;
+}
+
+.alert {
+ border-radius: 2px;
+ display: flex;
+ font-size: 12px;
+ gap: 10px;
+ line-height: 16px;
+ padding: 6px 8px 6px 4px;
+}
+
+.error {
+ background-color: rgba(214, 41, 41, 0.08);
+ color: var(--colors-red700);
+}
+
+.warning {
+ background-color: rgba(255, 173, 0, 0.12);
+ color: var(--colors-yellow800);
+}
+
+.info {
+ background-color: rgba(20, 124, 215, 0.08);
+ color: var(--colors-blue700);
+}
+
+.icon {
+ flex-shrink: 0;
+ line-height: 0;
+ margin-top: 1px;
+}
+
+.body {
+ min-width: 0;
+}
+
+.title {
+ color: var(--colors-grey900);
+ font-weight: 500;
+}
+
+.description {
+ color: var(--colors-grey800);
+ margin-top: 2px;
+}
+
+.link {
+ background: none;
+ border: none;
+ color: var(--colors-blue700);
+ cursor: pointer;
+ font-size: 12px;
+ margin-top: 4px;
+ padding: 0;
+ text-decoration: underline;
+}
+
+.actions {
+ display: flex;
+ gap: 12px;
+}
+
+.details {
+ background-color: rgba(0, 0, 0, 0.04);
+ color: var(--colors-grey900);
+ font-size: 11px;
+ margin: 4px 0 0;
+ padding: 4px 6px;
+}
+
+.details dt {
+ color: var(--colors-grey700);
+ display: inline;
+}
+
+.details dd {
+ display: inline;
+ font-family: Menlo, Courier, monospace;
+ margin: 0;
+ overflow-wrap: anywhere;
+}
diff --git a/src/components/loaders/__tests__/useLoaderAlerts.spec.js b/src/components/loaders/__tests__/useLoaderAlerts.spec.js
new file mode 100644
index 0000000000..e92728f1f5
--- /dev/null
+++ b/src/components/loaders/__tests__/useLoaderAlerts.spec.js
@@ -0,0 +1,42 @@
+import { renderHook } from '@testing-library/react'
+import {
+ LAYER_ALERT_LOAD_FAILED,
+ LAYER_ALERT_NO_DATA,
+} from '../../../constants/layerAlerts.js'
+import { createLayerAlert } from '../../../util/layerAlerts.js'
+import useLoaderAlerts from '../useLoaderAlerts.js'
+
+const mockShow = jest.fn()
+
+jest.mock('@dhis2/app-service-alerts', () => ({
+ useAlert: () => ({ show: mockShow }),
+}))
+
+describe('useLoaderAlerts', () => {
+ beforeEach(() => {
+ mockShow.mockClear()
+ })
+
+ it('shows older alerts as snackbars', () => {
+ const { result } = renderHook(() => useLoaderAlerts())
+
+ result.current.showAlerts([
+ { code: 'WARNING_NO_DATA', message: 'Thematic layer' },
+ ])
+
+ expect(mockShow).toHaveBeenCalledWith({
+ msg: 'Thematic layer: No data found',
+ })
+ })
+
+ it('leaves layer alerts to the legend', () => {
+ const { result } = renderHook(() => useLoaderAlerts())
+
+ result.current.showAlerts([
+ createLayerAlert(LAYER_ALERT_NO_DATA),
+ createLayerAlert(LAYER_ALERT_LOAD_FAILED),
+ ])
+
+ expect(mockShow).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/components/loaders/useLoaderAlerts.js b/src/components/loaders/useLoaderAlerts.js
index 949bbf936f..e514c7b653 100644
--- a/src/components/loaders/useLoaderAlerts.js
+++ b/src/components/loaders/useLoaderAlerts.js
@@ -10,6 +10,7 @@ import {
ERROR_CRITICAL,
CUSTOM_ALERT,
} from '../../constants/alerts.js'
+import { isLayerAlert } from '../../util/layerAlerts.js'
function useLoaderAlerts(loaderAlertAction = Function.prototype) {
const errorAlert = useAlert(ALERT_MESSAGE_DYNAMIC, {
@@ -44,64 +45,68 @@ function useLoaderAlerts(loaderAlertAction = Function.prototype) {
onHidden: loaderAlertAction,
})
+ // LEGACY-ALERTS: layer alerts are shown in the legend instead. Remove this
+ // hook when every loader returns layer alerts
const showAlerts = (alerts) => {
- alerts.forEach(({ message: msg, code, warning, critical }) => {
- switch (code) {
- case WARNING_NO_DATA: {
- noDataAlert.show({
- msg: `${msg}: ${i18n.t('No data found')}`,
- })
- break
- }
- case WARNING_ALL_EVENTS_OUTSIDE_OU: {
- noDataAlert.show({
- msg: `${msg}: ${i18n.t(
- 'All events outside org unit boundaries'
- )}`,
- })
- break
- }
- case WARNING_NO_OU_COORD: {
- noOUCoordinatesAlert.show({
- msg: i18n.t(
- 'Selected org units: No coordinates found',
- { nsSeparator: '^^' }
- ),
- })
- break
- }
- case WARNING_NO_GEOMETRY_COORD: {
- noGeometryCoordinatesAlert.show({
- msg: `${msg}: ${i18n.t('No coordinates found')}`,
- })
- break
- }
- case WARNING_OU_BOUNDARIES_FETCH_FAILED: {
- ouBoundariesFetchFailedAlert.show({
- msg: `${msg}: ${i18n.t(
- 'Could not check org unit boundaries'
- )}`,
- })
- break
- }
- case ERROR_CRITICAL: {
- errorAlert.show({ msg: `${i18n.t('Error')}: ${msg}` })
- break
- }
- case CUSTOM_ALERT: {
- if (critical) {
- errorAlert.show({ msg })
- } else if (warning) {
- warningAlert.show({ msg })
- } else {
- infoAlert.show({ msg })
+ alerts
+ .filter((alert) => !isLayerAlert(alert))
+ .forEach(({ message: msg, code, warning, critical }) => {
+ switch (code) {
+ case WARNING_NO_DATA: {
+ noDataAlert.show({
+ msg: `${msg}: ${i18n.t('No data found')}`,
+ })
+ break
+ }
+ case WARNING_ALL_EVENTS_OUTSIDE_OU: {
+ noDataAlert.show({
+ msg: `${msg}: ${i18n.t(
+ 'All events outside org unit boundaries'
+ )}`,
+ })
+ break
+ }
+ case WARNING_NO_OU_COORD: {
+ noOUCoordinatesAlert.show({
+ msg: i18n.t(
+ 'Selected org units: No coordinates found',
+ { nsSeparator: '^^' }
+ ),
+ })
+ break
+ }
+ case WARNING_NO_GEOMETRY_COORD: {
+ noGeometryCoordinatesAlert.show({
+ msg: `${msg}: ${i18n.t('No coordinates found')}`,
+ })
+ break
+ }
+ case WARNING_OU_BOUNDARIES_FETCH_FAILED: {
+ ouBoundariesFetchFailedAlert.show({
+ msg: `${msg}: ${i18n.t(
+ 'Could not check org unit boundaries'
+ )}`,
+ })
+ break
+ }
+ case ERROR_CRITICAL: {
+ errorAlert.show({ msg: `${i18n.t('Error')}: ${msg}` })
+ break
+ }
+ case CUSTOM_ALERT: {
+ if (critical) {
+ errorAlert.show({ msg })
+ } else if (warning) {
+ warningAlert.show({ msg })
+ } else {
+ infoAlert.show({ msg })
+ }
+ break
}
- break
+ default:
+ break
}
- default:
- break
- }
- })
+ })
}
return { showAlerts }
diff --git a/src/components/plugin/LayerLoader.jsx b/src/components/plugin/LayerLoader.jsx
deleted file mode 100644
index 8ecbdb4f0c..0000000000
--- a/src/components/plugin/LayerLoader.jsx
+++ /dev/null
@@ -1,101 +0,0 @@
-import { Analytics, useDataOutputPeriodTypes } from '@dhis2/analytics'
-import { useDataEngine, useConfig } from '@dhis2/app-runtime'
-import PropTypes from 'prop-types'
-import { useState, useEffect, useRef } from 'react'
-import { THEMATIC_LAYER, EVENT_LAYER } from '../../constants/layers.js'
-import earthEngineLoader from '../../loaders/earthEngineLoader.js'
-import eventLoader from '../../loaders/eventLoader.js'
-import externalLoader from '../../loaders/externalLoader.js'
-import facilityLoader from '../../loaders/facilityLoader.js'
-import geoJsonUrlLoader from '../../loaders/geoJsonUrlLoader.js'
-import orgUnitLoader from '../../loaders/orgUnitLoader.js'
-import thematicLoader from '../../loaders/thematicLoader.js'
-import trackedEntityLoader from '../../loaders/trackedEntityLoader.js'
-import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx'
-
-const loaders = {
- earthEngine: earthEngineLoader,
- event: eventLoader,
- external: externalLoader,
- facility: facilityLoader,
- orgUnit: orgUnitLoader,
- thematic: thematicLoader,
- geoJsonUrl: geoJsonUrlLoader,
- trackedEntity: trackedEntityLoader,
-}
-
-const LayerLoader = ({ config, onLoad }) => {
- const { baseUrl, serverVersion } = useConfig()
- const engine = useDataEngine()
- const [analyticsEngine] = useState(() => Analytics.getAnalytics(engine))
- const {
- systemSettings: {
- keyAnalysisDigitGroupSeparator,
- KeyTrackedEntityInstanceMaxLimit,
- KeyTrackedEntityMaxLimit,
- },
- currentUser,
- } = useCachedData()
- const {
- keyAnalysisDisplayProperty,
- id: userId,
- userOrgUnitIdsByKeyword,
- } = currentUser
- const periodTypeData = useDataOutputPeriodTypes()
- const loadedConfig = useRef(null)
-
- useEffect(() => {
- if (
- periodTypeData?.supportsEnabledPeriodTypes &&
- !periodTypeData?.enabledPeriodTypesData &&
- [THEMATIC_LAYER, EVENT_LAYER].includes(config.layer)
- ) {
- return
- }
- // Loaders mutate config, so never load the same object twice
- if (loadedConfig.current === config) {
- return
- }
- loadedConfig.current = config
- const loader = loaders[config.layer]
- loader({
- config,
- engine,
- keyAnalysisDisplayProperty, // name/shortName
- keyAnalysisDigitGroupSeparator, // NONE/SPACE/COMMA
- userId,
- userOrgUnitIdsByKeyword, // Event loader
- baseUrl,
- analyticsEngine, // Thematic, Event and Tracked entity loader
- periodTypeData, // Thematic and Event loader
- serverVersion, // Tracked entity loader
- KeyTrackedEntityInstanceMaxLimit, // Tracked entity loader (2.40)
- KeyTrackedEntityMaxLimit, // Tracked entity loader
- }).then((result) => {
- onLoad(result)
- })
- }, [
- config,
- onLoad,
- engine,
- analyticsEngine,
- periodTypeData,
- userId,
- userOrgUnitIdsByKeyword,
- baseUrl,
- keyAnalysisDisplayProperty,
- keyAnalysisDigitGroupSeparator,
- serverVersion,
- KeyTrackedEntityInstanceMaxLimit,
- KeyTrackedEntityMaxLimit,
- ])
-
- return null
-}
-
-LayerLoader.propTypes = {
- config: PropTypes.object.isRequired,
- onLoad: PropTypes.func.isRequired,
-}
-
-export default LayerLoader
diff --git a/src/components/plugin/Legend.jsx b/src/components/plugin/Legend.jsx
index 8e9277b412..397b6aa31a 100644
--- a/src/components/plugin/Legend.jsx
+++ b/src/components/plugin/Legend.jsx
@@ -2,9 +2,20 @@ import i18n from '@dhis2/d2-i18n'
import cx from 'classnames'
import PropTypes from 'prop-types'
import React, { useState, useEffect } from 'react'
+import { ALERT_SEVERITY } from '../../constants/layerAlerts.js'
+import { getMostSevereAlert } from '../../util/layerAlerts.js'
+import { ALERT_ICONS } from '../legend/LayerAlerts.jsx'
import LegendLayer from './LegendLayer.jsx'
import './styles/Legend.css'
+// Shown on the closed legend, as alerts are only visible when it is open
+const BADGE_SEVERITIES = [ALERT_SEVERITY.ERROR, ALERT_SEVERITY.WARNING]
+
+const getBadgeTitle = (severity) =>
+ severity === ALERT_SEVERITY.ERROR
+ ? i18n.t('Legend (a layer has an error)')
+ : i18n.t('Legend (a layer has a warning)')
+
// Renders a legend for all map layers
const Legend = ({ layers, toggleLayerVisibility, isFullscreen }) => {
const [isOpen, setIsOpen] = useState(false)
@@ -20,8 +31,11 @@ const Legend = ({ layers, toggleLayerVisibility, isFullscreen }) => {
}
}, [isFullscreen])
+ const badgeSeverity = getMostSevereAlert(layers)?.severity
+ const hasBadge = BADGE_SEVERITIES.includes(badgeSeverity)
+
const legendLayers = layers
- .filter((layer) => layer.legend || layer.alerts)
+ .filter((layer) => layer.legend || layer.alerts?.length)
.reverse() // Show top layer first
return (
@@ -51,9 +65,25 @@ const Legend = ({ layers, toggleLayerVisibility, isFullscreen }) => {
) : (
setIsOpen(true)}
- />
+ >
+ {hasBadge && (
+
+ {ALERT_ICONS[badgeSeverity]}
+
+ )}
+
)}
)
diff --git a/src/components/plugin/LegendLayer.jsx b/src/components/plugin/LegendLayer.jsx
index dd62127204..510d277822 100644
--- a/src/components/plugin/LegendLayer.jsx
+++ b/src/components/plugin/LegendLayer.jsx
@@ -2,16 +2,40 @@ import i18n from '@dhis2/d2-i18n'
import { IconView24, IconViewOff24 } from '@dhis2/ui'
import PropTypes from 'prop-types'
import React, { Fragment } from 'react'
+import { isLayerAlert } from '../../util/layerAlerts.js'
import { getRenderingLabel } from '../../util/legend.js'
+import LayerAlerts from '../legend/LayerAlerts.jsx'
import LayerLegend from '../legend/Legend.jsx'
import LegendAlert from '../legend/LegendAlert.jsx'
const DEFAULT_NO_ALERTS = []
+// Shown between the title and the legend, like in the app layer card
+const LegendLayerAlerts = ({ alerts, name, layerType }) => (
+ <>
+
+ {/* LEGACY-ALERTS: remove when every loader returns layer alerts */}
+ {alerts
+ .filter((alert) => !isLayerAlert(alert))
+ .map((alert, index) => (
+
+
+
+ ))}
+ >
+)
+
+LegendLayerAlerts.propTypes = {
+ alerts: PropTypes.array.isRequired,
+ layerType: PropTypes.string,
+ name: PropTypes.string,
+}
+
// Renders a legend with alerts for one map layer
const LegendLayer = ({
id,
name,
+ layer: layerType,
legend,
renderingStrategy,
alerts = DEFAULT_NO_ALERTS,
@@ -24,6 +48,13 @@ const LegendLayer = ({
{name}
)}
+ {!legend && (
+
+ )}
{legend && (
@@ -51,14 +82,14 @@ const LegendLayer = ({
)}
+
)}
- {alerts.map((alert, index) => (
-
-
-
- ))}
)
diff --git a/src/components/plugin/Map.jsx b/src/components/plugin/Map.jsx
index 57c2caea97..32ab0cf4b5 100644
--- a/src/components/plugin/Map.jsx
+++ b/src/components/plugin/Map.jsx
@@ -12,11 +12,11 @@ import React, {
useEffect,
useRef,
} from 'react'
+import { useLoadLayer } from '../../hooks/useLoadLayer.js'
import { drillUpDown } from '../../util/map.js'
import { didViewsChange } from '../../util/pluginHelper.js'
import MapView from '../map/MapView.jsx'
import ContextMenu from './ContextMenu.jsx'
-import LayerLoader from './LayerLoader.jsx'
import Legend from './Legend.jsx'
import styles from './styles/Map.module.css'
@@ -91,6 +91,30 @@ const Map = forwardRef((props, ref) => {
}
}, [])
+ // Loads each config object once, when it can be loaded. Runs after every
+ // render: new views, a drill or period types arriving can all allow more
+ const { loadLayer, canLoadLayer } = useLoadLayer()
+ const requestedConfigs = useRef(new WeakSet())
+
+ useEffect(() => {
+ layers.current
+ .filter(
+ (config) =>
+ !config.isLoaded &&
+ !requestedConfigs.current.has(config) &&
+ canLoadLayer(config)
+ )
+ .forEach((config) => {
+ requestedConfigs.current.add(config)
+ loadLayer(config).then((layer) => {
+ // Views changed meanwhile: the result is outdated
+ if (layers.current.includes(config)) {
+ onLayerLoad(layer)
+ }
+ })
+ })
+ })
+
// TODO: Remove when map.js is refactored
useEffect(() => {
if (getResizeFunction) {
@@ -140,17 +164,9 @@ const Map = forwardRef((props, ref) => {
}
if (!mapIsLoaded) {
- const layersToLoad = layers.current.filter((config) => !config.isLoaded)
return (
- {layersToLoad.map((config) => (
-
- ))}
)
}
diff --git a/src/components/plugin/__tests__/LayerLoader.spec.jsx b/src/components/plugin/__tests__/LayerLoader.spec.jsx
deleted file mode 100644
index 2831aeac74..0000000000
--- a/src/components/plugin/__tests__/LayerLoader.spec.jsx
+++ /dev/null
@@ -1,99 +0,0 @@
-import { useDataOutputPeriodTypes } from '@dhis2/analytics'
-import { render, waitFor } from '@testing-library/react'
-import React from 'react'
-import earthEngineLoader from '../../../loaders/earthEngineLoader.js'
-import thematicLoader from '../../../loaders/thematicLoader.js'
-import LayerLoader from '../LayerLoader.jsx'
-
-jest.mock('@dhis2/analytics', () => ({
- Analytics: { getAnalytics: jest.fn(() => ({})) },
- useDataOutputPeriodTypes: jest.fn(),
-}))
-jest.mock('@dhis2/app-runtime', () => ({
- useConfig: () => ({ baseUrl: '', serverVersion: { minor: 43 } }),
- useDataEngine: () => ({}),
-}))
-jest.mock('../../cachedDataProvider/CachedDataProvider.jsx', () => ({
- useCachedData: () => ({
- systemSettings: { keyAnalysisDigitGroupSeparator: 'SPACE' },
- currentUser: {
- keyAnalysisDisplayProperty: 'name',
- id: 'user',
- userOrgUnitIdsByKeyword: {},
- },
- }),
-}))
-jest.mock('../../../loaders/earthEngineLoader.js', () => jest.fn())
-jest.mock('../../../loaders/thematicLoader.js', () => jest.fn())
-
-const pendingPeriodTypes = {
- supportsEnabledPeriodTypes: true,
- enabledPeriodTypesData: null,
-}
-const loadedPeriodTypes = {
- supportsEnabledPeriodTypes: true,
- enabledPeriodTypesData: { enabledTypes: [] },
-}
-
-describe('LayerLoader', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- earthEngineLoader.mockResolvedValue({ id: 'ee', isLoaded: true })
- thematicLoader.mockResolvedValue({ id: 'th', isLoaded: true })
- })
-
- test('loads an earth engine layer once when period types resolve later', async () => {
- const config = { id: 'ee', layer: 'earthEngine' }
- const onLoad = jest.fn()
-
- useDataOutputPeriodTypes.mockReturnValue(pendingPeriodTypes)
- const { rerender } = render(
-
- )
-
- useDataOutputPeriodTypes.mockReturnValue(loadedPeriodTypes)
- rerender()
-
- await waitFor(() => expect(onLoad).toHaveBeenCalled())
- expect(earthEngineLoader).toHaveBeenCalledTimes(1)
- expect(onLoad).toHaveBeenCalledTimes(1)
- })
-
- test('waits for period types before loading a thematic layer', async () => {
- const config = { id: 'th', layer: 'thematic' }
- const onLoad = jest.fn()
-
- useDataOutputPeriodTypes.mockReturnValue(pendingPeriodTypes)
- const { rerender } = render(
-
- )
- expect(thematicLoader).not.toHaveBeenCalled()
-
- useDataOutputPeriodTypes.mockReturnValue(loadedPeriodTypes)
- rerender()
-
- await waitFor(() => expect(onLoad).toHaveBeenCalled())
- expect(thematicLoader).toHaveBeenCalledTimes(1)
- })
-
- test('reloads when a new config is passed', async () => {
- const onLoad = jest.fn()
- useDataOutputPeriodTypes.mockReturnValue(loadedPeriodTypes)
-
- const { rerender } = render(
-
- )
- rerender(
-
- )
-
- await waitFor(() => expect(onLoad).toHaveBeenCalledTimes(2))
- expect(earthEngineLoader).toHaveBeenCalledTimes(2)
- })
-})
diff --git a/src/components/plugin/__tests__/Legend.spec.jsx b/src/components/plugin/__tests__/Legend.spec.jsx
index fee19cb36f..62c1f02e63 100644
--- a/src/components/plugin/__tests__/Legend.spec.jsx
+++ b/src/components/plugin/__tests__/Legend.spec.jsx
@@ -2,7 +2,6 @@ import { render, screen, fireEvent } from '@testing-library/react'
import React from 'react'
import Legend from '../Legend.jsx'
-jest.mock('@dhis2/d2-i18n', () => ({ t: (s) => s }))
jest.mock('../LegendLayer.jsx', () => () => null)
const layers = [{ id: 'layer-1', legend: { title: 'My Layer' } }]
@@ -72,4 +71,42 @@ describe('Legend', () => {
expect(screen.getByTitle('Click to unpin legend')).toBeInTheDocument()
})
+
+ test.each([
+ ['error', 'Legend (a layer has an error)'],
+ ['warning', 'Legend (a layer has a warning)'],
+ ])('shows a %s badge on the closed legend', (severity, title) => {
+ render(
+
+ )
+
+ expect(
+ screen.getByTestId(`legend-badge-${severity}`)
+ ).toBeInTheDocument()
+ expect(screen.getByTitle(title)).toBeInTheDocument()
+ })
+
+ test('shows no badge for info or older alerts', () => {
+ render(
+
+ )
+
+ expect(screen.queryByTestId(/^legend-badge-/)).not.toBeInTheDocument()
+ expect(screen.getByTitle('Legend')).toBeInTheDocument()
+ })
})
diff --git a/src/components/plugin/__tests__/LegendLayer.spec.jsx b/src/components/plugin/__tests__/LegendLayer.spec.jsx
index 8bff201004..0011ceb5f0 100644
--- a/src/components/plugin/__tests__/LegendLayer.spec.jsx
+++ b/src/components/plugin/__tests__/LegendLayer.spec.jsx
@@ -1,13 +1,27 @@
import { render, screen, fireEvent } from '@testing-library/react'
import React from 'react'
+import {
+ LAYER_ALERT_LOAD_FAILED,
+ LAYER_ALERT_NO_DATA,
+} from '../../../constants/layerAlerts.js'
+import { createLayerAlert } from '../../../util/layerAlerts.js'
import LegendLayer from '../LegendLayer.jsx'
jest.mock('@dhis2/d2-i18n', () => ({ t: (s) => s }))
jest.mock('@dhis2/ui', () => ({
IconView24: () => eye-open,
IconViewOff24: () => eye-off,
+ IconErrorFilled16: () => error-icon,
+ IconWarningFilled16: () => warning-icon,
+ IconInfoFilled16: () => info-icon,
}))
-jest.mock('../../legend/Legend.jsx', () => () => null)
+jest.mock('@dhis2/app-runtime', () => ({
+ useConfig: () => ({ serverVersion: { major: 2, minor: 43 } }),
+}))
+jest.mock('../../legend/Legend.jsx', () => {
+ const MockLegend = () => legend-items
+ return MockLegend
+})
jest.mock('../../../util/legend.js', () => ({
getRenderingLabel: () => '',
}))
@@ -100,4 +114,34 @@ describe('LegendLayer', () => {
fireEvent.click(screen.getByTitle('Hide layer'))
expect(parentClick).not.toHaveBeenCalled()
})
+
+ test('shows layer alerts between the title and the legend', () => {
+ const { container } = render(
+
+ )
+ const text = container.textContent
+ expect(text.indexOf('My Layer')).toBeLessThan(
+ text.indexOf('No data found')
+ )
+ expect(text.indexOf('No data found')).toBeLessThan(
+ text.indexOf('legend-items')
+ )
+ })
+
+ test('keeps the legend under an error', () => {
+ render(
+
+ )
+ expect(screen.getByText('Failed to load layer')).toBeInTheDocument()
+ expect(screen.getByText('legend-items')).toBeInTheDocument()
+ })
})
diff --git a/src/components/plugin/__tests__/Map.spec.jsx b/src/components/plugin/__tests__/Map.spec.jsx
index ee4646f42f..aff339a0c2 100644
--- a/src/components/plugin/__tests__/Map.spec.jsx
+++ b/src/components/plugin/__tests__/Map.spec.jsx
@@ -1,28 +1,22 @@
-/* eslint-disable react/prop-types */
import { render, screen, act } from '@testing-library/react'
import React from 'react'
import Map from '../Map.jsx'
-const mockMounted = new Set()
-const mockOnLoad = {}
const mockLoads = {}
+const mockResolve = {}
+const mockCanLoad = { thematic: true }
-jest.mock('../LayerLoader.jsx', () => {
- const { useEffect } = require('react')
- const LayerLoader = ({ config, onLoad }) => {
- // LayerLoader loads each config object it receives once
- useEffect(() => {
+jest.mock('../../../hooks/useLoadLayer.js', () => ({
+ useLoadLayer: () => ({
+ loadLayer: (config) => {
mockLoads[config.id] = (mockLoads[config.id] || 0) + 1
- }, [config])
- useEffect(() => {
- mockMounted.add(config.id)
- mockOnLoad[config.id] = onLoad
- return () => mockMounted.delete(config.id)
- }, [config, onLoad])
- return null
- }
- return LayerLoader
-})
+ return new Promise((resolve) => {
+ mockResolve[config.id] = resolve
+ })
+ },
+ canLoadLayer: (config) => mockCanLoad[config.layer] ?? true,
+ }),
+}))
jest.mock('../../map/MapView.jsx', () => {
const MapView = () => MapView
return MapView
@@ -35,26 +29,45 @@ const mapViews = [
{ id: 'th', layer: 'thematic', filters: [], rows: [] },
]
+const loaded = (view) => ({ ...view, isLoaded: true })
+const resize = () => act(() => window.dispatchEvent(new Event('resize')))
+const finish = async (view) => {
+ await act(async () => mockResolve[view.id](loaded(view)))
+}
+
describe('Plugin Map', () => {
beforeEach(() => {
- mockMounted.clear()
Object.keys(mockLoads).forEach((id) => delete mockLoads[id])
+ mockCanLoad.thematic = true
})
- test('keeps loaders mounted for pending layers (e.g. waiting for period types) when re-rendered', () => {
+ test('loads every layer, then shows the map', async () => {
render()
- expect([...mockMounted].sort()).toEqual(['ee', 'th'])
+ expect(mockLoads).toEqual({ ee: 1, th: 1 })
+
+ await finish(mapViews[0])
+ expect(screen.queryByText('MapView')).not.toBeInTheDocument()
- act(() => mockOnLoad.ee({ id: 'ee', isLoaded: true }))
- act(() => window.dispatchEvent(new Event('resize')))
+ await finish(mapViews[1])
+ expect(screen.getByText('MapView')).toBeInTheDocument()
+ })
- expect(mockMounted.has('th')).toBe(true)
+ test('waits for period types before loading a thematic layer', async () => {
+ mockCanLoad.thematic = false
+ const { rerender } = render()
+ expect(mockLoads).toEqual({ ee: 1 })
- act(() => mockOnLoad.th({ id: 'th', isLoaded: true }))
+ // Period types arrive: the next render loads it
+ mockCanLoad.thematic = true
+ rerender()
+ expect(mockLoads).toEqual({ ee: 1, th: 1 })
+
+ await finish(mapViews[0])
+ await finish(mapViews[1])
expect(screen.getByText('MapView')).toBeInTheDocument()
})
- test('loads each layer once when re-rendered while loading', () => {
+ test('loads each layer once when re-rendered while loading', async () => {
const views = [
{ id: 'ee', layer: 'earthEngine' },
{ id: 'fa', layer: 'facility', filters: [], rows: [] },
@@ -62,24 +75,59 @@ describe('Plugin Map', () => {
]
// Props from the dashboard arrive as fresh copies of the same views
const copyViews = () => JSON.parse(JSON.stringify(views))
- const loaded = (view) => ({ ...view, isLoaded: true })
- const resize = () =>
- act(() => window.dispatchEvent(new Event('resize')))
const { rerender } = render()
resize()
rerender()
- act(() => mockOnLoad.ee(loaded(views[0])))
+ await finish(views[0])
resize()
rerender()
- act(() => mockOnLoad.fa(loaded(views[1])))
+ await finish(views[1])
resize()
expect(mockLoads).toEqual({ ee: 1, fa: 1, th: 1 })
- act(() => mockOnLoad.th(loaded(views[2])))
+ await finish(views[2])
+ expect(screen.getByText('MapView')).toBeInTheDocument()
+ })
+
+ test('loads again when the views change', async () => {
+ const { rerender } = render()
+ await finish(mapViews[0])
+ await finish(mapViews[1])
+
+ // e.g. a dashboard filter adds a period filter
+ rerender(
+
+ )
+
+ expect(mockLoads).toEqual({ ee: 2, th: 2 })
+ })
+
+ test('ignores a load that finishes after the views changed', async () => {
+ const changedViews = [
+ mapViews[0],
+ { ...mapViews[1], filters: [{ dimension: 'pe' }] },
+ ]
+ const { rerender } = render()
+ const resolveFirstThematic = mockResolve.th
+
+ rerender()
+ await finish(mapViews[0])
+ // The first thematic load finishes last, with the old filters
+ await act(async () => resolveFirstThematic(loaded(mapViews[1])))
+
+ expect(screen.queryByText('MapView')).not.toBeInTheDocument()
+
+ await finish(changedViews[1])
expect(screen.getByText('MapView')).toBeInTheDocument()
})
})
diff --git a/src/components/plugin/styles/Legend.css b/src/components/plugin/styles/Legend.css
index 4f7ffc86b1..bff0fb9bee 100644
--- a/src/components/plugin/styles/Legend.css
+++ b/src/components/plugin/styles/Legend.css
@@ -22,6 +22,7 @@
.dhis2-map-legend-button {
cursor: pointer;
+ position: relative;
width: 26px;
height: 26px;
background-position: 50% 50%;
@@ -30,6 +31,25 @@
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAy5pVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMDY3IDc5LjE1Nzc0NywgMjAxNS8wMy8zMC0yMzo0MDo0MiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIEVsZW1lbnRzIDE0LjAgKE1hY2ludG9zaCkiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6M0VDMTEyQzZDRTU4MTFFNTk1Q0ZGQzk2MTlGNkY2NjYiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6M0VDMTEyQzdDRTU4MTFFNTk1Q0ZGQzk2MTlGNkY2NjYiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo5QkJDODIwOUNFNTYxMUU1OTVDRkZDOTYxOUY2RjY2NiIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo5QkJDODIwQUNFNTYxMUU1OTVDRkZDOTYxOUY2RjY2NiIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PjzimhsAAACgSURBVHjaYqxXZ/nPgAN8+A2WYgQRE+7+YaAFYGIYYMBCqgZGRsb/wyoEhl4U2FhbDawDjhw9xkhVB0Cz2sCFgAArI14fkepAmofAgCfCw0eODrNsmKX2D2ccPP/JBK8LRgui0bpgtC6gWV0AzWoDFwKS7P/w+ohUB9I8BEbrAqqHgK2PJc6S7ePnr6N1wWhdMFoX0L4ugGa1AQsBgAADAHQ1NZdoV4oTAAAAAElFTkSuQmCC');
}
+.dhis2-map-legend-badge {
+ position: absolute;
+ top: -7px;
+ right: -7px;
+ display: flex;
+ border-radius: 50%;
+ background-color: #fff;
+ box-shadow: 0 0 0 1px #fff;
+ line-height: 0;
+}
+
+.dhis2-map-legend-badge.error {
+ color: var(--colors-red700);
+}
+
+.dhis2-map-legend-badge.warning {
+ color: var(--colors-yellow800);
+}
+
.dhis2-map-legend-content {
cursor: pointer;
min-width: 150px;
diff --git a/src/constants/layerAlerts.js b/src/constants/layerAlerts.js
new file mode 100644
index 0000000000..b78dcb9ead
--- /dev/null
+++ b/src/constants/layerAlerts.js
@@ -0,0 +1,95 @@
+import i18n from '@dhis2/d2-i18n'
+
+export const ALERT_SEVERITY = {
+ ERROR: 'error',
+ WARNING: 'warning',
+ INFO: 'info',
+}
+
+const { ERROR, WARNING, INFO } = ALERT_SEVERITY
+
+export const SEVERITY_ORDER = [ERROR, WARNING, INFO]
+
+// Layer alert ids
+export const LAYER_ALERT_LOAD_FAILED = 'LOAD_FAILED'
+export const LAYER_ALERT_NO_ACCESS = 'NO_ACCESS'
+export const LAYER_ALERT_PROGRAM_UNAVAILABLE = 'PROGRAM_UNAVAILABLE'
+export const LAYER_ALERT_TRACKED_ENTITY_TYPE_UNAVAILABLE =
+ 'TRACKED_ENTITY_TYPE_UNAVAILABLE'
+export const LAYER_ALERT_ORG_UNITS_UNAVAILABLE = 'ORG_UNITS_UNAVAILABLE'
+export const LAYER_ALERT_NO_DATA = 'NO_DATA'
+export const LAYER_ALERT_TRACKED_ENTITIES_TRUNCATED =
+ 'TRACKED_ENTITIES_TRUNCATED'
+export const LAYER_ALERT_RELATIONSHIPS_FAILED = 'RELATIONSHIPS_FAILED'
+
+// Deleted and no longer accessible look the same to the app: say both
+const unavailableHint = () =>
+ i18n.t(
+ 'It may have been deleted, or your access to it may have changed. Contact your system administrator if you need it.'
+ )
+
+export const LAYER_ALERTS = {
+ [LAYER_ALERT_LOAD_FAILED]: {
+ severity: ERROR,
+ title: () => i18n.t('Failed to load layer'),
+ },
+ [LAYER_ALERT_NO_ACCESS]: {
+ severity: ERROR,
+ title: () => i18n.t("You don't have access to this layer's data"),
+ description: () =>
+ i18n.t(
+ 'Your access may have changed. Contact your system administrator.'
+ ),
+ },
+ [LAYER_ALERT_PROGRAM_UNAVAILABLE]: {
+ severity: ERROR,
+ title: () => i18n.t("This layer's program is not available"),
+ description: unavailableHint,
+ },
+ [LAYER_ALERT_TRACKED_ENTITY_TYPE_UNAVAILABLE]: {
+ severity: ERROR,
+ title: () =>
+ i18n.t("This layer's tracked entity type is not available"),
+ description: unavailableHint,
+ },
+ [LAYER_ALERT_ORG_UNITS_UNAVAILABLE]: {
+ severity: ERROR,
+ title: () => i18n.t("This layer's org units are not available"),
+ description: unavailableHint,
+ },
+ [LAYER_ALERT_NO_DATA]: {
+ severity: WARNING,
+ title: () => i18n.t('No data found'),
+ description: () =>
+ i18n.t(
+ "Check the period, filters and org units. Data you don't have access to is not shown."
+ ),
+ },
+ [LAYER_ALERT_TRACKED_ENTITIES_TRUNCATED]: {
+ severity: WARNING,
+ title: ({ limit }) =>
+ i18n.t('Showing the first {{limit}} tracked entities', { limit }),
+ description: () =>
+ i18n.t('Narrow the period or org units to see all of them.'),
+ },
+ [LAYER_ALERT_RELATIONSHIPS_FAILED]: {
+ severity: WARNING,
+ title: () => i18n.t('Relationships could not be loaded'),
+ description: unavailableHint,
+ },
+}
+
+// DHIS2 error codes with a more specific alert than LOAD_FAILED, from
+// analytics (E7...) and the tracker API (E1...)
+export const ERROR_CODE_ALERTS = {
+ E1003: LAYER_ALERT_PROGRAM_UNAVAILABLE,
+ E7120: LAYER_ALERT_ORG_UNITS_UNAVAILABLE,
+ E7125: LAYER_ALERT_TRACKED_ENTITY_TYPE_UNAVAILABLE,
+ E7129: LAYER_ALERT_PROGRAM_UNAVAILABLE,
+ E7143: LAYER_ALERT_ORG_UNITS_UNAVAILABLE,
+}
+
+// Analytics can't be used, but the tracker API can: the type has no events
+// or its tables were never generated (E7144), or the user can't view event
+// analytics (E7217)
+export const ANALYTICS_FALLBACK_CODES = new Set(['E7144', 'E7217'])
diff --git a/src/hooks/__tests__/useLoadLayer.spec.js b/src/hooks/__tests__/useLoadLayer.spec.js
new file mode 100644
index 0000000000..8584403f78
--- /dev/null
+++ b/src/hooks/__tests__/useLoadLayer.spec.js
@@ -0,0 +1,145 @@
+import { useDataOutputPeriodTypes } from '@dhis2/analytics'
+import { renderHook } from '@testing-library/react'
+import earthEngineLoader from '../../loaders/earthEngineLoader.js'
+import { useLoadLayer } from '../useLoadLayer.js'
+
+jest.mock('@dhis2/analytics', () => ({
+ Analytics: { getAnalytics: jest.fn(() => ({ analytics: true })) },
+ useDataOutputPeriodTypes: jest.fn(),
+}))
+jest.mock('@dhis2/app-runtime', () => ({
+ useConfig: () => ({ baseUrl: 'base', serverVersion: { minor: 43 } }),
+ useDataEngine: () => ({ engine: true }),
+}))
+jest.mock('../../components/cachedDataProvider/CachedDataProvider.jsx', () => ({
+ useCachedData: () => ({
+ systemSettings: {
+ keyAnalysisDigitGroupSeparator: 'SPACE',
+ KeyTrackedEntityMaxLimit: 50000,
+ },
+ currentUser: {
+ keyAnalysisDisplayProperty: 'name',
+ id: 'user',
+ userOrgUnitIdsByKeyword: {},
+ },
+ }),
+}))
+jest.mock('../../loaders/earthEngineLoader.js', () => jest.fn())
+jest.mock('../../components/map/MapApi.js', () => ({
+ loadEarthEngineWorker: jest.fn(),
+ poleOfInaccessibility: jest.fn(),
+}))
+
+const config = { id: 'layer1', layer: 'earthEngine', name: 'Heat stress' }
+
+const renderLoadLayer = (periodTypeData) => {
+ useDataOutputPeriodTypes.mockReturnValue(periodTypeData)
+ return renderHook(() => useLoadLayer()).result.current
+}
+
+describe('useLoadLayer', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ jest.spyOn(console, 'error').mockImplementation(() => {})
+ })
+
+ afterEach(() => {
+ console.error.mockRestore()
+ })
+
+ describe('canLoadLayer', () => {
+ it('waits for period types for thematic and event layers only', () => {
+ const { canLoadLayer } = renderLoadLayer({
+ supportsEnabledPeriodTypes: true,
+ enabledPeriodTypesData: null,
+ })
+
+ expect(canLoadLayer({ layer: 'thematic' })).toBe(false)
+ expect(canLoadLayer({ layer: 'event' })).toBe(false)
+ expect(canLoadLayer({ layer: 'earthEngine' })).toBe(true)
+ })
+
+ it('loads any layer once period types are there', () => {
+ const { canLoadLayer } = renderLoadLayer({
+ supportsEnabledPeriodTypes: true,
+ enabledPeriodTypesData: { enabledTypes: [] },
+ })
+
+ expect(canLoadLayer({ layer: 'thematic' })).toBe(true)
+ })
+ })
+
+ describe('loadLayer', () => {
+ it('calls the loader of the layer type with what it needs', async () => {
+ earthEngineLoader.mockResolvedValue({ ...config, isLoaded: true })
+ const { loadLayer } = renderLoadLayer()
+
+ const result = await loadLayer(config, { loadExtended: true })
+
+ expect(earthEngineLoader).toHaveBeenCalledWith(
+ expect.objectContaining({
+ config: expect.objectContaining(config),
+ engine: { engine: true },
+ analyticsEngine: { analytics: true },
+ baseUrl: 'base',
+ serverVersion: { minor: 43 },
+ keyAnalysisDigitGroupSeparator: 'SPACE',
+ keyAnalysisDisplayProperty: 'name',
+ userId: 'user',
+ KeyTrackedEntityMaxLimit: 50000,
+ loadExtended: true,
+ })
+ )
+ expect(result).toEqual({ ...config, isLoaded: true })
+ })
+
+ it('does not pass the previous alerts to the loader', async () => {
+ earthEngineLoader.mockImplementation(({ config }) => config)
+ const { loadLayer } = renderLoadLayer()
+
+ const result = await loadLayer({
+ ...config,
+ alerts: [{ id: 'NO_DATA' }],
+ loadError: 'Boom',
+ })
+
+ expect(result.alerts).toBeUndefined()
+ expect(result.loadError).toBeUndefined()
+ })
+
+ it('turns a thrown error into a loaded layer with an error alert', async () => {
+ const error = new Error('Boom')
+ earthEngineLoader.mockRejectedValue(error)
+ const { loadLayer } = renderLoadLayer()
+
+ const result = await loadLayer(config)
+
+ expect(result).toMatchObject({
+ ...config,
+ data: [],
+ isLoaded: true,
+ isLoading: false,
+ })
+ expect(result.alerts).toEqual([
+ expect.objectContaining({
+ id: 'LOAD_FAILED',
+ severity: 'error',
+ details: { message: 'Boom' },
+ }),
+ ])
+ // Kept for developers, as the alert only has the message
+ expect(console.error).toHaveBeenCalledWith(error)
+ })
+
+ it('handles an unknown layer type the same way', async () => {
+ const { loadLayer } = renderLoadLayer()
+
+ const result = await loadLayer({ id: 'layer2', layer: 'unknown' })
+
+ expect(result).toMatchObject({ name: 'Layer', isLoaded: true })
+ expect(result.alerts[0].details.message).toBe(
+ 'Unknown layer type: unknown'
+ )
+ })
+ })
+})
diff --git a/src/hooks/__tests__/useLoadLayerWithRedux.spec.js b/src/hooks/__tests__/useLoadLayerWithRedux.spec.js
new file mode 100644
index 0000000000..46f23c7d76
--- /dev/null
+++ b/src/hooks/__tests__/useLoadLayerWithRedux.spec.js
@@ -0,0 +1,79 @@
+import { renderHook, waitFor } from '@testing-library/react'
+import { useDispatch, useSelector } from 'react-redux'
+import { setLayerLoading, updateLayer } from '../../actions/layers.js'
+import { useLoadLayer } from '../useLoadLayer.js'
+import { useLoadLayerWithRedux } from '../useLoadLayerWithRedux.js'
+
+jest.mock('react-redux', () => ({
+ useDispatch: jest.fn(),
+ useSelector: jest.fn(),
+}))
+jest.mock('../useLoadLayer.js', () => ({ useLoadLayer: jest.fn() }))
+jest.mock('../../components/loaders/useLoaderAlerts.js', () => () => ({
+ showAlerts: mockShowAlerts,
+}))
+jest.mock('../../util/layerAlertsPreview.js', () => ({
+ withPreviewAlerts: (layer) => layer,
+}))
+
+const mockShowAlerts = jest.fn()
+const dispatch = jest.fn()
+const loadLayer = jest.fn((config) =>
+ Promise.resolve({ ...config, isLoaded: true, alerts: [] })
+)
+
+const renderLoader = ({ mapViews, dataTable = null, canLoadLayer }) => {
+ useSelector.mockImplementation((selector) =>
+ selector({ map: { mapViews }, dataTable })
+ )
+ useLoadLayer.mockReturnValue({
+ loadLayer,
+ canLoadLayer: canLoadLayer ?? (() => true),
+ })
+ renderHook(() => useLoadLayerWithRedux())
+}
+
+describe('useLoadLayerWithRedux', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ useDispatch.mockReturnValue(dispatch)
+ })
+
+ it('loads the unloaded layers and stores the result', async () => {
+ const config = { id: 'layer1', layer: 'trackedEntity' }
+
+ renderLoader({
+ mapViews: [config, { id: 'layer2', isLoaded: true }],
+ })
+
+ expect(dispatch).toHaveBeenCalledWith(setLayerLoading('layer1'))
+ expect(loadLayer).toHaveBeenCalledTimes(1)
+ expect(loadLayer).toHaveBeenCalledWith(config, { loadExtended: false })
+ await waitFor(() =>
+ expect(dispatch).toHaveBeenCalledWith(
+ updateLayer({ ...config, isLoaded: true, alerts: [] })
+ )
+ )
+ expect(mockShowAlerts).toHaveBeenCalledWith([])
+ })
+
+ it('skips layers already loading or not ready', () => {
+ renderLoader({
+ mapViews: [
+ { id: 'layer1', isLoading: true },
+ { id: 'layer2', layer: 'thematic' },
+ ],
+ canLoadLayer: (config) => config.layer !== 'thematic',
+ })
+
+ expect(loadLayer).not.toHaveBeenCalled()
+ })
+
+ it('reloads an event layer with extended data for the data table', () => {
+ const config = { id: 'layer1', layer: 'event', isLoaded: true }
+
+ renderLoader({ mapViews: [config], dataTable: 'layer1' })
+
+ expect(loadLayer).toHaveBeenCalledWith(config, { loadExtended: true })
+ })
+})
diff --git a/src/hooks/useLayersLoader.js b/src/hooks/useLayersLoader.js
deleted file mode 100644
index 0c37f2130f..0000000000
--- a/src/hooks/useLayersLoader.js
+++ /dev/null
@@ -1,140 +0,0 @@
-import { Analytics, useDataOutputPeriodTypes } from '@dhis2/analytics'
-import { useDataEngine, useConfig } from '@dhis2/app-runtime'
-import { useAlert } from '@dhis2/app-service-alerts'
-import { useState, useEffect } from 'react'
-import { useSelector, useDispatch } from 'react-redux'
-import { setLayerLoading, updateLayer } from '../actions/layers.js'
-import { useCachedData } from '../components/cachedDataProvider/CachedDataProvider.jsx'
-import useLoaderAlerts from '../components/loaders/useLoaderAlerts.js'
-import { THEMATIC_LAYER, EVENT_LAYER } from '../constants/layers.js'
-import earthEngineLoader from '../loaders/earthEngineLoader.js'
-import eventLoader from '../loaders/eventLoader.js'
-import externalLoader from '../loaders/externalLoader.js'
-import facilityLoader from '../loaders/facilityLoader.js'
-import geoJsonUrlLoader from '../loaders/geoJsonUrlLoader.js'
-import orgUnitLoader from '../loaders/orgUnitLoader.js'
-import thematicLoader from '../loaders/thematicLoader.js'
-import trackedEntityLoader from '../loaders/trackedEntityLoader.js'
-
-const loaders = {
- earthEngine: earthEngineLoader,
- event: eventLoader,
- external: externalLoader,
- facility: facilityLoader,
- orgUnit: orgUnitLoader,
- thematic: thematicLoader,
- geoJsonUrl: geoJsonUrlLoader,
- trackedEntity: trackedEntityLoader,
-}
-
-export const useLayersLoader = () => {
- const { baseUrl, serverVersion } = useConfig()
- const engine = useDataEngine()
- const [analyticsEngine] = useState(() => Analytics.getAnalytics(engine))
- const {
- systemSettings: {
- keyAnalysisDigitGroupSeparator,
- KeyTrackedEntityInstanceMaxLimit,
- KeyTrackedEntityMaxLimit,
- },
- currentUser,
- } = useCachedData()
- const { showAlerts } = useLoaderAlerts()
- const allLayers = useSelector((state) => state.map.mapViews)
- const dataTable = useSelector((state) => state.dataTable)
- const dispatch = useDispatch()
- const { show: showLoaderAlert } = useAlert(
- ({ layer }) => `Could not load layer ${layer}`,
- { critical: true }
- )
- const periodTypeData = useDataOutputPeriodTypes()
-
- const {
- keyAnalysisDisplayProperty,
- id: userId,
- userOrgUnitIdsByKeyword,
- } = currentUser
-
- useEffect(() => {
- async function loadLayer(config, loader) {
- const result = await loader({
- config,
- engine,
- keyAnalysisDisplayProperty, // name/shortName
- keyAnalysisDigitGroupSeparator, // NONE/SPACE/COMMA
- userId,
- userOrgUnitIdsByKeyword, // Event loader
- baseUrl,
- analyticsEngine, // Thematic, Event and Tracked entity loader
- periodTypeData, // Thematic and Event loader
- serverVersion, // Tracked entity loader
- KeyTrackedEntityInstanceMaxLimit, // Tracked entity loader (2.40)
- KeyTrackedEntityMaxLimit, // Tracked entity loader
- loadExtended: !!dataTable, // Event loader
- })
- if (result.alerts) {
- showAlerts(result.alerts)
- }
- dispatch(updateLayer(result))
- }
-
- const unloadedLayers = allLayers.filter((layer) => {
- if (layer.isLoading) {
- return false
- } else {
- // The layer is not loaded - load it
- if (!layer.isLoaded) {
- return true
- }
-
- // The layer is loaded but the data table is now displayed and
- // event extended data hasn't been loaded yet - so load it
- if (
- layer.layer === EVENT_LAYER &&
- layer.id === dataTable &&
- !layer.isExtended &&
- !layer.serverCluster
- ) {
- return true
- }
-
- return false
- }
- })
-
- // only load layers that have not yet been loaded
- unloadedLayers.forEach((layerConfig) => {
- const loader = loaders[layerConfig.layer]
- if (!loader) {
- showLoaderAlert({ layer: layerConfig.layer })
- return
- }
- if (
- periodTypeData?.supportsEnabledPeriodTypes &&
- !periodTypeData?.enabledPeriodTypesData &&
- [THEMATIC_LAYER, EVENT_LAYER].includes(layerConfig.layer)
- ) {
- return
- }
- dispatch(setLayerLoading(layerConfig.id))
- loadLayer(layerConfig, loader)
- })
- }, [
- allLayers,
- dispatch,
- keyAnalysisDisplayProperty,
- keyAnalysisDigitGroupSeparator,
- userId,
- userOrgUnitIdsByKeyword,
- engine,
- analyticsEngine,
- periodTypeData,
- showAlerts,
- showLoaderAlert,
- baseUrl,
- dataTable,
- serverVersion,
- KeyTrackedEntityInstanceMaxLimit,
- KeyTrackedEntityMaxLimit,
- ])
-}
diff --git a/src/hooks/useLoadLayer.js b/src/hooks/useLoadLayer.js
new file mode 100644
index 0000000000..1dba922252
--- /dev/null
+++ b/src/hooks/useLoadLayer.js
@@ -0,0 +1,114 @@
+import { Analytics, useDataOutputPeriodTypes } from '@dhis2/analytics'
+import { useDataEngine, useConfig } from '@dhis2/app-runtime'
+import i18n from '@dhis2/d2-i18n'
+import { useCallback, useState } from 'react'
+import { useCachedData } from '../components/cachedDataProvider/CachedDataProvider.jsx'
+import { THEMATIC_LAYER, EVENT_LAYER } from '../constants/layers.js'
+import earthEngineLoader from '../loaders/earthEngineLoader.js'
+import eventLoader from '../loaders/eventLoader.js'
+import externalLoader from '../loaders/externalLoader.js'
+import facilityLoader from '../loaders/facilityLoader.js'
+import geoJsonUrlLoader from '../loaders/geoJsonUrlLoader.js'
+import orgUnitLoader from '../loaders/orgUnitLoader.js'
+import thematicLoader from '../loaders/thematicLoader.js'
+import trackedEntityLoader from '../loaders/trackedEntityLoader.js'
+import { createLayerAlertFromError } from '../util/layerAlerts.js'
+
+const LOADERS = {
+ earthEngine: earthEngineLoader,
+ event: eventLoader,
+ external: externalLoader,
+ facility: facilityLoader,
+ orgUnit: orgUnitLoader,
+ thematic: thematicLoader,
+ geoJsonUrl: geoJsonUrlLoader,
+ trackedEntity: trackedEntityLoader,
+}
+
+// Returns loadLayer, used by the app (useLoadLayerWithRedux) and the plugin
+// (plugin/Map.jsx). loadLayer never throws: a layer always finishes loading,
+// with an error alert if something went wrong
+export const useLoadLayer = () => {
+ const { baseUrl, serverVersion } = useConfig()
+ const engine = useDataEngine()
+ const [analyticsEngine] = useState(() => Analytics.getAnalytics(engine))
+ const {
+ systemSettings: {
+ keyAnalysisDigitGroupSeparator,
+ KeyTrackedEntityInstanceMaxLimit,
+ KeyTrackedEntityMaxLimit,
+ },
+ currentUser: {
+ keyAnalysisDisplayProperty,
+ id: userId,
+ userOrgUnitIdsByKeyword,
+ },
+ } = useCachedData()
+ const periodTypeData = useDataOutputPeriodTypes()
+
+ // Thematic and event layers need the enabled period types first
+ const canLoadLayer = useCallback(
+ (config) =>
+ !(
+ periodTypeData?.supportsEnabledPeriodTypes &&
+ !periodTypeData?.enabledPeriodTypesData &&
+ [THEMATIC_LAYER, EVENT_LAYER].includes(config.layer)
+ ),
+ [periodTypeData]
+ )
+
+ const loadLayer = useCallback(
+ async (layer, { loadExtended = false } = {}) => {
+ // The previous load's alerts must not come back with the new result
+ const config = { ...layer, alerts: undefined, loadError: undefined }
+
+ try {
+ const loader = LOADERS[config.layer]
+ if (!loader) {
+ throw new Error(`Unknown layer type: ${config.layer}`)
+ }
+ return await loader({
+ config,
+ engine,
+ keyAnalysisDisplayProperty, // name/shortName
+ keyAnalysisDigitGroupSeparator, // NONE/SPACE/COMMA
+ userId,
+ userOrgUnitIdsByKeyword, // Event loader
+ baseUrl,
+ analyticsEngine, // Thematic, Event and Tracked entity loader
+ periodTypeData, // Thematic and Event loader
+ serverVersion, // Tracked entity loader
+ KeyTrackedEntityInstanceMaxLimit, // Tracked entity loader (2.40)
+ KeyTrackedEntityMaxLimit, // Tracked entity loader
+ loadExtended, // Event loader
+ })
+ } catch (error) {
+ console.error(error)
+ return {
+ ...config,
+ name: config.name || i18n.t('Layer'),
+ data: [],
+ alerts: [createLayerAlertFromError(error)],
+ isLoaded: true,
+ isLoading: false,
+ isExpanded: true,
+ }
+ }
+ },
+ [
+ engine,
+ keyAnalysisDisplayProperty,
+ keyAnalysisDigitGroupSeparator,
+ userId,
+ userOrgUnitIdsByKeyword,
+ baseUrl,
+ analyticsEngine,
+ periodTypeData,
+ serverVersion,
+ KeyTrackedEntityInstanceMaxLimit,
+ KeyTrackedEntityMaxLimit,
+ ]
+ )
+
+ return { loadLayer, canLoadLayer }
+}
diff --git a/src/hooks/useLoadLayerWithRedux.js b/src/hooks/useLoadLayerWithRedux.js
new file mode 100644
index 0000000000..d61ee2e9f9
--- /dev/null
+++ b/src/hooks/useLoadLayerWithRedux.js
@@ -0,0 +1,49 @@
+import { useEffect } from 'react'
+import { useSelector, useDispatch } from 'react-redux'
+import { setLayerLoading, updateLayer } from '../actions/layers.js'
+import useLoaderAlerts from '../components/loaders/useLoaderAlerts.js'
+import { EVENT_LAYER } from '../constants/layers.js'
+import { withPreviewAlerts } from '../util/layerAlertsPreview.js'
+import { useLoadLayer } from './useLoadLayer.js'
+
+// useLoadLayer for the app: loads the layers of the redux store that are not
+// loaded yet, and stores the results
+export const useLoadLayerWithRedux = () => {
+ const { loadLayer, canLoadLayer } = useLoadLayer()
+ const { showAlerts } = useLoaderAlerts()
+ const allLayers = useSelector((state) => state.map.mapViews)
+ const dataTable = useSelector((state) => state.dataTable)
+ const dispatch = useDispatch()
+
+ useEffect(() => {
+ const unloadedLayers = allLayers.filter((layer) => {
+ if (layer.isLoading) {
+ return false
+ }
+ // The layer is not loaded - load it
+ if (!layer.isLoaded) {
+ return true
+ }
+ // The layer is loaded but the data table is now displayed and
+ // event extended data hasn't been loaded yet - so load it
+ return (
+ layer.layer === EVENT_LAYER &&
+ layer.id === dataTable &&
+ !layer.isExtended &&
+ !layer.serverCluster
+ )
+ })
+
+ unloadedLayers.filter(canLoadLayer).forEach(async (config) => {
+ dispatch(setLayerLoading(config.id))
+ const result = withPreviewAlerts(
+ await loadLayer(config, { loadExtended: !!dataTable })
+ )
+ // LEGACY-ALERTS: snackbars for loaders not migrated yet
+ if (result.alerts) {
+ showAlerts(result.alerts)
+ }
+ dispatch(updateLayer(result))
+ })
+ }, [allLayers, dataTable, dispatch, loadLayer, canLoadLayer, showAlerts])
+}
diff --git a/src/loaders/__tests__/trackedEntityLoader.spec.js b/src/loaders/__tests__/trackedEntityLoader.spec.js
index 5b0050ef81..17445b735e 100644
--- a/src/loaders/__tests__/trackedEntityLoader.spec.js
+++ b/src/loaders/__tests__/trackedEntityLoader.spec.js
@@ -207,10 +207,9 @@ describe('trackedEntityLoader', () => {
expect(result.alerts).toEqual([
expect.objectContaining({
- warning: true,
- code: 'CUSTOM_ALERT',
- message:
- 'Tracked entity: Displaying first 50,000 tracked entities',
+ id: 'TRACKED_ENTITIES_TRUNCATED',
+ severity: 'warning',
+ title: 'Showing the first 50,000 tracked entities',
}),
])
})
@@ -233,8 +232,8 @@ describe('trackedEntityLoader', () => {
expect(result.alerts).toEqual([
expect.objectContaining({
- warning: true,
- message: 'Tracked entity: Displaying first 2 tracked entities',
+ id: 'TRACKED_ENTITIES_TRUNCATED',
+ title: 'Showing the first 2 tracked entities',
}),
])
})
@@ -250,13 +249,15 @@ describe('trackedEntityLoader', () => {
})
expect(result.alerts).toEqual([
- { code: 'WARNING_NO_DATA', message: 'Person' },
+ expect.objectContaining({ id: 'NO_DATA', severity: 'warning' }),
])
})
- it('sets a load error instead of throwing', async () => {
+ it('returns an error alert instead of throwing', async () => {
loadTrackedEntitiesFromAnalytics.mockRejectedValue(
- new Error('Please ensure analytics job was run')
+ Object.assign(new Error('Boom'), {
+ request: 'analytics/trackedEntities/query',
+ })
)
const result = await trackedEntityLoader({
@@ -266,18 +267,21 @@ describe('trackedEntityLoader', () => {
serverVersion: v41,
})
- expect(result.loadError).toBe('Please ensure analytics job was run')
expect(result.alerts).toEqual([
- {
- code: 'ERROR_CRITICAL',
- message: 'Please ensure analytics job was run',
- },
+ expect.objectContaining({
+ id: 'LOAD_FAILED',
+ severity: 'error',
+ details: {
+ message: 'Boom',
+ request: 'analytics/trackedEntities/query',
+ },
+ }),
])
expect(result.data).toEqual([])
expect(result.isLoaded).toBe(true)
})
- it('sets a load error when the tracker API fails', async () => {
+ it('returns an error alert when the tracker API fails', async () => {
const engine = { query: jest.fn().mockRejectedValue(new Error('Boom')) }
const result = await trackedEntityLoader({
@@ -287,8 +291,182 @@ describe('trackedEntityLoader', () => {
serverVersion: v40,
})
- expect(result.loadError).toBe('Boom')
+ expect(result.alerts).toEqual([
+ expect.objectContaining({
+ id: 'LOAD_FAILED',
+ details: {
+ message: 'Boom',
+ request: 'tracker/trackedEntities',
+ },
+ }),
+ ])
+ expect(result.isLoaded).toBe(true)
+ })
+})
+
+describe('trackedEntityLoader errors', () => {
+ const fetchError = (details, type = 'unknown') =>
+ Object.assign(new Error(details.message ?? 'Server error'), {
+ type,
+ details,
+ })
+ const load = (overrides = {}) =>
+ trackedEntityLoader({
+ config: { ...baseConfig },
+ engine: createEngine({ trackedEntities: [] }),
+ analyticsEngine: {},
+ serverVersion: v41,
+ ...overrides,
+ })
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it.each(['E7144', 'E7217'])(
+ 'falls back to the tracker API on analytics error %s',
+ async (errorCode) => {
+ loadTrackedEntitiesFromAnalytics.mockRejectedValue(
+ fetchError({ errorCode })
+ )
+ const engine = createEngine({
+ trackedEntities: [
+ {
+ id: 'te1',
+ geometry: { type: 'Point', coordinates: [1, 2] },
+ },
+ ],
+ })
+
+ const result = await load({ engine })
+
+ expect(engine.query).toHaveBeenCalledTimes(1)
+ expect(result.data).toEqual([point])
+ expect(result.alerts).toEqual([])
+ }
+ )
+
+ it.each([
+ [
+ 'no access',
+ fetchError({ httpStatusCode: 403 }, 'access'),
+ 'NO_ACCESS',
+ ],
+ [
+ 'a missing program',
+ fetchError({ errorCode: 'E7129' }),
+ 'PROGRAM_UNAVAILABLE',
+ ],
+ [
+ 'a missing tracked entity type',
+ fetchError({ errorCode: 'E7125' }),
+ 'TRACKED_ENTITY_TYPE_UNAVAILABLE',
+ ],
+ [
+ 'org units outside the user access',
+ fetchError({ errorCode: 'E7120' }),
+ 'ORG_UNITS_UNAVAILABLE',
+ ],
+ [
+ 'invalid org units',
+ fetchError({ errorCode: 'E7143' }),
+ 'ORG_UNITS_UNAVAILABLE',
+ ],
+ ['any other error', fetchError({ errorCode: 'E7999' }), 'LOAD_FAILED'],
+ ])('maps %s to %s', async (_, error, id) => {
+ loadTrackedEntitiesFromAnalytics.mockRejectedValue(error)
+
+ const result = await load()
+
+ expect(result.alerts).toEqual([
+ expect.objectContaining({ id, severity: 'error' }),
+ ])
+ })
+
+ it('maps tracker API errors too', async () => {
+ const engine = {
+ query: jest
+ .fn()
+ .mockRejectedValue(
+ fetchError({ httpStatusCode: 400, errorCode: 'E1003' })
+ ),
+ }
+
+ const result = await load({ engine, serverVersion: v40 })
+
+ expect(result.alerts).toEqual([
+ expect.objectContaining({
+ id: 'PROGRAM_UNAVAILABLE',
+ details: expect.objectContaining({
+ errorCode: 'E1003',
+ request: 'tracker/trackedEntities',
+ }),
+ }),
+ ])
+ })
+
+ it('keeps the tracked entities when relationships fail to load', async () => {
+ const engine = {
+ query: jest
+ .fn()
+ .mockResolvedValueOnce({
+ trackedEntities: {
+ trackedEntities: [
+ {
+ id: 'te1',
+ geometry: {
+ type: 'Point',
+ coordinates: [1, 2],
+ },
+ },
+ ],
+ },
+ })
+ .mockRejectedValueOnce(fetchError({ httpStatusCode: 404 })),
+ }
+
+ const result = await load({
+ config: { ...baseConfig, relationshipType: 'relType' },
+ engine,
+ })
+
+ expect(result.data).toEqual([point])
+ expect(result.relationships).toBeUndefined()
+ expect(result.alerts).toEqual([
+ expect.objectContaining({
+ id: 'RELATIONSHIPS_FAILED',
+ severity: 'warning',
+ details: expect.objectContaining({
+ httpStatusCode: 404,
+ request: 'relationshipTypes',
+ }),
+ }),
+ ])
+ })
+
+ it('shows an unknown program status as is', async () => {
+ loadTrackedEntitiesFromAnalytics.mockResolvedValue({ data: [point] })
+
+ const result = await load({
+ config: {
+ ...baseConfig,
+ program: { id: 'programId', name: 'Malaria' },
+ programStatus: 'UNKNOWN',
+ },
+ })
+
+ expect(result.legend.explanation).toEqual(['Program status: UNKNOWN'])
+ })
+
+ it('finishes loading when the config is broken', async () => {
+ const result = await load({
+ config: { ...baseConfig, trackedEntityType: undefined },
+ })
+
expect(result.isLoaded).toBe(true)
+ expect(result.alerts).toEqual([
+ expect.objectContaining({ id: 'LOAD_FAILED', severity: 'error' }),
+ ])
})
})
diff --git a/src/loaders/trackedEntityLoader.js b/src/loaders/trackedEntityLoader.js
index dccfc0f4a1..ec1ce59144 100644
--- a/src/loaders/trackedEntityLoader.js
+++ b/src/loaders/trackedEntityLoader.js
@@ -1,9 +1,10 @@
import i18n from '@dhis2/d2-i18n'
import {
- CUSTOM_ALERT,
- ERROR_CRITICAL,
- WARNING_NO_DATA,
-} from '../constants/alerts.js'
+ ANALYTICS_FALLBACK_CODES,
+ LAYER_ALERT_NO_DATA,
+ LAYER_ALERT_RELATIONSHIPS_FAILED,
+ LAYER_ALERT_TRACKED_ENTITIES_TRUNCATED,
+} from '../constants/layerAlerts.js'
import {
TEI_COLOR,
TEI_RADIUS,
@@ -13,6 +14,11 @@ import {
} from '../constants/layers.js'
import { getProgramStatuses } from '../constants/programStatuses.js'
import { GEO_TYPE_POINT, GEO_TYPE_LINE } from '../util/geojson.js'
+import {
+ createLayerAlert,
+ createLayerAlertFromError,
+ getErrorDetails,
+} from '../util/layerAlerts.js'
import { formatWithSeparator } from '../util/numbers.js'
import { formatStartEndDate, getDateArray } from '../util/time.js'
import {
@@ -77,6 +83,68 @@ const getRelationshipLegendItems = ({
]
}
+// Tracker analytics where possible, otherwise the tracker API
+const loadTrackedEntities = async ({
+ config,
+ engine,
+ analyticsEngine,
+ serverVersion,
+ maxLimit,
+}) => {
+ if (canLoadTrackedEntitiesFromAnalytics(config, serverVersion)) {
+ try {
+ return await loadTrackedEntitiesFromAnalytics({
+ config,
+ analyticsEngine,
+ serverVersion,
+ })
+ } catch (error) {
+ if (!ANALYTICS_FALLBACK_CODES.has(error.details?.errorCode)) {
+ throw error
+ }
+ }
+ }
+ return loadTrackedEntitiesFromTracker({
+ config,
+ engine,
+ serverVersion,
+ maxLimit,
+ })
+}
+
+const createLegend = ({
+ name,
+ trackedEntityType,
+ program,
+ programStatus,
+ startDate,
+ endDate,
+ eventPointColor,
+ eventPointRadius,
+ areaRadius,
+}) => ({
+ title: name,
+ period: formatStartEndDate(getDateArray(startDate), getDateArray(endDate)),
+ items: [
+ {
+ name:
+ trackedEntityType.name +
+ (areaRadius ? ` + ${areaRadius} ${'m'} ${'buffer'}` : ''),
+ color: eventPointColor || TEI_COLOR,
+ radius: eventPointRadius || TEI_RADIUS,
+ },
+ ],
+ ...(program &&
+ programStatus && {
+ explanation: [
+ `${i18n.t('Program status')}: ${
+ getProgramStatuses().find((s) => s.id === programStatus)
+ ?.name ?? programStatus
+ }`,
+ ],
+ }),
+})
+
const trackedEntityLoader = async ({
config,
engine,
@@ -89,51 +157,11 @@ const trackedEntityLoader = async ({
parseJsonConfig(config)
const {
- trackedEntityType,
program,
- programStatus,
relationshipType: relationshipTypeID,
- startDate,
- endDate,
organisationUnitSelectionMode,
- eventPointColor,
- eventPointRadius,
- areaRadius,
} = config
-
- // Legend skeleton
- // -----
-
- const name = program ? program.name : i18n.t('Tracked entity')
-
- const legend = {
- title: name,
- period: formatStartEndDate(
- getDateArray(startDate),
- getDateArray(endDate)
- ),
- items: [
- {
- name:
- trackedEntityType.name +
- (areaRadius ? ` + ${areaRadius} ${'m'} ${'buffer'}` : ''),
- color: eventPointColor || TEI_COLOR,
- radius: eventPointRadius || TEI_RADIUS,
- },
- ],
- }
-
- if (program && programStatus) {
- legend.explanation = [
- `${i18n.t('Program status')}: ${
- getProgramStatuses().find((s) => s.id === programStatus).name
- }`,
- ]
- }
-
- // Data loading
- // -----
-
+ const name = program?.name || i18n.t('Tracked entity')
const alerts = []
const loadConfig = {
...config,
@@ -142,82 +170,69 @@ const trackedEntityLoader = async ({
getTrackedEntityDefaultOrgUnitMode(serverVersion),
}
let data = []
- let relationships, secondaryData, loadError
+ let legend, relationships, secondaryData
try {
- const result = canLoadTrackedEntitiesFromAnalytics(
- loadConfig,
- serverVersion
- )
- ? await loadTrackedEntitiesFromAnalytics({
- config: loadConfig,
- analyticsEngine,
- serverVersion,
- })
- : await loadTrackedEntitiesFromTracker({
- config: loadConfig,
- engine,
- serverVersion,
- maxLimit: getTrackerMaxLimit(
- {
- KeyTrackedEntityInstanceMaxLimit,
- KeyTrackedEntityMaxLimit,
- },
- serverVersion
- ),
- })
-
+ legend = createLegend({ ...config, name })
+
+ const result = await loadTrackedEntities({
+ config: loadConfig,
+ engine,
+ analyticsEngine,
+ serverVersion,
+ maxLimit: getTrackerMaxLimit(
+ { KeyTrackedEntityInstanceMaxLimit, KeyTrackedEntityMaxLimit },
+ serverVersion
+ ),
+ })
data = result.data
if (relationshipTypeID) {
+ // The tracked entities are still shown when this fails
const relationshipResult = await loadTrackedEntityRelationships({
config: loadConfig,
engine,
serverVersion,
instances: result.instances,
orgUnits: result.orgUnits,
+ }).catch((error) => {
+ alerts.push(
+ createLayerAlert(LAYER_ALERT_RELATIONSHIPS_FAILED, {
+ details: getErrorDetails(error),
+ })
+ )
+ return null
})
- ;({ data, relationships, secondaryData } = relationshipResult)
- legend.items.push(
- ...getRelationshipLegendItems({
- ...config,
- ...relationshipResult,
- })
- )
+ if (relationshipResult) {
+ ;({ data, relationships, secondaryData } = relationshipResult)
+ legend.items.push(
+ ...getRelationshipLegendItems({
+ ...config,
+ ...relationshipResult,
+ })
+ )
+ }
}
if (result.isTruncated) {
- alerts.push({
- warning: true,
- code: CUSTOM_ALERT,
- message: `${name}: ${i18n.t(
- 'Displaying first {{pageSize}} tracked entities',
- {
- pageSize: formatWithSeparator(
+ alerts.push(
+ createLayerAlert(LAYER_ALERT_TRACKED_ENTITIES_TRUNCATED, {
+ params: {
+ limit: formatWithSeparator(
result.limit,
keyAnalysisDigitGroupSeparator
),
- }
- )}`,
- })
+ },
+ })
+ )
}
- } catch (error) {
- loadError = error.message || i18n.t('an error occurred')
- alerts.push({
- code: ERROR_CRITICAL,
- message: loadError,
- })
- }
- // Result alert
- // -----
-
- if (!loadError && !data.length) {
- alerts.push({
- code: WARNING_NO_DATA,
- message: trackedEntityType.name,
- })
+ if (!data.length) {
+ alerts.push(createLayerAlert(LAYER_ALERT_NO_DATA))
+ }
+ } catch (error) {
+ alerts.push(createLayerAlertFromError(error))
}
return {
@@ -229,7 +244,6 @@ const trackedEntityLoader = async ({
secondaryData,
legend,
alerts,
- loadError,
isLoaded: true,
isLoading: false,
isExpanded: true,
diff --git a/src/reducers/__tests__/map.spec.js b/src/reducers/__tests__/map.spec.js
index 1c70810eab..04ffb40fff 100644
--- a/src/reducers/__tests__/map.spec.js
+++ b/src/reducers/__tests__/map.spec.js
@@ -386,6 +386,27 @@ describe('map reducer - per-layer delegation', () => {
expect(result.mapViews[0].isLoading).toBe(true)
expect(result.mapViews[1]).toBe(other)
})
+
+ it('clears the previous alerts and load error', () => {
+ const state = {
+ ...defaultState,
+ mapViews: [
+ {
+ id: 'layer1',
+ alerts: [{ id: 'NO_DATA', severity: 'warning' }],
+ loadError: 'Boom',
+ },
+ ],
+ }
+
+ const result = map(state, {
+ type: types.LAYER_LOADING_SET,
+ id: 'layer1',
+ })
+
+ expect(result.mapViews[0].alerts).toBeUndefined()
+ expect(result.mapViews[0].loadError).toBeUndefined()
+ })
})
describe('LAYER_TOGGLE_VISIBILITY', () => {
diff --git a/src/reducers/map.js b/src/reducers/map.js
index 7794693f07..3a796ffe70 100644
--- a/src/reducers/map.js
+++ b/src/reducers/map.js
@@ -110,6 +110,9 @@ const layer = (state, action) => {
return {
...state,
isLoading: true,
+ // Not shown while reloading
+ alerts: undefined,
+ loadError: undefined, // LEGACY-ALERTS
}
case types.LAYER_TOGGLE_VISIBILITY:
diff --git a/src/util/__tests__/layerAlerts.spec.js b/src/util/__tests__/layerAlerts.spec.js
new file mode 100644
index 0000000000..4ac7e68e1b
--- /dev/null
+++ b/src/util/__tests__/layerAlerts.spec.js
@@ -0,0 +1,200 @@
+import {
+ LAYER_ALERT_LOAD_FAILED,
+ LAYER_ALERT_NO_DATA,
+ LAYER_ALERT_TRACKED_ENTITIES_TRUNCATED,
+} from '../../constants/layerAlerts.js'
+import {
+ createLayerAlert,
+ createLayerAlertFromError,
+ formatAlertDetails,
+ getLayerAlerts,
+ getMostSevereAlert,
+ hasLayerError,
+ isLayerAlert,
+ sortBySeverity,
+ throwWithRequest,
+} from '../layerAlerts.js'
+
+const fetchError = (details, type = 'unknown') =>
+ Object.assign(new Error(details.message ?? 'Server error'), {
+ type,
+ details,
+ })
+
+describe('createLayerAlert', () => {
+ it('builds an alert from the catalog', () => {
+ expect(
+ createLayerAlert(LAYER_ALERT_TRACKED_ENTITIES_TRUNCATED, {
+ params: { limit: '50,000' },
+ })
+ ).toEqual({
+ id: 'TRACKED_ENTITIES_TRUNCATED',
+ severity: 'warning',
+ title: 'Showing the first 50,000 tracked entities',
+ description: 'Narrow the period or org units to see all of them.',
+ })
+ })
+
+ it('adds details when given', () => {
+ expect(
+ createLayerAlert(LAYER_ALERT_LOAD_FAILED, {
+ details: { message: 'Boom' },
+ })
+ ).toMatchObject({
+ severity: 'error',
+ details: { message: 'Boom' },
+ })
+ })
+})
+
+describe('createLayerAlertFromError', () => {
+ it('maps access errors', () => {
+ expect(
+ createLayerAlertFromError(
+ fetchError({ httpStatusCode: 403 }, 'access')
+ ).id
+ ).toBe('NO_ACCESS')
+ expect(
+ createLayerAlertFromError(fetchError({ httpStatusCode: 403 })).id
+ ).toBe('NO_ACCESS')
+ })
+
+ it('maps known error codes, and keeps the server details', () => {
+ const error = fetchError({
+ httpStatusCode: 409,
+ errorCode: 'E7129',
+ message: 'Program is specified but does not exist',
+ })
+ error.request = 'analytics/trackedEntities/query'
+
+ const alert = createLayerAlertFromError(error)
+
+ expect(alert).toMatchObject({
+ id: 'PROGRAM_UNAVAILABLE',
+ severity: 'error',
+ details: {
+ httpStatusCode: 409,
+ errorCode: 'E7129',
+ message: 'Program is specified but does not exist',
+ request: 'analytics/trackedEntities/query',
+ },
+ })
+ })
+
+ it('falls back to a generic error for anything else', () => {
+ expect(createLayerAlertFromError(new Error('Boom'))).toEqual({
+ id: 'LOAD_FAILED',
+ severity: 'error',
+ title: 'Failed to load layer',
+ details: { message: 'Boom' },
+ })
+ expect(createLayerAlertFromError('Boom').details.message).toBe('Boom')
+ })
+})
+
+describe('alert helpers', () => {
+ const warning = createLayerAlert(LAYER_ALERT_NO_DATA)
+ const error = createLayerAlert(LAYER_ALERT_LOAD_FAILED)
+
+ it('finds the most severe alert of the layers', () => {
+ expect(
+ getMostSevereAlert([
+ { alerts: [warning] },
+ {},
+ { alerts: [{ code: 'ERROR_CRITICAL' }, error] },
+ ])
+ ).toBe(error)
+ expect(getMostSevereAlert([{ alerts: [] }])).toBeUndefined()
+ })
+
+ it('tells new alerts from older { code, message } ones', () => {
+ expect(isLayerAlert(warning)).toBe(true)
+ expect(isLayerAlert({ code: 'WARNING_NO_DATA' })).toBe(false)
+ })
+
+ it('finds errors in new alerts and in loadError', () => {
+ expect(hasLayerError({ alerts: [warning, error] })).toBe(true)
+ expect(hasLayerError({ alerts: [warning] })).toBe(false)
+ expect(hasLayerError({ loadError: 'Boom' })).toBe(true)
+ expect(hasLayerError({})).toBe(false)
+ })
+
+ it('sorts errors first', () => {
+ expect(
+ sortBySeverity([warning, error]).map((alert) => alert.id)
+ ).toEqual(['LOAD_FAILED', 'NO_DATA'])
+ })
+
+ it('formats details as plain text', () => {
+ const text = formatAlertDetails(
+ createLayerAlertFromError(
+ Object.assign(
+ fetchError({ httpStatusCode: 409, errorCode: 'E7144' }),
+ { request: 'analytics/trackedEntities/query' }
+ )
+ ),
+ {
+ layerName: 'Malaria',
+ layerType: 'trackedEntity',
+ serverVersion: { major: 2, minor: 43, patch: 1 },
+ appVersion: { full: '101.17.5', major: 101, minor: 17 },
+ date: new Date('2026-10-06T10:00:00Z'),
+ }
+ )
+
+ expect(text).toBe(
+ [
+ 'Problem: Failed to load layer',
+ 'Layer: Malaria (trackedEntity)',
+ 'App version: 101.17.5',
+ 'Server version: 2.43.1',
+ 'Time: 2026-10-06T10:00:00.000Z',
+ 'Alert: LOAD_FAILED',
+ 'HTTP status: 409',
+ 'Error code: E7144',
+ 'Server message: Server error',
+ 'Request: analytics/trackedEntities/query',
+ ].join('\n')
+ )
+ })
+})
+
+describe('throwWithRequest', () => {
+ it('adds the failed request to the error and rethrows it', async () => {
+ const error = new Error('Boom')
+
+ await expect(
+ Promise.reject(error).catch(throwWithRequest('analytics/query'))
+ ).rejects.toBe(error)
+ expect(error.request).toBe('analytics/query')
+ })
+
+ it('keeps the request of a deeper call', async () => {
+ const error = Object.assign(new Error('Boom'), { request: 'first' })
+
+ await expect(
+ Promise.reject(error).catch(throwWithRequest('second'))
+ ).rejects.toMatchObject({ request: 'first' })
+ })
+})
+
+describe('getLayerAlerts', () => {
+ it('returns the layer alerts', () => {
+ const alerts = [createLayerAlert(LAYER_ALERT_NO_DATA)]
+ expect(getLayerAlerts({ alerts })).toBe(alerts)
+ expect(getLayerAlerts({})).toEqual([])
+ })
+
+ it('turns an older loadError into an error alert with its message', () => {
+ expect(
+ getLayerAlerts({ loadError: 'Data item was not found' })
+ ).toEqual([
+ {
+ id: 'LOAD_FAILED',
+ severity: 'error',
+ title: 'Failed to load layer',
+ description: 'Data item was not found',
+ },
+ ])
+ })
+})
diff --git a/src/util/__tests__/layerAlertsPreview.spec.js b/src/util/__tests__/layerAlertsPreview.spec.js
new file mode 100644
index 0000000000..83eef5160c
--- /dev/null
+++ b/src/util/__tests__/layerAlertsPreview.spec.js
@@ -0,0 +1,45 @@
+import { LAYER_ALERT_NO_DATA } from '../../constants/layerAlerts.js'
+import { getHashUrlParam } from '../history.js'
+import { createLayerAlert } from '../layerAlerts.js'
+import { getPreviewAlerts, withPreviewAlerts } from '../layerAlertsPreview.js'
+
+jest.mock('../history.js', () => ({ getHashUrlParam: jest.fn() }))
+
+describe('getPreviewAlerts', () => {
+ it('shows every alert, with sample details on errors', () => {
+ const alerts = getPreviewAlerts()
+ const findAlert = (id) => alerts.find((alert) => alert.id === id)
+
+ expect(findAlert('NO_DATA')).toBeDefined()
+ expect(findAlert('LOAD_FAILED')).toBeDefined()
+ alerts.forEach((alert) =>
+ expect(Boolean(alert.details)).toBe(alert.severity === 'error')
+ )
+ expect(findAlert('TRACKED_ENTITIES_TRUNCATED').title).toBe(
+ 'Showing the first 50,000 tracked entities'
+ )
+ expect(findAlert('NO_ACCESS').details.httpStatusCode).toBe(403)
+ })
+})
+
+describe('withPreviewAlerts', () => {
+ const layer = {
+ id: 'layer1',
+ alerts: [createLayerAlert(LAYER_ALERT_NO_DATA)],
+ }
+
+ it('returns the layer unchanged without the URL param', () => {
+ getHashUrlParam.mockReturnValue(undefined)
+
+ expect(withPreviewAlerts(layer)).toBe(layer)
+ })
+
+ it('adds the preview alerts the layer does not have', () => {
+ getHashUrlParam.mockReturnValue('')
+
+ const ids = withPreviewAlerts(layer).alerts.map((alert) => alert.id)
+
+ expect(ids).toHaveLength(getPreviewAlerts().length)
+ expect(ids.filter((id) => id === 'NO_DATA')).toHaveLength(1)
+ })
+})
diff --git a/src/util/__tests__/trackedEntity.spec.js b/src/util/__tests__/trackedEntity.spec.js
index 90c7846e04..bd003fc49d 100644
--- a/src/util/__tests__/trackedEntity.spec.js
+++ b/src/util/__tests__/trackedEntity.spec.js
@@ -241,6 +241,21 @@ describe('loadTrackedEntitiesFromAnalytics', () => {
).toBe(1)
})
+ it('adds the request to a failed request error', async () => {
+ mockEngine.query.mockRejectedValueOnce(new Error('Boom'))
+
+ await expect(
+ loadTrackedEntitiesFromAnalytics({
+ config: baseConfig,
+ analyticsEngine,
+ serverVersion: v42,
+ })
+ ).rejects.toMatchObject({
+ message: 'Boom',
+ request: 'analytics/trackedEntities/query',
+ })
+ })
+
it('is truncated when the server returns fewer rows than requested', async () => {
// e.g. capped by the analytics max limit setting
mockEngine.query.mockResolvedValueOnce({
diff --git a/src/util/layerAlerts.js b/src/util/layerAlerts.js
new file mode 100644
index 0000000000..dbd4a2ad9a
--- /dev/null
+++ b/src/util/layerAlerts.js
@@ -0,0 +1,123 @@
+import i18n from '@dhis2/d2-i18n'
+import {
+ ALERT_SEVERITY,
+ ERROR_CODE_ALERTS,
+ LAYER_ALERTS,
+ LAYER_ALERT_LOAD_FAILED,
+ LAYER_ALERT_NO_ACCESS,
+ SEVERITY_ORDER,
+} from '../constants/layerAlerts.js'
+
+export const createLayerAlert = (id, { params = {}, details } = {}) => {
+ const { severity, title, description } = LAYER_ALERTS[id]
+
+ return {
+ id,
+ severity,
+ title: title(params),
+ ...(description && { description: description(params) }),
+ ...(details && { details }),
+ }
+}
+
+// What the server said, from a data engine FetchError or a plain Error
+export const getErrorDetails = (error) => {
+ const { httpStatusCode, errorCode, message } = error?.details ?? {}
+ return {
+ ...(httpStatusCode && { httpStatusCode }),
+ ...(errorCode && { errorCode }),
+ message: message || error?.message || String(error),
+ ...(error?.request && { request: error.request }),
+ }
+}
+
+// For .catch(): adds the failed request to the error, for the alert details
+export const throwWithRequest = (request) => (error) => {
+ if (error instanceof Object) {
+ error.request ??= request
+ }
+ throw error
+}
+
+// An error alert for any thrown error. A known error code says more than a
+// 403 alone
+export const createLayerAlertFromError = (error) => {
+ const details = getErrorDetails(error)
+ const isAccessError =
+ error?.type === 'access' || details.httpStatusCode === 403
+ const id =
+ ERROR_CODE_ALERTS[details.errorCode] ??
+ (isAccessError ? LAYER_ALERT_NO_ACCESS : LAYER_ALERT_LOAD_FAILED)
+
+ return createLayerAlert(id, { details })
+}
+
+// LEGACY-ALERTS: loaders not migrated yet return loadError and
+// { code, message } alerts. Remove when every loader returns layer alerts
+export const getLayerAlerts = ({ alerts = [], loadError } = {}) => {
+ if (!loadError) {
+ return alerts
+ }
+ const alert = createLayerAlert(LAYER_ALERT_LOAD_FAILED)
+ return [{ ...alert, description: loadError }]
+}
+
+// LEGACY-ALERTS: remove when every loader returns layer alerts
+export const isLayerAlert = (alert) => Boolean(alert?.severity)
+
+export const hasLayerError = (layer) =>
+ getLayerAlerts(layer).some(
+ (alert) => alert.severity === ALERT_SEVERITY.ERROR
+ )
+
+export const sortBySeverity = (alerts) =>
+ [...alerts].sort(
+ (a, b) =>
+ SEVERITY_ORDER.indexOf(a.severity) -
+ SEVERITY_ORDER.indexOf(b.severity)
+ )
+
+export const getMostSevereAlert = (layers) =>
+ sortBySeverity(
+ layers.flatMap((layer) => (layer.alerts ?? []).filter(isLayerAlert))
+ )[0]
+
+const formatVersion = (version) =>
+ version?.full ||
+ (version &&
+ [version.major, version.minor, version.patch]
+ .filter((part) => part !== undefined)
+ .join('.'))
+
+const hasValue = ([, value]) => value !== undefined && value !== ''
+
+export const getDetailRows = ({
+ httpStatusCode,
+ errorCode,
+ message,
+ request,
+} = {}) =>
+ [
+ [i18n.t('HTTP status'), httpStatusCode],
+ [i18n.t('Error code'), errorCode],
+ [i18n.t('Server message'), message],
+ [i18n.t('Request'), request],
+ ].filter(hasValue)
+
+// Plain text for an administrator or developer
+export const formatAlertDetails = (
+ alert,
+ { layerName, layerType, serverVersion, appVersion, date = new Date() } = {}
+) =>
+ [
+ [i18n.t('Problem'), alert.title],
+ [i18n.t('Layer'), layerName && `${layerName} (${layerType})`],
+ [i18n.t('App version'), formatVersion(appVersion)],
+ [i18n.t('Server version'), formatVersion(serverVersion)],
+ [i18n.t('Time'), date.toISOString()],
+ [i18n.t('Alert'), alert.id],
+ ...getDetailRows(alert.details),
+ ]
+ .filter(hasValue)
+ .map(([label, value]) => `${label}: ${value}`)
+ .join('\n')
diff --git a/src/util/layerAlertsPreview.js b/src/util/layerAlertsPreview.js
new file mode 100644
index 0000000000..8c2b153ff6
--- /dev/null
+++ b/src/util/layerAlertsPreview.js
@@ -0,0 +1,53 @@
+import {
+ ALERT_SEVERITY,
+ LAYER_ALERTS,
+ LAYER_ALERT_NO_ACCESS,
+} from '../constants/layerAlerts.js'
+import { getHashUrlParam } from './history.js'
+import { createLayerAlert } from './layerAlerts.js'
+
+// Developer preview of every layer alert, in the app only: add
+// "alertPreview" to the URL hash (e.g. #/?alertPreview). To remove it, delete
+// this file and its call in hooks/useLoadLayerWithRedux.js
+
+const PREVIEW_PARAMS = { limit: '50,000' }
+const PREVIEW_DETAILS = {
+ httpStatusCode: 409,
+ errorCode: 'E0000',
+ message: 'Sample server message',
+ request: 'analytics/sample/query',
+}
+
+const getPreviewDetails = (id, severity) => {
+ if (severity !== ALERT_SEVERITY.ERROR) {
+ return
+ }
+ return id === LAYER_ALERT_NO_ACCESS
+ ? { ...PREVIEW_DETAILS, httpStatusCode: 403 }
+ : PREVIEW_DETAILS
+}
+
+export const getPreviewAlerts = () =>
+ Object.entries(LAYER_ALERTS).map(([id, { severity }]) =>
+ createLayerAlert(id, {
+ params: PREVIEW_PARAMS,
+ details: getPreviewDetails(id, severity),
+ })
+ )
+
+// Adds the preview alerts the layer doesn't have
+export const withPreviewAlerts = (layer) => {
+ if (getHashUrlParam('alertPreview') === undefined) {
+ return layer
+ }
+ const alerts = layer.alerts ?? []
+ const ids = new Set(alerts.map((alert) => alert.id))
+
+ return {
+ ...layer,
+ alerts: [
+ ...alerts,
+ ...getPreviewAlerts().filter((alert) => !ids.has(alert.id)),
+ ],
+ }
+}
diff --git a/src/util/trackedEntity.js b/src/util/trackedEntity.js
index c3c328ee2d..0c30f75562 100644
--- a/src/util/trackedEntity.js
+++ b/src/util/trackedEntity.js
@@ -9,6 +9,7 @@ import {
parseWkt,
trackedEntityGeometryTypes,
} from './geojson.js'
+import { throwWithRequest } from './layerAlerts.js'
import { trimTime } from './time.js'
import {
serverSupportsTracker41Api,
@@ -177,9 +178,9 @@ export const loadTrackedEntitiesFromAnalytics = async ({
analyticsEngine,
serverVersion,
})
- const response = await analyticsEngine.trackedEntities.getQuery(
- request.withPageSize(pageSize)
- )
+ const response = await analyticsEngine.trackedEntities
+ .getQuery(request.withPageSize(pageSize))
+ .catch(throwWithRequest('analytics/trackedEntities/query'))
return {
data: createTrackedEntityFeatures(response, serverVersion),
@@ -287,10 +288,12 @@ export const loadTrackedEntitiesFromTracker = async ({
Boolean(config.relationshipType)
)
- const { trackedEntities: response } = await engine.query(
- { trackedEntities: TRACKED_ENTITIES_QUERY },
- { variables: params }
- )
+ const { trackedEntities: response } = await engine
+ .query(
+ { trackedEntities: TRACKED_ENTITIES_QUERY },
+ { variables: params }
+ )
+ .catch(throwWithRequest(TRACKED_ENTITIES_QUERY.resource))
const allInstances =
response[isTracker41Api ? 'trackedEntities' : 'instances']
diff --git a/src/util/trackedEntityRelationships.js b/src/util/trackedEntityRelationships.js
index aac4755b4b..d6b8956871 100644
--- a/src/util/trackedEntityRelationships.js
+++ b/src/util/trackedEntityRelationships.js
@@ -1,3 +1,4 @@
+import { throwWithRequest } from './layerAlerts.js'
import { createTrackedEntityInstanceFeatures } from './trackedEntity.js'
import { serverSupportsTracker41Api } from './versionToggle.js'
@@ -183,18 +184,21 @@ export const getDataWithRelationships = async ({
} else {
// VERSION-TOGGLE: see util/versionToggle.js
const isTracker41Api = serverSupportsTracker41Api(serverVersion)
- const { tei } = await engine.query(
- { tei: isTracker41Api ? TEI_41_QUERY : TEI_40_QUERY },
- {
- variables: {
- fields,
- orgUnits,
- orgUnitMode: organisationUnitSelectionMode,
- program: recursiveProp?.program,
- trackedEntityType: recursiveProp?.type?.id,
- },
- }
- )
+ const teiQuery = isTracker41Api ? TEI_41_QUERY : TEI_40_QUERY
+ const { tei } = await engine
+ .query(
+ { tei: teiQuery },
+ {
+ variables: {
+ fields,
+ orgUnits,
+ orgUnitMode: organisationUnitSelectionMode,
+ program: recursiveProp?.program,
+ trackedEntityType: recursiveProp?.type?.id,
+ },
+ }
+ )
+ .catch(throwWithRequest(teiQuery.resource))
normalizedPotentialTargetInstances = normalizeInstances(
tei[isTracker41Api ? 'trackedEntities' : 'instances']
@@ -260,19 +264,23 @@ export const loadTrackedEntityRelationships = async ({
organisationUnitSelectionMode,
} = config
- const { relationshipType } = await engine.query(
- { relationshipType: RELATIONSHIP_TYPE_QUERY },
- { variables: { id: relationshipTypeId } }
- )
+ const { relationshipType } = await engine
+ .query(
+ { relationshipType: RELATIONSHIP_TYPE_QUERY },
+ { variables: { id: relationshipTypeId } }
+ )
+ .catch(throwWithRequest(RELATIONSHIP_TYPE_QUERY.resource))
- const { relatedEntityType } = await engine.query(
- { relatedEntityType: TRACKED_ENTITY_TYPE_QUERY },
- {
- variables: {
- id: relationshipType.toConstraint.trackedEntityType.id,
- },
- }
- )
+ const { relatedEntityType } = await engine
+ .query(
+ { relatedEntityType: TRACKED_ENTITY_TYPE_QUERY },
+ {
+ variables: {
+ id: relationshipType.toConstraint.trackedEntityType.id,
+ },
+ }
+ )
+ .catch(throwWithRequest(TRACKED_ENTITY_TYPE_QUERY.resource))
const { primary, relationships, secondary } =
await getDataWithRelationships({