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({