diff --git a/cypress/elements/layer.js b/cypress/elements/layer.js
index f067f60bd..d9c16a0d9 100644
--- a/cypress/elements/layer.js
+++ b/cypress/elements/layer.js
@@ -46,7 +46,7 @@ export class Layer {
.contains(level)
.find('input')
.check()
- cy.get('body').click() // Close the modal menu
+ cy.get('body').click()
return this
}
@@ -59,27 +59,28 @@ export class Layer {
.find('input')
.uncheck()
- cy.get('body').click() // Close the modal menu
+ cy.get('body').click()
return this
}
typeStartDate(dateString) {
- cy.get('label')
- .contains('Start date')
- .next()
+ cy.getByDataTest('calendar-clear-button').eq(0).click()
+ cy.getByDataTest('start-date-input-content')
.find('input')
.type(dateString)
+ cy.get('body').click(0, 0)
return this
}
typeEndDate(dateString) {
- cy.get('label')
- .contains('End date')
- .next()
+ cy.getByDataTest('calendar-clear-button').eq(1).click()
+ cy.getByDataTest('end-date-input-content')
.find('input')
.type(dateString)
+ cy.get('body').click(0, 0)
+
return this
}
diff --git a/cypress/elements/thematic_layer.js b/cypress/elements/thematic_layer.js
index 4aed7bb38..77bbc9c32 100644
--- a/cypress/elements/thematic_layer.js
+++ b/cypress/elements/thematic_layer.js
@@ -55,9 +55,48 @@ export class ThematicLayer extends Layer {
return this
}
- selectPeriodType(periodType) {
- cy.get('[data-test="periodtypeselect"]').click()
- cy.contains(periodType).click()
+ selectPeriodType(periodType, periodDimension = 'fixed', n = 'last') {
+ cy.getByDataTest(
+ `period-dimension-${periodDimension}-periods-button`
+ ).click()
+ cy.getByDataTest(
+ `period-dimension-${periodDimension}-period-filter${
+ periodDimension === 'fixed' ? '-period-type' : ''
+ }-content`
+ ).click()
+ cy.get(`[data-value="${periodType}"]`).then(($el) => {
+ if ($el.hasClass('active')) {
+ cy.get('body').click('topLeft')
+ } else {
+ cy.wrap($el).click()
+ }
+ })
+ if (n === 'last') {
+ cy.getByDataTest(
+ 'period-dimension-transfer-actions-removeall'
+ ).click()
+ cy.getByDataTest('period-dimension-transfer-option-content')
+ .last()
+ .dblclick()
+ } else {
+ cy.getByDataTest(
+ 'period-dimension-transfer-actions-removeall'
+ ).click()
+ cy.getByDataTest('period-dimension-transfer-option-content')
+ .eq(n)
+ .dblclick()
+ }
+
+ return this
+ }
+
+ selectPresets() {
+ cy.contains('Choose from presets').click()
+
+ return this
+ }
+ selectStartEndDates() {
+ cy.contains('Define start - end dates').click()
return this
}
diff --git a/cypress/integration/layers/eventlayer.cy.js b/cypress/integration/layers/eventlayer.cy.js
index 432493e57..f31e5a5d6 100644
--- a/cypress/integration/layers/eventlayer.cy.js
+++ b/cypress/integration/layers/eventlayer.cy.js
@@ -3,11 +3,33 @@ import { EXTENDED_TIMEOUT } from '../../support/util.js'
context('Event Layers', () => {
beforeEach(() => {
- cy.visit('/', EXTENDED_TIMEOUT)
+ cy.visit('/')
})
const Layer = new EventLayer()
+ it('adds an event layer and applies style for boolean data element', () => {
+ Layer.openDialog('Events')
+ .selectProgram('E2E program')
+ .validateStage('Stage 1 - Repeatable')
+ .selectTab('Style')
+
+ cy.getByDataTest('style-by-data-element-select').click()
+
+ cy.getByDataTest('dhis2-uicore-singleselectoption')
+ .contains('E2E - Yes/no')
+ .click()
+
+ cy.getByDataTest('dhis2-uicore-modalactions')
+ .contains('Add layer')
+ .click()
+
+ Layer.validateDialogClosed(true)
+
+ Layer.validateCardTitle('Stage 1 - Repeatable')
+ Layer.validateCardItems(['Yes', 'No', 'Not set'])
+ })
+
it('shows error if no program selected', () => {
Layer.openDialog('Events').addToMap()
@@ -31,28 +53,6 @@ context('Event Layers', () => {
Layer.validateCardItems(['Event'])
})
- it('adds an event layer and applies style for boolean data element', () => {
- Layer.openDialog('Events')
- .selectProgram('E2E program')
- .validateStage('Stage 1 - Repeatable')
- .selectTab('Style')
-
- cy.getByDataTest('style-by-data-element-select').click()
-
- cy.getByDataTest('dhis2-uicore-singleselectoption')
- .contains('E2E - Yes/no')
- .click()
-
- cy.getByDataTest('dhis2-uicore-modalactions')
- .contains('Add layer')
- .click()
-
- Layer.validateDialogClosed(true)
-
- Layer.validateCardTitle('Stage 1 - Repeatable')
- Layer.validateCardItems(['Yes', 'No', 'Not set'])
- })
-
it('opens an event popup', () => {
Layer.openDialog('Events')
.selectProgram('Inpatient morbidity and mortality')
diff --git a/cypress/integration/layers/multilayers.cy.js b/cypress/integration/layers/multilayers.cy.js
index 94ddf64fd..d3c4bafee 100644
--- a/cypress/integration/layers/multilayers.cy.js
+++ b/cypress/integration/layers/multilayers.cy.js
@@ -17,7 +17,7 @@ describe('Multiple Layers', () => {
.selectIndicatorGroup('ANC')
.selectIndicator(INDICATOR_NAME)
.selectTab('Period')
- .selectPeriodType('Yearly')
+ .selectPeriodType('YEARLY')
.selectTab('Org Units')
.selectOu('Sierra Leone')
.selectOuLevel('District')
diff --git a/cypress/integration/layers/telayer.cy.js b/cypress/integration/layers/telayer.cy.js
index f1a41e993..f9b1025df 100644
--- a/cypress/integration/layers/telayer.cy.js
+++ b/cypress/integration/layers/telayer.cy.js
@@ -3,31 +3,11 @@ import { EXTENDED_TIMEOUT } from '../../support/util.js'
describe('Tracked Entity Layers', () => {
beforeEach(() => {
- cy.visit('/', EXTENDED_TIMEOUT)
+ cy.visit('/')
})
const Layer = new TeLayer()
- it('adds a tracked entity layer', () => {
- Layer.openDialog('Tracked entities')
- .selectTab('Data')
- .selectTeType('Malaria Entity')
- .selectTeProgram(
- 'Malaria case diagnosis, treatment and investigation'
- )
- .selectTab('Org Units')
- .selectOu('Bombali')
- .selectOu('Bo')
- .addToMap()
-
- Layer.validateDialogClosed(true)
-
- Layer.validateCardTitle(
- 'Malaria case diagnosis, treatment and investigation'
- )
- Layer.validateCardItems(['Malaria Entity'])
- })
-
it('opens a tracked entity layer popup', () => {
Layer.openDialog('Tracked entities')
.selectTab('Data')
@@ -80,4 +60,24 @@ describe('Tracked Entity Layers', () => {
Layer.validateCardTitle('Malaria focus investigation')
Layer.validateCardItems(['Focus area'])
})
+
+ it('adds a tracked entity layer', () => {
+ Layer.openDialog('Tracked entities')
+ .selectTab('Data')
+ .selectTeType('Malaria Entity')
+ .selectTeProgram(
+ 'Malaria case diagnosis, treatment and investigation'
+ )
+ .selectTab('Org Units')
+ .selectOu('Bombali')
+ .selectOu('Bo')
+ .addToMap()
+
+ Layer.validateDialogClosed(true)
+
+ Layer.validateCardTitle(
+ 'Malaria case diagnosis, treatment and investigation'
+ )
+ Layer.validateCardItems(['Malaria Entity'])
+ })
})
diff --git a/cypress/integration/layers/thematiclayer.cy.js b/cypress/integration/layers/thematiclayer.cy.js
index adeffb1d3..48e3655f4 100644
--- a/cypress/integration/layers/thematiclayer.cy.js
+++ b/cypress/integration/layers/thematiclayer.cy.js
@@ -41,7 +41,7 @@ context('Thematic Layers', () => {
.selectIndicatorGroup('HIV')
.selectIndicator(INDICATOR_NAME)
.selectTab('Period')
- .selectPeriodType('Yearly')
+ .selectPeriodType('YEARLY')
.selectTab('Org Units')
.selectOu('Sierra Leone')
.addToMap()
@@ -66,7 +66,7 @@ context('Thematic Layers', () => {
.selectIndicatorGroup('HIV')
.selectIndicator(INDICATOR_NAME)
.selectTab('Period')
- .selectPeriodType('Yearly')
+ .selectPeriodType('YEARLY')
.selectTab('Org Units')
.selectOu('Bombali')
.selectOu('Bo')
@@ -83,7 +83,7 @@ context('Thematic Layers', () => {
.selectIndicatorGroup('HIV')
.selectIndicator(INDICATOR_NAME)
.selectTab('Period')
- .selectPeriodType('Start/end dates')
+ .selectStartEndDates()
.typeStartDate(`${CURRENT_YEAR}-02-01`)
.typeEndDate(`${CURRENT_YEAR}-11-30`)
.selectTab('Org Units')
@@ -104,7 +104,7 @@ context('Thematic Layers', () => {
.selectIndicatorGroup('Stock')
.selectIndicator('BCG Stock PHU')
.selectTab('Period')
- .selectPeriodType('Start/end dates')
+ .selectStartEndDates()
.typeStartDate(`${CURRENT_YEAR}-11-01`)
.typeEndDate(`${CURRENT_YEAR}-11-30`)
.selectTab('Style')
@@ -175,9 +175,7 @@ context('Thematic Layers', () => {
.selectTab('Org Units')
.selectOu('Sierra Leone')
.selectTab('Period')
-
- cy.getByDataTest('relative-period-select-content').click()
- cy.contains('Last 3 months').click()
+ .selectPeriodType('MONTHLY', 'relative', 2)
cy.get('[type="radio"]').should('have.length', 3)
cy.get('[type="radio"]').check('SPLIT_BY_PERIOD')
@@ -307,7 +305,7 @@ context('Thematic Layers', () => {
.selectIndicatorGroup('HIV')
.selectIndicator(INDICATOR_NAME)
.selectTab('Period')
- .selectPeriodType('Yearly')
+ .selectPeriodType('YEARLY')
.selectTab('Org Units')
.selectOu('Bo')
.unselectOuLevel('District')
diff --git a/cypress/integration/systemsettings.cy.js b/cypress/integration/systemsettings.cy.js
index 52fd45885..0bb98d53c 100644
--- a/cypress/integration/systemsettings.cy.js
+++ b/cypress/integration/systemsettings.cy.js
@@ -34,14 +34,16 @@ describe('systemSettings', () => {
Layer.openDialog('Thematic').selectTab('Period')
- cy.getByDataTest('periodtypeselect-content').click()
+ cy.getByDataTest(
+ 'period-dimension-relative-period-filter-content'
+ ).click()
cy.getByDataTest('dhis2-uicore-select-menu-menuwrapper')
- .contains('Bi-weekly')
+ .contains('Bi-weeks')
.should('be.visible')
cy.getByDataTest('dhis2-uicore-select-menu-menuwrapper')
- .contains('Weekly')
+ .contains('Weeks')
.should('not.exist')
})
@@ -52,14 +54,16 @@ describe('systemSettings', () => {
Layer.openDialog('Thematic').selectTab('Period')
- cy.getByDataTest('periodtypeselect-content').click()
+ cy.getByDataTest(
+ 'period-dimension-relative-period-filter-content'
+ ).click()
cy.getByDataTest('dhis2-uicore-select-menu-menuwrapper')
- .contains('Bi-weekly')
+ .contains('Bi-weeks')
.should('be.visible')
cy.getByDataTest('dhis2-uicore-select-menu-menuwrapper')
- .contains('Weekly')
+ .contains('Weeks')
.should('be.visible')
})
diff --git a/i18n/en.pot b/i18n/en.pot
index 072bf313f..3a5e2610e 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: 2024-10-23T12:50:51.401Z\n"
-"PO-Revision-Date: 2024-10-23T12:50:51.401Z\n"
+"POT-Creation-Date: 2024-12-09T14:15:43.944Z\n"
+"PO-Revision-Date: 2024-12-09T14:15:43.944Z\n"
msgid "2020"
msgstr "2020"
@@ -461,6 +461,12 @@ msgstr "High radius should be greater than low radius"
msgid "Radius should be between {{min}} and {{max}}"
msgstr "Radius should be between {{min}} and {{max}}"
+msgid "Choose from presets"
+msgstr "Choose from presets"
+
+msgid "Define start - end dates"
+msgstr "Define start - end dates"
+
msgid "Indicator group is required"
msgstr "Indicator group is required"
@@ -921,17 +927,23 @@ msgstr "Period type"
msgid "Start/end dates"
msgstr "Start/end dates"
+msgid "Select {{number}} or more periods to enable timeline or split map views."
+msgstr "Select {{number}} or more periods to enable timeline or split map views."
+
msgid "Only one timeline is allowed."
msgstr "Only one timeline is allowed."
msgid "Remove other layers to enable split map views."
msgstr "Remove other layers to enable split map views."
-msgid "Display periods"
-msgstr "Display periods"
+msgid "Only up to {{number}} periods can be selected to enable split map views."
+msgstr "Only up to {{number}} periods can be selected to enable split map views."
-msgid "Single (aggregate)"
-msgstr "Single (aggregate)"
+msgid "Period display mode"
+msgstr "Period display mode"
+
+msgid "Single (combine periods)"
+msgstr "Single (combine periods)"
msgid "Timeline"
msgstr "Timeline"
@@ -939,6 +951,9 @@ msgstr "Timeline"
msgid "Split map views"
msgstr "Split map views"
+msgid "Start and end dates are inclusive and will be included in the outputs."
+msgstr "Start and end dates are inclusive and will be included in the outputs."
+
msgid "Start date"
msgstr "Start date"
diff --git a/package.json b/package.json
index 3a825bb2f..ada9baab8 100644
--- a/package.json
+++ b/package.json
@@ -27,7 +27,7 @@
"@semantic-release/exec": "^6",
"@semantic-release/git": "^10",
"@testing-library/react": "^12.1.5",
- "cypress": "^12.16.0",
+ "cypress": "^13.17.0",
"cypress-tags": "^1.1.2",
"cypress-wait-until": "^1.7.2",
"enzyme": "^3.11.0",
@@ -41,7 +41,7 @@
"start-server-and-test": "^2.0.3"
},
"dependencies": {
- "@dhis2/analytics": "^26.8.4",
+ "@dhis2/analytics": "git+https://github.com/d2-ci/analytics.git#8c539c2cee08357ef6c7daaab2521e3df6ef7801",
"@dhis2/app-runtime": "^3.11.2",
"@dhis2/app-runtime-adapter-d2": "^1.1.0",
"@dhis2/app-service-datastore": "^1.0.0-beta.3",
diff --git a/src/actions/layerEdit.js b/src/actions/layerEdit.js
index b810d7895..afae1496d 100644
--- a/src/actions/layerEdit.js
+++ b/src/actions/layerEdit.js
@@ -185,6 +185,12 @@ export const setPeriodType = (periodType, keepPeriod) => ({
keepPeriod,
})
+// Set periods (thematic)
+export const setPeriods = (periods) => ({
+ type: types.LAYER_EDIT_PERIODS_SET,
+ periods,
+})
+
// Set period (event & thematic)
export const setPeriod = (period) => ({
type: types.LAYER_EDIT_PERIOD_SET,
diff --git a/src/components/core/RadioGroup.js b/src/components/core/RadioGroup.js
index f746175e8..e16600e6f 100644
--- a/src/components/core/RadioGroup.js
+++ b/src/components/core/RadioGroup.js
@@ -12,6 +12,8 @@ const RadioGroup = ({
helpText,
display,
onChange,
+ boldLabel,
+ compact,
children,
dataTest,
}) => {
@@ -28,10 +30,20 @@ const RadioGroup = ({
+ {label}
+
+ }
helpText={helpText}
dataTest={dataTest}
>
@@ -44,7 +56,9 @@ const RadioGroup = ({
RadioGroup.propTypes = {
onChange: PropTypes.func.isRequired,
+ boldLabel: PropTypes.bool,
children: PropTypes.arrayOf(PropTypes.node),
+ compact: PropTypes.bool,
dataTest: PropTypes.string,
display: PropTypes.string,
helpText: PropTypes.string,
diff --git a/src/components/core/styles/RadioGroup.module.css b/src/components/core/styles/RadioGroup.module.css
index 91d4f14b8..65e160d74 100644
--- a/src/components/core/styles/RadioGroup.module.css
+++ b/src/components/core/styles/RadioGroup.module.css
@@ -10,6 +10,10 @@
height: 36px;
}
+.radioGroup.compact fieldset>div>div>label {
+ height: 20px;
+}
+
.row {
margin: var(--spacers-dp8) 0;
}
@@ -21,3 +25,7 @@
.row fieldset > div > div > label {
margin-right: var(--spacers-dp16);
}
+
+.boldLabel {
+ font-weight: bold;
+}
\ No newline at end of file
diff --git a/src/components/datatable/BottomPanel.js b/src/components/datatable/BottomPanel.js
index 82a15c6e5..97a5018a5 100644
--- a/src/components/datatable/BottomPanel.js
+++ b/src/components/datatable/BottomPanel.js
@@ -8,6 +8,7 @@ import {
LAYERS_PANEL_WIDTH,
RIGHT_PANEL_WIDTH,
} from '../../constants/layout.js'
+import useKeyDown from '../../hooks/useKeyDown.js'
import { useWindowDimensions } from '../WindowDimensionsProvider.js'
import DataTable from './DataTable.js'
import ErrorBoundary from './ErrorBoundary.js'
@@ -37,6 +38,8 @@ const BottomPanel = () => {
const tableWidth = width - layersWidth - rightPanelWidth
const dataTableControlsHeight = 20
+ useKeyDown('Escape', () => dispatch(closeDataTable()), true)
+
return (
{
})
useEffect(() => {
- /* The combination of automtic table layout and virtual scrolling
+ /* The combination of automatic table layout and virtual scrolling
* causes a content shift when scrolling and filtering because the
* cells in the DOM have a different content length which causes the
* columns to have a different width. To avoid that we measure the
diff --git a/src/components/download/DownloadSettings.js b/src/components/download/DownloadSettings.js
index a6bfd81e7..de6567b61 100644
--- a/src/components/download/DownloadSettings.js
+++ b/src/components/download/DownloadSettings.js
@@ -3,6 +3,7 @@ import { Button, ButtonStrip } from '@dhis2/ui'
import React, { useState, useMemo, useCallback, useEffect } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { setDownloadConfig } from '../../actions/download.js'
+import useKeyDown from '../../hooks/useKeyDown.js'
import { standardizeFilename } from '../../util/dataDownload.js'
import { downloadMapImage, downloadSupport } from '../../util/export-image.js'
import { getSplitViewLayer } from '../../util/helpers.js'
@@ -105,6 +106,8 @@ const DownloadSettings = () => {
}
}, [isPushAnalytics])
+ useKeyDown('Escape', closeDownloadMode)
+
const isSupported = downloadSupport() && !error
const isSplitView = !!getSplitViewLayer(mapViews)
const showMarginsCheckbox = false // Not in use
diff --git a/src/components/edit/LayerEdit.js b/src/components/edit/LayerEdit.js
index 286909ce6..fce24438f 100644
--- a/src/components/edit/LayerEdit.js
+++ b/src/components/edit/LayerEdit.js
@@ -13,6 +13,7 @@ import React, { useState } from 'react'
import { connect } from 'react-redux'
import { addLayer, updateLayer, cancelLayer } from '../../actions/layers.js'
import { EARTH_ENGINE_LAYER } from '../../constants/layers.js'
+import useKeyDown from '../../hooks/useKeyDown.js'
import { useOrgUnits } from '../OrgUnitsProvider.js'
import EarthEngineDialog from './earthEngine/EarthEngineDialog.js'
import EventDialog from './event/EventDialog.js'
@@ -72,6 +73,9 @@ const LayerEdit = ({ layer, addLayer, updateLayer, cancelLayer }) => {
}
}
+ useKeyDown('Escape', cancelLayer)
+ useKeyDown('Enter', onValidateLayer)
+
if (!layer) {
return null
}
@@ -94,7 +98,12 @@ const LayerEdit = ({ layer, addLayer, updateLayer, cancelLayer }) => {
: i18n.t('Add new {{name}} layer', { name })
return (
-
+
{title}
diff --git a/src/components/edit/event/EventDialog.js b/src/components/edit/event/EventDialog.js
index 647dcee3a..2a18f06bc 100644
--- a/src/components/edit/event/EventDialog.js
+++ b/src/components/edit/event/EventDialog.js
@@ -1,5 +1,5 @@
import i18n from '@dhis2/d2-i18n'
-import { NoticeBox } from '@dhis2/ui'
+import { NoticeBox, IconErrorFilled24 } from '@dhis2/ui'
import PropTypes from 'prop-types'
import React, { Component } from 'react'
import { connect } from 'react-redux'
@@ -18,8 +18,6 @@ import {
setOrgUnits,
} from '../../../actions/layerEdit.js'
import {
- DEFAULT_START_DATE,
- DEFAULT_END_DATE,
EVENT_COLOR,
EVENT_RADIUS,
EVENT_BUFFER,
@@ -47,7 +45,7 @@ import FilterGroup from '../../dataItem/filter/FilterGroup.js'
import StyleByDataItem from '../../dataItem/StyleByDataItem.js'
import OrgUnitSelect from '../../orgunits/OrgUnitSelect.js'
import RelativePeriodSelect from '../../periods/RelativePeriodSelect.js'
-import StartEndDates from '../../periods/StartEndDates.js'
+import StartEndDate from '../../periods/StartEndDate.js'
import ProgramSelect from '../../program/ProgramSelect.js'
import ProgramStageSelect from '../../program/ProgramStageSelect.js'
import BufferRadius from '../shared/BufferRadius.js'
@@ -82,6 +80,7 @@ class EventDialog extends Component {
legendSet: PropTypes.object,
method: PropTypes.number,
orgUnits: PropTypes.object,
+ periodsSettings: PropTypes.object,
program: PropTypes.shape({
id: PropTypes.string.isRequired,
trackedEntityType: PropTypes.object,
@@ -116,8 +115,6 @@ class EventDialog extends Component {
endDate,
orgUnits,
setPeriod,
- setStartDate,
- setEndDate,
setOrgUnits,
} = this.props
@@ -125,20 +122,18 @@ class EventDialog extends Component {
const { keyAnalysisRelativePeriod: defaultPeriod, hiddenPeriods } =
systemSettings
+ const hasDate = startDate !== undefined && endDate !== undefined
+
// Set default period from system settings
if (
!period &&
- !startDate &&
- !endDate &&
+ !hasDate &&
defaultPeriod &&
isPeriodAvailable(defaultPeriod, hiddenPeriods)
) {
setPeriod({
id: defaultPeriod,
})
- } else if (!startDate && !endDate) {
- setStartDate(DEFAULT_START_DATE)
- setEndDate(DEFAULT_END_DATE)
}
// Set org unit tree roots as default
@@ -151,18 +146,38 @@ class EventDialog extends Component {
}
componentDidUpdate(prev) {
- const { validateLayer, onLayerValidation } = this.props
+ const {
+ validateLayer,
+ onLayerValidation,
+ filters,
+ startDate,
+ endDate,
+ setStartDate,
+ setEndDate,
+ } = this.props
+ const { periodError } = this.state
if (validateLayer && validateLayer !== prev.validateLayer) {
onLayerValidation(this.validate())
}
+
+ const hasDate = startDate !== undefined || endDate !== undefined
+ if (hasDate && getPeriodFromFilters(filters) !== undefined) {
+ setStartDate()
+ setEndDate()
+ this.setErrorState('periodError', null, 'period')
+ } else if (
+ periodError &&
+ (startDate !== prev.startDate || endDate !== prev.endDate)
+ ) {
+ this.setErrorState('periodError', null, 'period')
+ }
}
render() {
const {
// layer options
columns = [],
- endDate,
eventClustering,
eventStatus,
eventCoordinateField,
@@ -173,7 +188,9 @@ class EventDialog extends Component {
program,
programStage,
startDate,
+ endDate,
legendSet,
+ periodsSettings,
} = this.props
const {
@@ -272,12 +289,20 @@ class EventDialog extends Component {
className={styles.select}
/>
{period && period.id === START_END_DATES && (
-
)}
+ {periodError && (
+
+
+ {periodError}
+
+ )}
)}
{tab === 'orgunits' && (
diff --git a/src/components/edit/styles/LayerDialog.module.css b/src/components/edit/styles/LayerDialog.module.css
index 26111ae2e..def2b7131 100644
--- a/src/components/edit/styles/LayerDialog.module.css
+++ b/src/components/edit/styles/LayerDialog.module.css
@@ -80,6 +80,11 @@
.error {
margin-top: var(--spacers-dp8);
color: var(--theme-error);
+ font-size: 12px;
+ display: flex;
+ align-items: center;
+ line-height: 1;
+ gap: 8px;
}
.indent {
@@ -109,6 +114,13 @@
margin: 8px 0 0 -8px;
}
+/* Thematic */
+
+.navigation {
+ flex-grow: 0;
+ margin-bottom: 8px;
+}
+
/* Tracked entity */
.teiCheckbox {
diff --git a/src/components/edit/thematic/ThematicDialog.js b/src/components/edit/thematic/ThematicDialog.js
index 28bde8b66..88bb09a78 100644
--- a/src/components/edit/thematic/ThematicDialog.js
+++ b/src/components/edit/thematic/ThematicDialog.js
@@ -1,4 +1,6 @@
+import { PeriodDimension, getRelativePeriodsName } from '@dhis2/analytics'
import i18n from '@dhis2/d2-i18n'
+import { SegmentedControl, IconErrorFilled24 } from '@dhis2/ui'
import PropTypes from 'prop-types'
import React, { Component } from 'react'
import { connect } from 'react-redux'
@@ -11,7 +13,9 @@ import {
setNoDataColor,
setOperand,
setOrgUnits,
- setPeriod,
+ setPeriods,
+ setStartDate,
+ setEndDate,
setPeriodType,
setRenderingStrategy,
setProgram,
@@ -25,13 +29,13 @@ import {
RENDERING_STRATEGY_SINGLE,
} from '../../../constants/layers.js'
import {
- RELATIVE_PERIODS,
+ PREDEFINED_PERIODS,
START_END_DATES,
} from '../../../constants/periods.js'
import {
getDataItemFromColumns,
getOrgUnitsFromRows,
- getPeriodFromFilters,
+ getPeriodsFromFilters,
getDimensionsFromFilters,
} from '../../../util/analytics.js'
import { isPeriodAvailable } from '../../../util/periods.js'
@@ -49,11 +53,8 @@ import DimensionFilter from '../../dimensions/DimensionFilter.js'
import IndicatorGroupSelect from '../../indicator/IndicatorGroupSelect.js'
import IndicatorSelect from '../../indicator/IndicatorSelect.js'
import OrgUnitSelect from '../../orgunits/OrgUnitSelect.js'
-import PeriodSelect from '../../periods/PeriodSelect.js'
-import PeriodTypeSelect from '../../periods/PeriodTypeSelect.js'
-import RelativePeriodSelect from '../../periods/RelativePeriodSelect.js'
import RenderingStrategy from '../../periods/RenderingStrategy.js'
-import StartEndDates from '../../periods/StartEndDates.js'
+import StartEndDate from '../../periods/StartEndDate.js'
import ProgramIndicatorSelect from '../../program/ProgramIndicatorSelect.js'
import ProgramSelect from '../../program/ProgramSelect.js'
import Labels from '../shared/Labels.js'
@@ -70,15 +71,17 @@ class ThematicDialog extends Component {
setClassification: PropTypes.func.isRequired,
setDataElementGroup: PropTypes.func.isRequired,
setDataItem: PropTypes.func.isRequired,
+ setEndDate: PropTypes.func.isRequired,
setIndicatorGroup: PropTypes.func.isRequired,
setLegendSet: PropTypes.func.isRequired,
setNoDataColor: PropTypes.func.isRequired,
setOperand: PropTypes.func.isRequired,
setOrgUnits: PropTypes.func.isRequired,
- setPeriod: PropTypes.func.isRequired,
setPeriodType: PropTypes.func.isRequired,
+ setPeriods: PropTypes.func.isRequired,
setProgram: PropTypes.func.isRequired,
setRenderingStrategy: PropTypes.func.isRequired,
+ setStartDate: PropTypes.func.isRequired,
setValueType: PropTypes.func.isRequired,
validateLayer: PropTypes.bool.isRequired,
onLayerValidation: PropTypes.func.isRequired,
@@ -121,13 +124,13 @@ class ThematicDialog extends Component {
startDate,
systemSettings,
endDate,
- setPeriod,
+ setPeriods,
+ setPeriodType,
setOrgUnits,
} = this.props
const dataItem = getDataItemFromColumns(columns)
- const period = getPeriodFromFilters(filters)
-
+ const periods = getPeriodsFromFilters(filters)
const { keyAnalysisRelativePeriod: defaultPeriod, hiddenPeriods } =
systemSettings
@@ -147,17 +150,30 @@ class ThematicDialog extends Component {
}
}
+ const hasDate = startDate !== undefined && endDate !== undefined
+
+ if (hasDate) {
+ const keepPeriod = false
+ setPeriodType({ value: START_END_DATES }, keepPeriod)
+ } else {
+ const keepPeriod = true
+ setPeriodType({ value: PREDEFINED_PERIODS }, keepPeriod)
+ }
+
// Set default period from system settings
if (
- !period &&
- !startDate &&
- !endDate &&
+ periods?.length == 0 &&
+ !hasDate &&
defaultPeriod &&
isPeriodAvailable(defaultPeriod, hiddenPeriods)
) {
- setPeriod({
- id: defaultPeriod,
- })
+ const defaultPeriods = [
+ {
+ id: defaultPeriod,
+ name: getRelativePeriodsName()[defaultPeriod],
+ },
+ ]
+ setPeriods(defaultPeriods)
}
// Set default org unit level
@@ -180,16 +196,23 @@ class ThematicDialog extends Component {
columns,
periodType,
renderingStrategy,
+ setPeriods,
+ setStartDate,
+ setEndDate,
setClassification,
setLegendSet,
setRenderingStrategy,
validateLayer,
onLayerValidation,
+ startDate,
+ endDate,
+ filters,
} = this.props
+ const { periodError } = this.state
// Set rendering strategy to single if not relative period
if (
- periodType !== RELATIVE_PERIODS &&
+ periodType !== PREDEFINED_PERIODS &&
renderingStrategy !== RENDERING_STRATEGY_SINGLE
) {
setRenderingStrategy(RENDERING_STRATEGY_SINGLE)
@@ -213,11 +236,32 @@ class ThematicDialog extends Component {
if (validateLayer && validateLayer !== prev.validateLayer) {
onLayerValidation(this.validate())
}
+
+ if (periodType !== prev.periodType) {
+ switch (periodType) {
+ case PREDEFINED_PERIODS:
+ setStartDate()
+ setEndDate()
+ break
+ case START_END_DATES:
+ setPeriods([])
+ break
+ }
+ } else if (
+ periodError &&
+ (periodType !== prev.periodType ||
+ startDate !== prev.startDate ||
+ endDate !== prev.endDate ||
+ getPeriodsFromFilters(filters) !==
+ getPeriodsFromFilters(prev.filters))
+ ) {
+ this.setErrorState('periodError', null, 'period')
+ }
}
render() {
const {
- // layer options
+ // Layer options
columns,
dataElementGroup,
filters,
@@ -243,7 +287,7 @@ class ThematicDialog extends Component {
setIndicatorGroup,
setNoDataColor,
setOperand,
- setPeriod,
+ setPeriods,
setPeriodType,
setRenderingStrategy,
setProgram,
@@ -261,13 +305,12 @@ class ThematicDialog extends Component {
calculationError,
eventDataItemError,
programIndicatorError,
- periodTypeError,
periodError,
orgUnitsError,
legendSetError,
} = this.state
- const period = getPeriodFromFilters(filters)
+ const periods = getPeriodsFromFilters(filters)
const dataItem = getDataItemFromColumns(columns)
const dimensions = getDimensionsFromFilters(filters)
@@ -429,51 +472,61 @@ class ThematicDialog extends Component {
className={styles.flexRowFlow}
data-test="thematicdialog-periodtab"
>
-
- {periodType === RELATIVE_PERIODS && (
-
+
+
+ {periodType === PREDEFINED_PERIODS && (
+ {
+ setPeriods(e.items)
+ }}
+ excludedPeriodTypes={
+ systemSettings.hiddenPeriods
+ }
+ height={'250px'}
/>
)}
- {((periodType &&
- periodType !== RELATIVE_PERIODS &&
- periodType !== START_END_DATES) ||
- (!periodType && id)) && (
-
)}
{periodType === START_END_DATES && (
-
)}
- {periodType === RELATIVE_PERIODS && (
-
+ {periodError && (
+
+
+ {periodError}
+
)}
)}
@@ -549,7 +602,7 @@ class ThematicDialog extends Component {
legendSet,
} = this.props
const dataItem = getDataItemFromColumns(columns)
- const period = getPeriodFromFilters(filters)
+ const periods = getPeriodsFromFilters(filters)
// Indicators
if (valueType === dimConf.indicator.objectName) {
@@ -623,6 +676,7 @@ class ThematicDialog extends Component {
}
}
+ // Calculation
if (valueType === dimConf.calculation.objectName && !dataItem) {
return this.setErrorState(
'calculationError',
@@ -631,7 +685,7 @@ class ThematicDialog extends Component {
)
}
- if (!period && periodType !== START_END_DATES) {
+ if (periods?.length === 0 && periodType !== START_END_DATES) {
return this.setErrorState(
'periodError',
i18n.t('Period is required'),
@@ -639,7 +693,6 @@ class ThematicDialog extends Component {
)
} else if (periodType === START_END_DATES) {
const error = getStartEndDateError(startDate, endDate)
-
if (error) {
return this.setErrorState('periodError', error, 'period')
}
@@ -681,7 +734,9 @@ export default connect(
setNoDataColor,
setOperand,
setOrgUnits,
- setPeriod,
+ setPeriods,
+ setStartDate,
+ setEndDate,
setPeriodType,
setRenderingStrategy,
setProgram,
diff --git a/src/components/edit/trackedEntity/PeriodTypeSelect.js b/src/components/edit/trackedEntity/PeriodTypeSelect.js
index 937c48427..b7751d420 100644
--- a/src/components/edit/trackedEntity/PeriodTypeSelect.js
+++ b/src/components/edit/trackedEntity/PeriodTypeSelect.js
@@ -19,7 +19,7 @@ const PeriodTypeSelect = ({
setPeriodType({ id: type })}
+ onChange={(type) => setPeriodType({ value: type })}
>
-
+ {periodError && (
+
+
+ {periodError}
+
+ )}
)}
{tab === 'orgunits' && (
diff --git a/src/components/interpretations/InterpretationMap.js b/src/components/interpretations/InterpretationMap.js
index cfa7140ea..5d28022d0 100644
--- a/src/components/interpretations/InterpretationMap.js
+++ b/src/components/interpretations/InterpretationMap.js
@@ -1,7 +1,7 @@
import PropTypes from 'prop-types'
import React, { useState, useEffect } from 'react'
import useBasemapConfig from '../../hooks/useBasemapConfig.js'
-import { getPeriodFromFilters } from '../../util/analytics.js'
+import { getPeriodsFromFilters } from '../../util/analytics.js'
import { getRelativePeriods } from '../../util/periods.js'
import Map from '../plugin/Map.js'
import styles from './styles/InterpretationMap.module.css'
@@ -14,10 +14,12 @@ const InterpretationMap = ({ visualization, filters, onResponsesReceived }) => {
// Find layers with relative periods
const relativePeriodLayers = visualization.mapViews
.filter((config) => {
- const period = getPeriodFromFilters(config.filters)
+ const periods = getPeriodsFromFilters(config.filters)
return (
- period &&
- getRelativePeriods().find((p) => p.id === period.id)
+ periods &&
+ periods.some((period) =>
+ getRelativePeriods().some((p) => p.id === period.id)
+ )
)
})
.map((layer) => ({
diff --git a/src/components/interpretations/InterpretationsToggle.js b/src/components/interpretations/InterpretationsToggle.js
index a47cce286..d5b43ff48 100644
--- a/src/components/interpretations/InterpretationsToggle.js
+++ b/src/components/interpretations/InterpretationsToggle.js
@@ -5,6 +5,7 @@ import {
openInterpretationsPanel,
closeInterpretationsPanel,
} from '../../actions/ui.js'
+import useKeyDown from '../../hooks/useKeyDown.js'
const InterpretationsToggle = () => {
const interpretationsEnabled = useSelector((state) => Boolean(state.map.id))
@@ -21,6 +22,14 @@ const InterpretationsToggle = () => {
}
}, [dispatch, interpretationsOpen])
+ const onClose = useCallback(() => {
+ if (interpretationsOpen) {
+ dispatch(closeInterpretationsPanel())
+ }
+ }, [dispatch, interpretationsOpen])
+
+ useKeyDown('Escape', onClose, true)
+
return (
{
const { managedLayerSources, showLayerSource, hideLayerSource } =
useManagedLayerSourcesStore()
+ useKeyDown('Escape', onClose)
+
return (
-
+
{i18n.t('Configure available layer sources')}
@@ -51,7 +59,7 @@ const ManageLayerSourcesModal = ({ onClose }) => {
diff --git a/src/components/layers/overlays/AddLayerPopover.js b/src/components/layers/overlays/AddLayerPopover.js
index 9aba2069f..41cbeaf23 100644
--- a/src/components/layers/overlays/AddLayerPopover.js
+++ b/src/components/layers/overlays/AddLayerPopover.js
@@ -6,6 +6,7 @@ import { useSelector, useDispatch } from 'react-redux'
import { addLayer, editLayer } from '../../../actions/layers.js'
import earthEngineLayers from '../../../constants/earthEngineLayers/index.js'
import { EXTERNAL_LAYER } from '../../../constants/layers.js'
+import useKeyDown from '../../../hooks/useKeyDown.js'
import useManagedLayerSourcesStore from '../../../hooks/useManagedLayerSourcesStore.js'
import { isSplitViewMap } from '../../../util/helpers.js'
import ManageLayerSourcesButton from '../../layerSources/ManageLayerSourcesButton.js'
@@ -38,6 +39,8 @@ const AddLayerPopover = ({ anchorEl, onClose, onManaging }) => {
managedLayerSources
)
+ useKeyDown('Escape', onClose)
+
const onLayerSelect = (layer) => {
const config = { ...layer }
const layerType = layer.layer
diff --git a/src/components/layers/overlays/styles/AddLayerButton.module.css b/src/components/layers/overlays/styles/AddLayerButton.module.css
index de920d003..d0c8b5dc6 100644
--- a/src/components/layers/overlays/styles/AddLayerButton.module.css
+++ b/src/components/layers/overlays/styles/AddLayerButton.module.css
@@ -42,7 +42,6 @@
}
.button:focus {
- outline: 3px solid var(--theme-focus);
outline-offset: -3px;
}
diff --git a/src/components/layers/styles/LayerCard.module.css b/src/components/layers/styles/LayerCard.module.css
index 0d95ec8eb..99773c073 100644
--- a/src/components/layers/styles/LayerCard.module.css
+++ b/src/components/layers/styles/LayerCard.module.css
@@ -6,7 +6,7 @@
}
.cardHeader {
- height: 54px;
+ min-height: 54px;
padding: 2px 8px 0 18px;
font-size: 14px;
display: flex;
@@ -26,15 +26,22 @@
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
+ margin: 8px 0 0 0;
+}
+
+.title hz3 {
line-height: 17px;
+ font-size: 14px;
+ font-weight: 400;
margin: 0;
+ color: var(--colors-grey800);
}
.title h3 {
line-height: 17px;
font-size: 14px;
font-weight: 400;
- margin: 0;
+ margin: 4px 32px 8px 0;
color: var(--colors-grey800);
}
diff --git a/src/components/map/Popup.js b/src/components/map/Popup.js
index 277f7f8bc..c628707ea 100644
--- a/src/components/map/Popup.js
+++ b/src/components/map/Popup.js
@@ -1,6 +1,7 @@
import PropTypes from 'prop-types'
import React, { useEffect, useMemo } from 'react'
import { createPortal } from 'react-dom'
+import useKeyDown from '../../hooks/useKeyDown.js'
import OrgUnitButton from '../orgunits/OrgUnitButton.js'
import './styles/Popup.css'
@@ -9,6 +10,8 @@ const Popup = (props, context) => {
const { map, isPlugin } = context
const container = useMemo(() => document.createElement('div'), [])
+ useKeyDown('Escape', () => map.closePopup())
+
// Create and open popup on map
useEffect(() => {
container.className = className
diff --git a/src/components/map/layers/EventPopup.js b/src/components/map/layers/EventPopup.js
index 0bf677511..58ab1cb57 100644
--- a/src/components/map/layers/EventPopup.js
+++ b/src/components/map/layers/EventPopup.js
@@ -9,7 +9,6 @@ import Popup from '../Popup.js'
// Returns true if value is not undefined or null;
const hasValue = (value) => value !== undefined && value !== null
-
const EVENTS_QUERY = {
events: {
resource: 'tracker/events',
@@ -40,10 +39,10 @@ const getDataRows = ({ displayElements, dataValues, styleDataItem, value }) => {
if (valueType === 'COORDINATE' && value) {
formattedValue = formatCoordinate(value)
- } else if (options) {
- formattedValue = options[value]
} else if (!hasValue(value)) {
formattedValue = i18n.t('Not set')
+ } else if (options) {
+ formattedValue = options[value]
}
dataRows.push(
diff --git a/src/components/map/layers/ThematicLayer.js b/src/components/map/layers/ThematicLayer.js
index 5d61c2122..d25d2d678 100644
--- a/src/components/map/layers/ThematicLayer.js
+++ b/src/components/map/layers/ThematicLayer.js
@@ -145,11 +145,19 @@ class ThematicLayer extends Layer {
return
}
+ let sortedPeriods = []
+ if (periods) {
+ sortedPeriods = periods.sort((a, b) => b.level - a.level)
+ sortedPeriods = periods.sort(
+ (a, b) => new Date(a.startDate) - new Date(b.startDate)
+ )
+ }
+
const initialPeriod = {
period:
renderingStrategy === RENDERING_STRATEGY_SINGLE
? null
- : period || periods[0],
+ : period || sortedPeriods[0],
}
// setPeriod without callback is called from the constructor (unmounted)
diff --git a/src/components/map/layers/TrackedEntityPopup.js b/src/components/map/layers/TrackedEntityPopup.js
index f427b8948..ff34195b7 100644
--- a/src/components/map/layers/TrackedEntityPopup.js
+++ b/src/components/map/layers/TrackedEntityPopup.js
@@ -30,10 +30,10 @@ const getDataRows = ({ displayAttributes, attributes }) => {
if (valueType === 'COORDINATE' && value) {
formattedValue = formatCoordinate(value)
- } else if (options) {
- formattedValue = options[value]
} else if (!hasValue(value)) {
formattedValue = i18n.t('Not set')
+ } else if (options) {
+ formattedValue = options[value]
}
dataRows.push(
diff --git a/src/components/orgunits/OrgUnitProfile.js b/src/components/orgunits/OrgUnitProfile.js
index 099908e8d..669e53191 100644
--- a/src/components/orgunits/OrgUnitProfile.js
+++ b/src/components/orgunits/OrgUnitProfile.js
@@ -4,6 +4,7 @@ import { CenteredContent, CircularLoader, IconCross24 } from '@dhis2/ui'
import React, { useEffect } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { closeOrgUnitProfile } from '../../actions/orgUnits.js'
+import useKeyDown from '../../hooks/useKeyDown.js'
import Drawer from '../core/Drawer.js'
import OrgUnitData from './OrgUnitData.js'
import OrgUnitInfo from './OrgUnitInfo.js'
@@ -34,6 +35,8 @@ const OrgUnitProfile = () => {
}
}, [id, refetch])
+ useKeyDown('Escape', () => dispatch(closeOrgUnitProfile()))
+
if (!id) {
return null
}
diff --git a/src/components/periods/RenderingStrategy.js b/src/components/periods/RenderingStrategy.js
index ea1c68e74..6868acdb4 100644
--- a/src/components/periods/RenderingStrategy.js
+++ b/src/components/periods/RenderingStrategy.js
@@ -1,6 +1,6 @@
import i18n from '@dhis2/d2-i18n'
import PropTypes from 'prop-types'
-import React, { useEffect } from 'react'
+import React, { useEffect, useMemo } from 'react'
import { useSelector } from 'react-redux'
import {
RENDERING_STRATEGY_SINGLE,
@@ -8,80 +8,130 @@ import {
RENDERING_STRATEGY_SPLIT_BY_PERIOD,
} from '../../constants/layers.js'
import {
- singleMapPeriods,
- invalidSplitViewPeriods,
+ MULTIMAP_MIN_PERIODS,
+ MULTIMAP_MAX_PERIODS,
} from '../../constants/periods.js'
import usePrevious from '../../hooks/usePrevious.js'
+import { getPeriodsFromFilters } from '../../util/analytics.js'
+import { countPeriods } from '../../util/periods.js'
import { Radio, RadioGroup } from '../core/index.js'
const RenderingStrategy = ({
layerId,
value = RENDERING_STRATEGY_SINGLE,
- period = {},
+ periods = [],
onChange,
}) => {
- const hasOtherLayers = useSelector(
- ({ map }) => !!map.mapViews.filter(({ id }) => id !== layerId).length
+ const prevPeriods = usePrevious(periods)
+ const totalPeriods = useMemo(() => countPeriods(periods), [periods])
+
+ const hasOtherLayers = useSelector(({ map }) =>
+ map.mapViews.some(({ id }) => id !== layerId)
)
- const hasOtherTimelineLayers = useSelector(
- ({ map }) =>
- !!map.mapViews.find(
- (layer) =>
- layer.renderingStrategy === RENDERING_STRATEGY_TIMELINE &&
- layer.id !== layerId
- )
+ const hasOtherTimelineLayers = useSelector(({ map }) =>
+ map.mapViews.some(
+ (layer) =>
+ layer.renderingStrategy === RENDERING_STRATEGY_TIMELINE &&
+ layer.id !== layerId
+ )
)
- const prevPeriod = usePrevious(period)
+ const hasTooManyPeriods = useSelector(({ layerEdit }) => {
+ const periods = getPeriodsFromFilters(layerEdit.filters)
+ return countPeriods(periods) > MULTIMAP_MAX_PERIODS
+ })
useEffect(() => {
- if (period !== prevPeriod) {
- if (
- singleMapPeriods.includes(period.id) &&
- value !== RENDERING_STRATEGY_SINGLE
- ) {
- onChange(RENDERING_STRATEGY_SINGLE)
- } else if (
- invalidSplitViewPeriods.includes(period.id) &&
- value === RENDERING_STRATEGY_SPLIT_BY_PERIOD
- ) {
- // TODO: Switch to 'timeline' when we support it
- onChange(RENDERING_STRATEGY_SINGLE)
- }
+ if (periods === prevPeriods) {
+ return
}
- }, [value, period, prevPeriod, onChange])
- if (singleMapPeriods.includes(period.id)) {
- return null
- }
+ if (
+ totalPeriods < MULTIMAP_MIN_PERIODS &&
+ value !== RENDERING_STRATEGY_SINGLE
+ ) {
+ onChange(RENDERING_STRATEGY_SINGLE)
+ } else if (
+ totalPeriods > MULTIMAP_MAX_PERIODS &&
+ value === RENDERING_STRATEGY_SPLIT_BY_PERIOD
+ ) {
+ onChange(RENDERING_STRATEGY_SINGLE)
+ }
+ }, [value, periods, prevPeriods, onChange, totalPeriods])
- let helpText
+ const helpText = useMemo(() => {
+ const messages = []
+ if (totalPeriods < MULTIMAP_MIN_PERIODS) {
+ messages.push(
+ i18n.t(
+ 'Select {{number}} or more periods to enable timeline or split map views.',
+ {
+ number: MULTIMAP_MIN_PERIODS,
+ }
+ )
+ )
+ }
+ if (hasOtherTimelineLayers) {
+ messages.push(i18n.t('Only one timeline is allowed.'))
+ }
+ if (hasOtherLayers) {
+ messages.push(
+ i18n.t('Remove other layers to enable split map views.')
+ )
+ }
+ if (hasTooManyPeriods) {
+ messages.push(
+ i18n.t(
+ 'Only up to {{number}} periods can be selected to enable split map views.',
+ {
+ number: MULTIMAP_MAX_PERIODS,
+ }
+ )
+ )
+ }
+ return messages.join(' ')
+ }, [
+ totalPeriods,
+ hasOtherTimelineLayers,
+ hasOtherLayers,
+ hasTooManyPeriods,
+ ])
- if (hasOtherTimelineLayers) {
- helpText = i18n.t('Only one timeline is allowed.')
- } else if (hasOtherLayers) {
- helpText = i18n.t('Remove other layers to enable split map views.')
- }
+ const isTimelineDisabled = useMemo(
+ () => totalPeriods < MULTIMAP_MIN_PERIODS || hasOtherTimelineLayers,
+ [totalPeriods, hasOtherTimelineLayers]
+ )
+
+ const isSplitViewDisabled = useMemo(
+ () =>
+ totalPeriods < MULTIMAP_MIN_PERIODS ||
+ hasTooManyPeriods ||
+ hasOtherLayers,
+ [totalPeriods, hasTooManyPeriods, hasOtherLayers]
+ )
return (
-
+
)
@@ -90,7 +140,7 @@ const RenderingStrategy = ({
RenderingStrategy.propTypes = {
onChange: PropTypes.func.isRequired,
layerId: PropTypes.string,
- period: PropTypes.object,
+ periods: PropTypes.array,
value: PropTypes.string,
}
diff --git a/src/components/periods/StartEndDate.js b/src/components/periods/StartEndDate.js
new file mode 100644
index 000000000..acbeb7924
--- /dev/null
+++ b/src/components/periods/StartEndDate.js
@@ -0,0 +1,179 @@
+import i18n from '@dhis2/d2-i18n'
+import { Field, IconArrowRight16, CalendarInput, colors } from '@dhis2/ui'
+import PropTypes from 'prop-types'
+import React, { useState } from 'react'
+import { connect } from 'react-redux'
+import { setStartDate, setEndDate } from '../../actions/layerEdit.js'
+import useKeyDown from '../../hooks/useKeyDown.js'
+import styles from './styles/StartEndDate.module.css'
+
+const formatDate = (date, calendar = 'iso8601') => {
+ if (calendar === 'iso8601') {
+ return formatDateIso8601(date)
+ }
+ return formatDateDefault(date)
+}
+const formatDateDefault = (date) => {
+ if (!date) {
+ return ''
+ }
+ return date
+}
+const formatDateIso8601 = (date) => {
+ if (!date) {
+ return ''
+ }
+
+ const numericDate = date.replace(/\D/g, '')
+
+ if (numericDate.length < 5) {
+ return numericDate
+ }
+
+ const year = numericDate.slice(0, 4)
+ const month = numericDate.slice(4, 6)
+ const day = numericDate.slice(6, 8)
+
+ if (numericDate.length < 7) {
+ return `${year}-${month}`
+ }
+ if (numericDate.length < 8) {
+ return `${year}-${month}-${day}`
+ }
+
+ const formattedYear = year === '0000' ? '2000' : year
+ let formattedMonth = month === '00' ? '01' : month
+ formattedMonth = formattedMonth > 12 ? '12' : formattedMonth
+
+ let formattedDay = day === '00' ? '01' : day
+
+ const maxDaysInMonth = getMaxDaysInMonth(formattedYear, formattedMonth)
+ formattedDay = formattedDay > maxDaysInMonth ? maxDaysInMonth : formattedDay
+
+ return `${formattedYear}-${formattedMonth}-${formattedDay}`
+}
+
+const getMaxDaysInMonth = (year, month) => {
+ const monthDays = {
+ '02': 28,
+ '04': 30,
+ '06': 30,
+ '09': 30,
+ 11: 30,
+ }
+
+ if (month === '02') {
+ return isLeapYear(year) ? 29 : 28
+ }
+
+ return monthDays[month] || 31
+}
+
+const isLeapYear = (year) => {
+ return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0
+}
+
+const createBoundHandler = (localSetter, reduxSetter, calendar) => (value) => {
+ const formattedDate = formatDate(value, calendar)
+ localSetter(formattedDate)
+ reduxSetter(formattedDate)
+}
+
+const StartEndDate = (props) => {
+ const {
+ startDate = '',
+ endDate = '',
+ setStartDate,
+ setEndDate,
+ errorText,
+ periodsSettings,
+ } = props
+ const [startDateInput, setStartDateInput] = useState(
+ formatDate(startDate, periodsSettings?.calendar)
+ )
+ const [endDateInput, setEndDateInput] = useState(
+ formatDate(endDate, periodsSettings?.calendar)
+ )
+
+ const onStartDateChange = createBoundHandler(
+ setStartDateInput,
+ setStartDate,
+ periodsSettings?.calendar
+ )
+ const onEndDateChange = createBoundHandler(
+ setEndDateInput,
+ setEndDate,
+ periodsSettings?.calendar
+ )
+
+ // Forces calendar to close when using Tab/Enter navigation
+ useKeyDown(['Tab', 'Enter'], () => {
+ const backdropElement = document.querySelectorAll('.backdrop')
+ if (backdropElement?.length === 3) {
+ backdropElement[2].click()
+ }
+ })
+
+ const hasDate = startDate !== undefined && endDate !== undefined
+ if (!hasDate) {
+ return null
+ }
+
+ return (
+
+
+
+ onStartDateChange(e?.calendarDateString)
+ }
+ onChange={(e) => onStartDateChange(e?.value)}
+ placeholder="YYYY-MM-DD"
+ dataTest="start-date-input"
+ strictValidation={true}
+ clearable={true}
+ />
+
+
+
+ onEndDateChange(e?.calendarDateString)}
+ onChange={(e) => onEndDateChange(e?.value)}
+ placeholder="YYYY-MM-DD"
+ dataTest="end-date-input"
+ strictValidation={true}
+ clearable={true}
+ />
+
+ {errorText && (
+
+ {errorText}
+
+ )}
+
+ )
+}
+StartEndDate.propTypes = {
+ setEndDate: PropTypes.func.isRequired,
+ setStartDate: PropTypes.func.isRequired,
+ endDate: PropTypes.string,
+ errorText: PropTypes.string,
+ periodsSettings: PropTypes.shape({
+ calendar: PropTypes.string,
+ locale: PropTypes.string,
+ }),
+ startDate: PropTypes.string,
+}
+
+export default connect(null, { setStartDate, setEndDate })(StartEndDate)
diff --git a/src/components/periods/StartEndDates.js b/src/components/periods/StartEndDates.js
deleted file mode 100644
index 12d3d8b1a..000000000
--- a/src/components/periods/StartEndDates.js
+++ /dev/null
@@ -1,60 +0,0 @@
-import i18n from '@dhis2/d2-i18n'
-import PropTypes from 'prop-types'
-import React, { Fragment, useEffect } from 'react'
-import { connect } from 'react-redux'
-import { setStartDate, setEndDate } from '../../actions/layerEdit.js'
-import { DEFAULT_START_DATE, DEFAULT_END_DATE } from '../../constants/layers.js'
-import { DatePicker } from '../core/index.js'
-import styles from '../edit/styles/LayerDialog.module.css'
-
-const StartEndDates = (props) => {
- const {
- startDate,
- endDate,
- setStartDate,
- setEndDate,
- errorText,
- className,
- } = props
- const hasDate = startDate !== undefined && endDate !== undefined
-
- useEffect(() => {
- if (!hasDate) {
- setStartDate(DEFAULT_START_DATE)
- setEndDate(DEFAULT_END_DATE)
- }
- }, [hasDate, setStartDate, setEndDate])
-
- return hasDate ? (
-
-
-
- {errorText && (
-
- {errorText}
-
- )}
-
- ) : null
-}
-
-StartEndDates.propTypes = {
- setEndDate: PropTypes.func.isRequired,
- setStartDate: PropTypes.func.isRequired,
- className: PropTypes.string,
- endDate: PropTypes.string,
- errorText: PropTypes.string,
- startDate: PropTypes.string,
-}
-
-export default connect(null, { setStartDate, setEndDate })(StartEndDates)
diff --git a/src/components/periods/Timeline.js b/src/components/periods/Timeline.js
index 4d4b8de3d..5ba87b5ad 100644
--- a/src/components/periods/Timeline.js
+++ b/src/components/periods/Timeline.js
@@ -4,99 +4,74 @@ import { scaleTime } from 'd3-scale'
import { select } from 'd3-selection'
import PropTypes from 'prop-types'
import React, { Component } from 'react'
+import { doubleTicksPeriods } from '../../constants/periods.js'
+import {
+ getPeriodTypeFromId,
+ getPeriodLevelFromPeriodType,
+} from '../../util/periods.js'
import timeTicks from '../../util/timeTicks.js'
import styles from './styles/Timeline.module.css'
-const paddingLeft = 40
-const paddingRight = 20
-const labelWidth = 80
-const delay = 1500
-const playBtn =
-const pauseBtn =
-const doubleTicksPeriods = ['LAST_6_BIMONTHS', 'BIMONTHS_THIS_YEAR']
+// Constants
+const PADDING_LEFT = 40
+const PADDING_RIGHT = 20
+const LABEL_WIDTH = 80
+const RECT_HEIGHT = 8
+const RECT_OFFSET = 8
+const DELAY = 1500
+const PLAY_ICON =
+const PAUSE_ICON =
+
+// Utility Functions
+const addPeriodDetails = (period) => {
+ const type = getPeriodTypeFromId(period.id)
+ const level = getPeriodLevelFromPeriodType(type)
+ return { ...period, type, level }
+}
+
+const getUniqueLevels = (periodsWithLevel) => [
+ ...new Set(periodsWithLevel.map((item) => item.level)),
+]
+
+const countUniqueRanks = (periods) => {
+ const periodsWithDetails = periods.map(addPeriodDetails)
+ return getUniqueLevels(periodsWithDetails).length
+}
+
+const sortPeriodsByLevelRank = (periods) => {
+ const periodsWithDetails = periods.map(addPeriodDetails)
+ const sortedLevels = getUniqueLevels(periodsWithDetails).sort(
+ (a, b) => b - a
+ )
+ return periodsWithDetails
+ .map((item) => ({
+ ...item,
+ levelRank: sortedLevels.indexOf(item.level),
+ }))
+ .sort((a, b) => a.levelRank - b.levelRank)
+}
+const sortPeriodsByLevelAndStartDate = (periods) =>
+ periods
+ .map(addPeriodDetails)
+ .sort((a, b) => b.level - a.level)
+ .sort((a, b) => new Date(a.startDate) - new Date(b.startDate))
+
+// Timeline Component
class Timeline extends Component {
static contextTypes = {
map: PropTypes.object,
}
-
static propTypes = {
period: PropTypes.object.isRequired,
- periodId: PropTypes.string.isRequired,
periods: PropTypes.array.isRequired,
onChange: PropTypes.func.isRequired,
}
-
state = {
width: null,
mode: 'start',
}
- componentDidMount() {
- this.setWidth()
- this.context.map.on('resize', this.setWidth)
- }
-
- componentDidUpdate() {
- this.setTimeAxis()
- }
-
- componentWillUnmount() {
- this.context.map.off('resize', this.setWidth)
- }
-
- render() {
- const { mode } = this.state
-
- this.setTimeScale()
-
- return (
-
- )
- }
-
- // Returns array of period rectangles
- getPeriodRects = () => {
- const { period, periods } = this.props
-
- return periods.map((item) => {
- const isCurrent = period.id === item.id
- const { id, startDate, endDate } = item
- const x = this.timeScale(startDate)
- const width = this.timeScale(endDate) - x
-
- return (
- this.onPeriodClick(item)}
- />
- )
- })
- }
-
// Set time scale
setTimeScale = () => {
const { periods } = this.props
@@ -106,74 +81,68 @@ class Timeline extends Component {
return
}
- const { startDate } = periods[0]
- const { endDate } = periods[periods.length - 1]
+ const { minStartDate, maxEndDate } = periods.reduce(
+ (acc, { startDate, endDate }) => {
+ const start = new Date(startDate)
+ const end = new Date(endDate)
+ return {
+ minStartDate:
+ start < acc.minStartDate ? start : acc.minStartDate,
+ maxEndDate: end > acc.maxEndDate ? end : acc.maxEndDate,
+ }
+ },
+ {
+ minStartDate: new Date(periods[0].startDate),
+ maxEndDate: new Date(periods[0].endDate),
+ }
+ )
// Link time domain to timeline width
this.timeScale = scaleTime()
- .domain([startDate, endDate])
+ .domain([minStartDate, maxEndDate])
.range([0, width])
}
// Set timeline axis
setTimeAxis = () => {
- const { periodId, periods } = this.props
+ const { periods } = this.props
+ const periodsType = periods.map(({ id }) => getPeriodTypeFromId(id))
const numPeriods =
- periods.length * (doubleTicksPeriods.includes(periodId) ? 2 : 1)
+ periods.length *
+ (doubleTicksPeriods.some((element) => periodsType.includes(element))
+ ? 2
+ : 1)
const { width } = this.state
- const maxTicks = Math.round(width / labelWidth)
- const numTicks = maxTicks < numPeriods ? maxTicks : numPeriods
- const timeAxis = axisBottom(this.timeScale)
- const [startDate, endDate] = this.timeScale.domain()
- const ticks = timeTicks(startDate, endDate, numTicks)
-
- timeAxis.tickValues(ticks)
+ const maxTicks = Math.round(width / LABEL_WIDTH)
+ const numTicks = Math.min(maxTicks, numPeriods)
+ const ticks = timeTicks(...this.timeScale.domain(), numTicks)
+ const timeAxis = axisBottom(this.timeScale).tickValues(ticks)
select(this.node).call(timeAxis)
}
- // Set timeline width from DOM el
+ // Set timeline width from DOM element
setWidth = () => {
if (this.node) {
// clientWith returns 0 for SVG elements in Firefox
const box = this.node.parentNode.getBoundingClientRect()
- const width = box.right - box.left - paddingLeft - paddingRight
-
+ const width = box.right - box.left - PADDING_LEFT - PADDING_RIGHT
this.setState({ width })
}
}
- // Handler for period click
- onPeriodClick(period) {
- // Switch to period if different
- if (period.id !== this.props.period.id) {
- this.props.onChange(period)
- }
-
- // Stop animation if running
- this.stop()
- }
-
- // Handler for play/pause button
- onPlayPause = () => {
- if (this.state.mode === 'play') {
- this.stop()
- } else {
- this.play()
- }
- }
-
// Play animation
play = () => {
const { period, periods, onChange } = this.props
- const index = periods.findIndex((p) => p.id === period.id)
- const isLastPeriod = index === periods.length - 1
+ const sortedPeriods = sortPeriodsByLevelAndStartDate(periods)
+ const currentIndex = sortedPeriods.findIndex((p) => p.id === period.id)
+ const isLastPeriod = currentIndex === sortedPeriods.length - 1
// If new animation
if (!this.timeout) {
// Switch to first period if last
if (isLastPeriod) {
- onChange(periods[0])
+ onChange(sortedPeriods[0])
}
this.setState({ mode: 'play' })
@@ -185,11 +154,11 @@ class Timeline extends Component {
}
// Switch to next period
- onChange(periods[index + 1])
+ onChange(sortedPeriods[currentIndex + 1])
}
- // Call itself after delay
- this.timeout = setTimeout(this.play, delay)
+ // Call itself after DELAY
+ this.timeout = setTimeout(this.play, DELAY)
}
// Stop animation
@@ -198,6 +167,105 @@ class Timeline extends Component {
clearTimeout(this.timeout)
delete this.timeout
}
+
+ // Handler for play/pause button
+ onPlayPause = () => {
+ if (this.state.mode === 'play') {
+ this.stop()
+ } else {
+ this.play()
+ }
+ }
+
+ // Handler for period click
+ onPeriodClick(period) {
+ // Switch to period if different
+ if (period.id !== this.props.period.id) {
+ this.props.onChange(period)
+ }
+
+ // Stop animation if running
+ this.stop()
+ }
+
+ componentDidMount() {
+ this.setWidth()
+ this.context.map.on('resize', this.setWidth)
+ }
+
+ componentDidUpdate() {
+ this.setTimeAxis()
+ }
+
+ componentWillUnmount() {
+ this.context.map.off('resize', this.setWidth)
+ }
+
+ render() {
+ const { mode } = this.state
+ const uniqueRanks = countUniqueRanks(this.props.periods)
+ const rectTotalHeight = RECT_HEIGHT + (uniqueRanks - 1) * RECT_OFFSET
+
+ this.setTimeScale()
+ return (
+
+ )
+ }
+
+ // Returns array of period rectangles
+ getPeriodRects = () => {
+ const { period, periods } = this.props
+
+ const sortedPeriods = sortPeriodsByLevelRank(periods)
+
+ return sortedPeriods.map((item) => {
+ const isCurrent = period.id === item.id
+ const { id, startDate, endDate } = item
+ const x = this.timeScale(startDate)
+ const width = this.timeScale(endDate) - x
+
+ return (
+ this.onPeriodClick(item)}
+ />
+ )
+ })
+ }
}
export default Timeline
diff --git a/src/components/periods/__tests__/RenderingStrategy.spec.js b/src/components/periods/__tests__/RenderingStrategy.spec.js
new file mode 100644
index 000000000..b8fc1413c
--- /dev/null
+++ b/src/components/periods/__tests__/RenderingStrategy.spec.js
@@ -0,0 +1,88 @@
+import { mount } from 'enzyme'
+import React from 'react'
+import { Provider } from 'react-redux'
+import configureMockStore from 'redux-mock-store'
+import {
+ RENDERING_STRATEGY_SINGLE,
+ RENDERING_STRATEGY_TIMELINE,
+ RENDERING_STRATEGY_SPLIT_BY_PERIOD,
+} from '../../../constants/layers.js'
+import { countPeriods } from '../../../util/periods.js'
+import RenderingStrategy from '../RenderingStrategy.js'
+
+const mockStore = configureMockStore()
+
+const store = mockStore({
+ map: {
+ mapViews: [{ id: 'layer1', renderingStrategy: 'SINGLE' }],
+ },
+ layerEdit: {
+ filters: [],
+ },
+})
+
+jest.mock('../../../util/periods', () => ({
+ countPeriods: jest.fn(),
+}))
+
+describe('RenderingStrategy', () => {
+ const renderWithProps = (props) =>
+ mount(
+
+
+
+ )
+
+ const layerId = 'layer1'
+ const value = RENDERING_STRATEGY_SPLIT_BY_PERIOD
+ const periods = []
+ const mockOnChange = jest.fn()
+ let props
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ props = {
+ layerId,
+ value,
+ periods,
+ onChange: mockOnChange,
+ }
+ })
+
+ it('renders all radio buttons with correct labels', () => {
+ countPeriods.mockReturnValue(5)
+ const wrapper = renderWithProps(props)
+ expect(wrapper.find('input').length).toBe(3) // Three radio buttons
+ expect(wrapper.find('label').at(0).text()).toBe('Period display mode')
+ expect(wrapper.find('label').at(1).text()).toBe(
+ 'Single (combine periods)'
+ )
+ expect(wrapper.find('label').at(2).text()).toBe('Timeline')
+ expect(wrapper.find('label').at(3).text()).toBe('Split map views')
+ })
+
+ it('disables timeline and split map views when total periods are below the minimum', () => {
+ countPeriods.mockReturnValue(1)
+ const wrapper = renderWithProps(props)
+ expect(wrapper.find('input').at(1).prop('disabled')).toBeDefined() // Timeline should be disabled
+ expect(wrapper.find('input').at(2).prop('disabled')).toBeDefined()
+ })
+
+ it('calls onChange with correct value when a radio button is clicked', () => {
+ countPeriods.mockReturnValue(5)
+ const wrapper = renderWithProps(props)
+ wrapper
+ .find('input')
+ .at(1)
+ .simulate('change', {
+ target: { value: RENDERING_STRATEGY_TIMELINE },
+ })
+ expect(mockOnChange).toHaveBeenCalledWith(RENDERING_STRATEGY_TIMELINE)
+ })
+
+ it('automatically switches to SINGLE when conditions are not met', () => {
+ countPeriods.mockReturnValue(1)
+ renderWithProps(props)
+ expect(mockOnChange).toHaveBeenCalledWith(RENDERING_STRATEGY_SINGLE)
+ })
+})
diff --git a/src/components/periods/__tests__/Timeline.spec.js b/src/components/periods/__tests__/Timeline.spec.js
index ecba61f4b..34385a72d 100644
--- a/src/components/periods/__tests__/Timeline.spec.js
+++ b/src/components/periods/__tests__/Timeline.spec.js
@@ -35,6 +35,15 @@ describe('Timeline', () => {
endDate: new Date('2019-06-30T24:00:00.000'),
},
]
+ const periodOnChange = {
+ id: '201904',
+ name: 'April 2019',
+ startDate: new Date('2019-04-01T00:00:00.000'),
+ endDate: new Date('2019-04-30T24:00:00.000'),
+ level: 7,
+ levelRank: 0,
+ type: 'MONTHLY',
+ }
const onChangeSpy = jest.fn()
let props
@@ -69,7 +78,7 @@ describe('Timeline', () => {
it('should call onChange with the period clicked', () => {
const wrapper = renderWithProps(props)
wrapper.find('rect').first().simulate('click')
- expect(onChangeSpy).toHaveBeenCalledWith(periods[0])
+ expect(onChangeSpy).toHaveBeenCalledWith(periodOnChange)
})
it('Should toggle play mode when play/pause button is clicked', () => {
@@ -80,7 +89,7 @@ describe('Timeline', () => {
playPauseBtn.simulate('click')
expect(wrapper.state('mode')).toBe('play')
// Called because current period is the last
- expect(onChangeSpy).toHaveBeenCalledWith(periods[0])
+ expect(onChangeSpy).toHaveBeenCalledWith(periodOnChange)
playPauseBtn.simulate('click')
expect(wrapper.state('mode')).toBe('stop')
})
diff --git a/src/components/periods/styles/StartEndDate.module.css b/src/components/periods/styles/StartEndDate.module.css
new file mode 100644
index 000000000..3f9a6bb4f
--- /dev/null
+++ b/src/components/periods/styles/StartEndDate.module.css
@@ -0,0 +1,15 @@
+.row {
+ display: flex;
+ gap: var(--spacers-dp4);
+ align-items: flex-end;
+}
+.icon {
+ flex-grow: 0;
+ height: 40px;
+ display: flex;
+ align-items: center;
+}
+.error {
+ margin-top: var(--spacers-dp8);
+ color: var(--theme-error);
+}
\ No newline at end of file
diff --git a/src/components/periods/styles/Timeline.module.css b/src/components/periods/styles/Timeline.module.css
index 5f87f8ca4..11c431358 100644
--- a/src/components/periods/styles/Timeline.module.css
+++ b/src/components/periods/styles/Timeline.module.css
@@ -3,8 +3,6 @@
width: calc(100% - 20px);
position: absolute;
left: 10px;
- bottom: 28px;
- height: 48px;
z-index: 1000px;
user-select: none;
}
@@ -19,18 +17,19 @@
}
.period {
- fill: #333;
+ fill: #ffffff;
stroke: #555;
stroke-width: 1px;
- fill-opacity: 0.1;
+ fill-opacity: .8;
cursor: pointer;
}
.period.selected {
- fill: #2b675c;
- fill-opacity: 0.9;
+ fill: #147cd7;
+ fill-opacity: 1;
}
.period:hover:not(.selected) {
- fill-opacity: 0.2;
+ fill: #F3F5F7;
+ fill-opacity: .9;
}
diff --git a/src/constants/actionTypes.js b/src/constants/actionTypes.js
index ba3596a75..a1a1536e7 100644
--- a/src/constants/actionTypes.js
+++ b/src/constants/actionTypes.js
@@ -57,6 +57,7 @@ export const LAYER_EDIT_PROGRAM_INDICATOR_SET =
'LAYER_EDIT_PROGRAM_INDICATOR_SET'
export const LAYER_EDIT_PERIOD_NAME_SET = 'LAYER_EDIT_PERIOD_NAME_SET'
export const LAYER_EDIT_PERIOD_SET = 'LAYER_EDIT_PERIOD_SET'
+export const LAYER_EDIT_PERIODS_SET = 'LAYER_EDIT_PERIODS_SET'
export const LAYER_EDIT_START_DATE_SET = 'LAYER_EDIT_START_DATE_SET'
export const LAYER_EDIT_END_DATE_SET = 'LAYER_EDIT_END_DATE_SET'
export const LAYER_EDIT_FILTER_ADD = 'LAYER_EDIT_FILTER_ADD'
diff --git a/src/constants/periods.js b/src/constants/periods.js
index 16deca6d0..837eb42f1 100644
--- a/src/constants/periods.js
+++ b/src/constants/periods.js
@@ -35,15 +35,8 @@ export const periodGroups = [
// TODO: import from @dhis2/analytics (needs to be defined)
const TODAY = 'TODAY'
const YESTERDAY = 'YESTERDAY'
-const LAST_14_DAYS = 'LAST_14_DAYS'
-const LAST_30_DAYS = 'LAST_30_DAYS'
-const LAST_60_DAYS = 'LAST_60_DAYS'
-const LAST_90_DAYS = 'LAST_90_DAYS'
-const LAST_180_DAYS = 'LAST_180_DAYS'
const THIS_WEEK = 'THIS_WEEK'
const LAST_WEEK = 'LAST_WEEK'
-const LAST_52_WEEKS = 'LAST_52_WEEKS'
-const WEEKS_THIS_YEAR = 'WEEKS_THIS_YEAR'
const THIS_BIWEEK = 'THIS_BIWEEK'
const LAST_BIWEEK = 'LAST_BIWEEK'
const THIS_MONTH = 'THIS_MONTH'
@@ -52,12 +45,18 @@ const THIS_BIMONTH = 'THIS_BIMONTH'
const LAST_BIMONTH = 'LAST_BIMONTH'
const THIS_QUARTER = 'THIS_QUARTER'
const LAST_QUARTER = 'LAST_QUARTER'
-const THIS_YEAR = 'THIS_YEAR'
-const LAST_YEAR = 'LAST_YEAR'
+const THIS_SIX_MONTH = 'THIS_SIX_MONTH'
+const LAST_SIX_MONTH = 'LAST_SIX_MONTH'
const THIS_FINANCIAL_YEAR = 'THIS_FINANCIAL_YEAR'
const LAST_FINANCIAL_YEAR = 'LAST_FINANCIAL_YEAR'
+const THIS_YEAR = 'THIS_YEAR'
+const LAST_YEAR = 'LAST_YEAR'
+
+export const doubleTicksPeriods = [BIMONTHLY, SIXMONTHLY, SIXMONTHLYAPR]
+export const PREDEFINED_PERIODS = 'PREDEFINED_PERIODS'
export const RELATIVE_PERIODS = 'RELATIVE_PERIODS'
+export const FIXED_PERIODS = 'FIXED_PERIODS'
export const START_END_DATES = 'START_END_DATES'
export const periodTypes = (includeRelativePeriods) => [
@@ -174,22 +173,17 @@ export const singleMapPeriods = [
LAST_BIMONTH,
THIS_QUARTER,
LAST_QUARTER,
- THIS_YEAR,
- LAST_YEAR,
+ THIS_SIX_MONTH,
+ LAST_SIX_MONTH,
THIS_FINANCIAL_YEAR,
LAST_FINANCIAL_YEAR,
+ THIS_YEAR,
+ LAST_YEAR,
]
// Periods not supported for split view (maximum 12 maps)
-export const invalidSplitViewPeriods = [
- LAST_14_DAYS,
- LAST_30_DAYS,
- LAST_60_DAYS,
- LAST_90_DAYS,
- LAST_180_DAYS,
- LAST_52_WEEKS,
- WEEKS_THIS_YEAR,
-]
+export const MULTIMAP_MIN_PERIODS = 2
+export const MULTIMAP_MAX_PERIODS = 12
// Period types used for Earth Engine layers
export const BY_YEAR = 'BY_YEAR'
diff --git a/src/constants/settings.js b/src/constants/settings.js
index 016a66ef1..c2334ffd0 100644
--- a/src/constants/settings.js
+++ b/src/constants/settings.js
@@ -6,14 +6,20 @@ export const DEFAULT_SYSTEM_SETTINGS = {
keyDefaultBaseMap: FALLBACK_BASEMAP_ID,
}
+export const SYSTEM_SETTINGS_HIDE_DAILY_PERIODS = 'keyHideDailyPeriods'
+export const SYSTEM_SETTINGS_HIDE_WEEKLY_PERIODS = 'keyHideWeeklyPeriods'
+export const SYSTEM_SETTINGS_HIDE_BIWEEKLY_PERIODS = 'keyHideBiWeeklyPeriods'
+export const SYSTEM_SETTINGS_HIDE_MONTHLY_PERIODS = 'keyHideMonthlyPeriods'
+export const SYSTEM_SETTINGS_HIDE_BIMONTHLY_PERIODS = 'keyHideBiMonthlyPeriods'
+
export const SYSTEM_SETTINGS = [
'keyAnalysisRelativePeriod',
'keyBingMapsApiKey',
- 'keyHideDailyPeriods',
- 'keyHideWeeklyPeriods',
- 'keyHideBiWeeklyPeriods',
- 'keyHideMonthlyPeriods',
- 'keyHideBiMonthlyPeriods',
+ SYSTEM_SETTINGS_HIDE_DAILY_PERIODS,
+ SYSTEM_SETTINGS_HIDE_WEEKLY_PERIODS,
+ SYSTEM_SETTINGS_HIDE_BIWEEKLY_PERIODS,
+ SYSTEM_SETTINGS_HIDE_MONTHLY_PERIODS,
+ SYSTEM_SETTINGS_HIDE_BIMONTHLY_PERIODS,
'keyDefaultBaseMap',
]
diff --git a/src/hooks/useKeyDown.js b/src/hooks/useKeyDown.js
new file mode 100644
index 000000000..e83da40fa
--- /dev/null
+++ b/src/hooks/useKeyDown.js
@@ -0,0 +1,41 @@
+import { useEffect, useRef, useMemo } from 'react'
+
+const useKeyDown = (key, callback, longPress = false) => {
+ const timerRef = useRef(null)
+
+ const keys = useMemo(() => (Array.isArray(key) ? key : [key]), [key])
+
+ useEffect(() => {
+ const handleKeyDown = (event) => {
+ if (keys.includes(event.key)) {
+ if (!longPress) {
+ callback(event)
+ } else {
+ timerRef.current = setTimeout(() => {
+ callback(event)
+ }, 250) // Adjust delay for long press detection
+ }
+ }
+ }
+
+ const handleKeyUp = (event) => {
+ if (keys.includes(event.key) && longPress) {
+ clearTimeout(timerRef.current)
+ }
+ }
+
+ window.addEventListener('keydown', handleKeyDown)
+ window.addEventListener('keyup', handleKeyUp)
+
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown)
+ window.removeEventListener('keyup', handleKeyUp)
+ }
+ }, [keys, callback, longPress])
+
+ useEffect(() => {
+ return () => clearTimeout(timerRef.current)
+ }, [])
+}
+
+export default useKeyDown
diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js
index 03f52b2ea..f27b09c54 100644
--- a/src/loaders/thematicLoader.js
+++ b/src/loaders/thematicLoader.js
@@ -23,7 +23,7 @@ import {
} from '../constants/layers.js'
import {
getOrgUnitsFromRows,
- getPeriodFromFilters,
+ getPeriodsFromFilters,
getValidDimensionsFromFilters,
getDataItemFromColumns,
getApiResponseNames,
@@ -97,10 +97,13 @@ const thematicLoader = async ({ config, engine, nameProperty }) => {
const isSingleMap = renderingStrategy === RENDERING_STRATEGY_SINGLE
const isBubbleMap = thematicMapType === THEMATIC_BUBBLE
const isSingleColor = config.method === CLASSIFICATION_SINGLE_COLOR
- const period = getPeriodFromFilters(config.filters)
+ const names = getApiResponseNames(data)
+ const presetPeriods = getPeriodsFromFilters(config.filters).map((pe) => {
+ pe.name = names[pe.id]
+ return pe
+ })
const periods = getPeriodsFromMetaData(data.metaData)
const dimensions = getValidDimensionsFromFilters(config.filters)
- const names = getApiResponseNames(data)
const valuesByPeriod = !isSingleMap ? getValuesByPeriod(data) : null
const valueById = getValueById(data)
const valueFeatures = noDataColor
@@ -149,12 +152,13 @@ const thematicLoader = async ({ config, engine, nameProperty }) => {
const legend = {
title: name,
- period: period
- ? names[period.id] || period.id
- : formatStartEndDate(
- getDateArray(config.startDate),
- getDateArray(config.endDate)
- ),
+ period:
+ presetPeriods.length > 0
+ ? presetPeriods.map((pe) => pe.name || pe.id).join(', ')
+ : formatStartEndDate(
+ getDateArray(config.startDate),
+ getDateArray(config.endDate)
+ ),
items: legendItems,
}
@@ -349,7 +353,7 @@ const loadData = async (config, nameProperty) => {
eventStatus,
} = config
const orgUnits = getOrgUnitsFromRows(rows)
- const period = getPeriodFromFilters(filters)
+ const presetPeriods = getPeriodsFromFilters(filters)
const dimensions = getValidDimensionsFromFilters(config.filters)
const dataItem = getDataItemFromColumns(columns) || {}
const coordinateField = getCoordinateField(config)
@@ -371,11 +375,18 @@ const loadData = async (config, nameProperty) => {
.withDisplayProperty(nameProperty)
if (!isSingleMap) {
- analyticsRequest = analyticsRequest.addPeriodDimension(period.id)
+ analyticsRequest = analyticsRequest.addPeriodDimension(
+ presetPeriods.map((pe) => pe.id)
+ )
} else {
- analyticsRequest = period
- ? analyticsRequest.addPeriodFilter(period.id)
- : analyticsRequest.withStartDate(startDate).withEndDate(endDate)
+ analyticsRequest =
+ presetPeriods.length > 0
+ ? analyticsRequest.addPeriodFilter(
+ presetPeriods.map((pe) => pe.id)
+ )
+ : analyticsRequest
+ .withStartDate(startDate.slice(0, 10))
+ .withEndDate(endDate.slice(0, 10))
}
if (dimensions) {
diff --git a/src/reducers/layerEdit.js b/src/reducers/layerEdit.js
index 565c843b5..8d006d3ce 100644
--- a/src/reducers/layerEdit.js
+++ b/src/reducers/layerEdit.js
@@ -12,6 +12,7 @@ import {
import { START_END_DATES } from '../constants/periods.js'
import {
setFiltersFromPeriod,
+ setFiltersFromPeriods,
setDataItemInColumns,
setOrgUnitPathInRows,
removePeriodFromFilters,
@@ -106,7 +107,7 @@ const layerEdit = (state = null, action) => {
case types.LAYER_EDIT_PERIOD_TYPE_SET:
return {
...state,
- periodType: action.periodType.id,
+ periodType: action.periodType.value,
filters: action.keepPeriod
? state.filters
: removePeriodFromFilters(state.filters),
@@ -121,6 +122,17 @@ const layerEdit = (state = null, action) => {
: [],
}
+ case types.LAYER_EDIT_PERIODS_SET:
+ return {
+ ...state,
+ filters: !(
+ action.periods.length === 1 &&
+ action.periods[0].id === START_END_DATES
+ )
+ ? setFiltersFromPeriods(state.filters, action.periods)
+ : [],
+ }
+
case types.LAYER_EDIT_RENDERING_STRATEGY_SET:
return {
...state,
diff --git a/src/util/__tests__/helpers.spec.js b/src/util/__tests__/helpers.spec.js
new file mode 100644
index 000000000..be1a55e22
--- /dev/null
+++ b/src/util/__tests__/helpers.spec.js
@@ -0,0 +1,45 @@
+import { sumObjectValues } from '../helpers.js'
+
+describe('sumObjectValues', () => {
+ it('should return 0 for an empty object', () => {
+ expect(sumObjectValues({})).toBe(0)
+ })
+
+ it('should sum flat numeric values in an object', () => {
+ expect(sumObjectValues({ a: 1, b: 2, c: 3 })).toBe(6)
+ })
+
+ it('should handle nested objects correctly', () => {
+ const obj = {
+ a: 1,
+ b: { c: 2, d: { e: 3 } },
+ }
+ expect(sumObjectValues(obj)).toBe(6)
+ })
+
+ it('should ignore non-numeric values', () => {
+ const obj = {
+ a: 1,
+ b: 'string',
+ c: { d: 2, e: null, f: true },
+ }
+ expect(sumObjectValues(obj)).toBe(3)
+ })
+
+ it('should handle an object with only non-numeric values', () => {
+ const obj = {
+ a: 'string',
+ b: null,
+ c: undefined,
+ }
+ expect(sumObjectValues(obj)).toBe(0)
+ })
+
+ it('should handle arrays as object values', () => {
+ const obj = {
+ a: [1, 2, 3],
+ b: { c: [4, 5], d: 6 },
+ }
+ expect(sumObjectValues(obj)).toBe(21) // Note: Array elements aren't handled differently, treated as objects.
+ })
+})
diff --git a/src/util/__tests__/periods.spec.js b/src/util/__tests__/periods.spec.js
index 2751bdb8d..3dbec5d72 100644
--- a/src/util/__tests__/periods.spec.js
+++ b/src/util/__tests__/periods.spec.js
@@ -1,4 +1,12 @@
-import { getFixedPeriodsByType } from '../periods.js'
+import { getFixedPeriodsByType, getPeriodsDurationByType } from '../periods.js'
+
+const periods = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]
+
+const periodsDetails = {
+ 1: { type: 'TYPE_A', offset: -1, duration: 3 },
+ 2: { type: 'TYPE_B', offset: -1, duration: 2 },
+ 3: { type: 'TYPE_A', offset: 0, duration: 4 },
+}
describe('util/periods', () => {
test('getFixedPeriodsByType - RELATIVE', () => {
@@ -316,6 +324,7 @@ describe('util/periods', () => {
},
])
})
+
test('getFixedPeriodsByType - MONTHLY 2020', () => {
expect(
getFixedPeriodsByType({
@@ -437,4 +446,27 @@ describe('util/periods', () => {
},
])
})
+
+ it('getPeriodsDurationByType - should correctly calculate the duration by type with deduplication', () => {
+ const result = getPeriodsDurationByType(periods, periodsDetails)
+ expect(result).toEqual({
+ FIXED_PERIODS: {
+ any: 1,
+ first: 0,
+ last: 0,
+ },
+ TYPE_A: { first: 1, last: 3 },
+ TYPE_B: { first: 0, last: 2 },
+ })
+ })
+
+ it('getPeriodsDurationByType - should correctly calculate the duration by type without deduplication', () => {
+ const result = getPeriodsDurationByType(periods, periodsDetails, false)
+ expect(result).toEqual({
+ 1: { any: 3 },
+ 2: { any: 2 },
+ 3: { any: 4 },
+ 4: { any: 1 },
+ })
+ })
})
diff --git a/src/util/__tests__/teiRelationshipsParser.spec.js b/src/util/__tests__/teiRelationshipsParser.spec.js
index 2f80aa199..ae28b69e3 100644
--- a/src/util/__tests__/teiRelationshipsParser.spec.js
+++ b/src/util/__tests__/teiRelationshipsParser.spec.js
@@ -570,11 +570,8 @@ describe('getDataWithRelationships', () => {
expect(result).toEqual(expected)
} else {
expect(result).toHaveProperty('primary')
- console.log('primary', result.primary)
expect(result).toHaveProperty('relationships')
- console.log('relationships', result.relationships)
expect(result).toHaveProperty('secondary')
- console.log('secondary', result.secondary)
const resultPrimaryIds = result.primary.map((item) => item.id)
expect(resultPrimaryIds.sort()).toEqual(expected.primary.sort())
@@ -691,11 +688,8 @@ describe('getDataWithRelationships', () => {
expect(result).toEqual(expected)
} else {
expect(result).toHaveProperty('primary')
- //console.log('primary', result.primary)
expect(result).toHaveProperty('relationships')
- //console.log('relationships', result.relationships)
expect(result).toHaveProperty('secondary')
- //console.log('secondary', result.secondary)
const resultPrimaryIds = result.primary.map((item) => item.id)
expect(resultPrimaryIds.sort()).toEqual(expected.primary.sort())
diff --git a/src/util/analytics.js b/src/util/analytics.js
index 1dd2911d0..7c30a3741 100644
--- a/src/util/analytics.js
+++ b/src/util/analytics.js
@@ -83,6 +83,9 @@ export const setOrgUnitPathInRows = (rows = [], id, path) => {
export const getPeriodFromFilters = (filters = []) =>
getDimensionItems('pe', filters)[0]
+export const getPeriodsFromFilters = (filters = []) =>
+ getDimensionItems('pe', filters)
+
export const removePeriodFromFilters = (filters = []) => [
...filters.filter((f) => f.dimension !== 'pe'),
]
@@ -94,6 +97,11 @@ export const setFiltersFromPeriod = (filters, period) => [
createDimension('pe', [{ ...period }]),
]
+export const setFiltersFromPeriods = (filters, periods) => [
+ ...removePeriodFromFilters(filters),
+ createDimension('pe', periods),
+]
+
/* DYNAMIC DIMENSION FILTERS */
export const getDimensionsFromFilters = (filters = []) =>
diff --git a/src/util/helpers.js b/src/util/helpers.js
index cbb9e7251..fb118ddef 100644
--- a/src/util/helpers.js
+++ b/src/util/helpers.js
@@ -141,3 +141,16 @@ export const getLongestTextLength = (array, key) =>
: text,
''
).length
+
+// Sum all numbers in an object recursively
+export const sumObjectValues = (obj) =>
+ Object.values(obj).reduce((sum, value) => {
+ if (value === null || value === undefined) {
+ return sum
+ } else if (typeof value === 'object') {
+ return sum + sumObjectValues(value)
+ } else if (typeof value === 'number') {
+ return sum + value
+ }
+ return sum
+ }, 0)
diff --git a/src/util/periods.js b/src/util/periods.js
index 5c4b0e942..5237dd883 100644
--- a/src/util/periods.js
+++ b/src/util/periods.js
@@ -1,8 +1,33 @@
import {
+ DAILY,
+ WEEKLY,
+ WEEKLYWED,
+ WEEKLYTHU,
+ WEEKLYSAT,
+ WEEKLYSUN,
+ BIWEEKLY,
+ MONTHLY,
+ BIMONTHLY,
+ QUARTERLY,
+ SIXMONTHLY,
+ SIXMONTHLYAPR,
+ YEARLY,
+ FINANCIAL,
+ FYNOV,
+ FYOCT,
+ FYJUL,
+ FYAPR,
getFixedPeriodsOptionsById,
getRelativePeriodsOptionsById,
+ getRelativePeriodsDetails,
+ PERIOD_TYPE_REGEX,
} from '@dhis2/analytics'
-import { periodTypes, periodGroups } from '../constants/periods.js'
+import {
+ periodTypes,
+ periodGroups,
+ FIXED_PERIODS,
+} from '../constants/periods.js'
+import { sumObjectValues } from '../util/helpers.js'
const getYearOffsetFromNow = (year) => year - new Date(Date.now()).getFullYear()
@@ -80,3 +105,80 @@ export const getHiddenPeriods = (systemSettings) => {
)
.map((setting) => setting.match(periodSetting)[1].toUpperCase())
}
+
+// Count maximum number of periods returned by analytics api
+// Preliminary step
+export const getPeriodsDurationByType = (
+ periods,
+ periodsDetails,
+ deduplication = true
+) => {
+ if (!deduplication) {
+ return periods.reduce((acc, { id }) => {
+ acc[id] = acc[id] || {}
+ acc[id].any = periodsDetails[id]?.duration || 1
+ return acc
+ }, {})
+ } else {
+ return periods.reduce((acc, { id }) => {
+ const {
+ type = FIXED_PERIODS,
+ offset = 0,
+ duration = 1,
+ } = periodsDetails[id] || {}
+
+ acc[type] = acc[type] || { first: 0, last: 0 }
+
+ if (type === FIXED_PERIODS && !periodsDetails[id]) {
+ acc[type].any = (acc[type].any || 0) + 1
+ } else {
+ acc[type].first = Math.max(acc[type].first, 1 + offset)
+ acc[type].last = Math.max(
+ acc[type].last,
+ duration - (1 + offset)
+ )
+ }
+ return acc
+ }, {})
+ }
+}
+// Total count
+export const countPeriods = (periods, deduplication) => {
+ const periodsDetails = getRelativePeriodsDetails()
+ const periodsDurationByType = getPeriodsDurationByType(
+ periods,
+ periodsDetails,
+ deduplication
+ )
+ return sumObjectValues(periodsDurationByType)
+}
+
+export const getPeriodTypeFromId = (periodId) => {
+ return Object.keys(PERIOD_TYPE_REGEX).find((type) =>
+ periodId.match(PERIOD_TYPE_REGEX[type])
+ )
+}
+
+export const getPeriodLevelFromPeriodType = (periodType) => {
+ const periodTypesByLevel = [
+ DAILY,
+ WEEKLY,
+ WEEKLYWED,
+ WEEKLYTHU,
+ WEEKLYSAT,
+ WEEKLYSUN,
+ BIWEEKLY,
+ MONTHLY,
+ BIMONTHLY,
+ QUARTERLY,
+ SIXMONTHLY,
+ SIXMONTHLYAPR,
+ YEARLY,
+ FINANCIAL,
+ FYNOV,
+ FYOCT,
+ FYJUL,
+ FYAPR,
+ ]
+ return periodTypesByLevel.indexOf(periodType)
+}
diff --git a/src/util/time.js b/src/util/time.js
index e4d305b55..02ed1900c 100644
--- a/src/util/time.js
+++ b/src/util/time.js
@@ -27,7 +27,7 @@ const shortDateRegexp = /^\d{4}-\d{2}-\d{2}$/
* @returns {String}
*/
const isValidDateFormat = (dateString) =>
- shortDateRegexp.test(dateString.substr(0, 10))
+ dateString && shortDateRegexp.test(dateString.substr(0, 10))
/**
* Formats a date string, timestamp or date array into format used by DHIS2 and date
diff --git a/yarn.lock b/yarn.lock
index ee5aa6897..6c488bd10 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1404,10 +1404,10 @@
through2 "^2.0.0"
watchify "^4.0.0"
-"@cypress/request@^2.88.10":
- version "2.88.11"
- resolved "https://registry.yarnpkg.com/@cypress/request/-/request-2.88.11.tgz#5a4c7399bc2d7e7ed56e92ce5acb620c8b187047"
- integrity sha512-M83/wfQ1EkspjkE2lNWNV5ui2Cv7UCv1swW1DqljahbzLVWltcsexQh8jYtuS/vzFXP+HySntGM83ZXA9fn17w==
+"@cypress/request@^3.0.6":
+ version "3.0.7"
+ resolved "https://registry.yarnpkg.com/@cypress/request/-/request-3.0.7.tgz#6a74a4da98d9e5ae9121d6e2d9c14780c9b5cf1a"
+ integrity sha512-LzxlLEMbBOPYB85uXrDqvD4MgcenjRBLIns3zyhx7vTPj/0u2eQhzXvPiGcaJrV38Q9dbkExWp6cOHPJ+EtFYg==
dependencies:
aws-sign2 "~0.7.0"
aws4 "^1.8.0"
@@ -1415,16 +1415,16 @@
combined-stream "~1.0.6"
extend "~3.0.2"
forever-agent "~0.6.1"
- form-data "~2.3.2"
- http-signature "~1.3.6"
+ form-data "~4.0.0"
+ http-signature "~1.4.0"
is-typedarray "~1.0.0"
isstream "~0.1.2"
json-stringify-safe "~5.0.1"
mime-types "~2.1.19"
performance-now "^2.1.0"
- qs "~6.10.3"
+ qs "6.13.1"
safe-buffer "^5.1.2"
- tough-cookie "~2.5.0"
+ tough-cookie "^5.0.0"
tunnel-agent "^0.6.0"
uuid "^8.3.2"
@@ -2018,10 +2018,9 @@
classnames "^2.3.1"
prop-types "^15.7.2"
-"@dhis2/analytics@^26.8.4":
- version "26.8.4"
- resolved "https://registry.yarnpkg.com/@dhis2/analytics/-/analytics-26.8.4.tgz#e26bb345b729f3e3b27af03d763c9a8c3de4f8ee"
- integrity sha512-kQsLcLt0b9FWnzrKNF4m44CSCok7eZft6iGXvl5Kq2Oo0B/k/P3kTosSL/Hx2Oub40CbT+V6Z2bQVrgRqJ49vg==
+"@dhis2/analytics@git+https://github.com/d2-ci/analytics.git#8c539c2cee08357ef6c7daaab2521e3df6ef7801":
+ version "26.9.3"
+ resolved "git+https://github.com/d2-ci/analytics.git#8c539c2cee08357ef6c7daaab2521e3df6ef7801"
dependencies:
"@dhis2/multi-calendar-dates" "^1.2.2"
"@dnd-kit/core" "^6.0.7"
@@ -2029,7 +2028,7 @@
"@dnd-kit/utilities" "^3.2.1"
"@react-hook/debounce" "^4.0.0"
classnames "^2.3.1"
- crypto-js "^4.1.1"
+ crypto-js "^4.2.0"
d2-utilizr "^0.2.16"
d3-color "^1.2.3"
highcharts "^10.3.3"
@@ -3970,11 +3969,6 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.14.2.tgz#c076ed1d7b6095078ad3cf21dfeea951842778b1"
integrity sha512-1uEQxww3DaghA0RxqHx0O0ppVlo43pJhepY51OxuQIKHpjbnYLA7vcdwioNPzIqmC2u3I/dmylcqjlh0e7AyUA==
-"@types/node@^14.14.31":
- version "14.18.36"
- resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.36.tgz#c414052cb9d43fab67d679d5f3c641be911f5835"
- integrity sha512-FXKWbsJ6a1hIrRxv+FoukuHnGTgEzKYGi7kilfMae96AL9UNkPFNWJEEYWzdRI9ooIkbr4AKldyuSTLql06vLQ==
-
"@types/normalize-package-data@^2.4.0", "@types/normalize-package-data@^2.4.1":
version "2.4.4"
resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz#56e2cc26c397c038fab0e3a917a12d5c5909e901"
@@ -5662,7 +5656,7 @@ buffer-xor@^1.0.3:
resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9"
integrity sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==
-buffer@^5.1.0, buffer@^5.5.0, buffer@^5.6.0:
+buffer@^5.1.0, buffer@^5.5.0, buffer@^5.7.1:
version "5.7.1"
resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0"
integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==
@@ -5973,6 +5967,11 @@ ci-info@^3.2.0:
resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.8.0.tgz#81408265a5380c929f0bc665d62256628ce9ef91"
integrity sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw==
+ci-info@^4.0.0:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-4.1.0.tgz#92319d2fa29d2620180ea5afed31f589bc98cf83"
+ integrity sha512-HutrvTNsF48wnxkzERIXOe5/mlcfFcbfCmwcg6CJnizbSue78AbDt+1cgl26zwn61WFxhcPykPfZrbqjGmBb4A==
+
cidr-regex@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/cidr-regex/-/cidr-regex-3.1.1.tgz#ba1972c57c66f61875f18fd7dd487469770b571d"
@@ -6691,7 +6690,7 @@ crypto-browserify@^3.0.0:
randombytes "^2.0.0"
randomfill "^1.0.3"
-crypto-js@^4.1.1:
+crypto-js@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631"
integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==
@@ -6933,23 +6932,23 @@ cypress-wait-until@^1.7.2:
resolved "https://registry.yarnpkg.com/cypress-wait-until/-/cypress-wait-until-1.7.2.tgz#7f534dd5a11c89b65359e7a0210f20d3dfc22107"
integrity sha512-uZ+M8/MqRcpf+FII/UZrU7g1qYZ4aVlHcgyVopnladyoBrpoaMJ4PKZDrdOJ05H5RHbr7s9Tid635X3E+ZLU/Q==
-cypress@^12.16.0:
- version "12.16.0"
- resolved "https://registry.yarnpkg.com/cypress/-/cypress-12.16.0.tgz#d0dcd0725a96497f4c60cf54742242259847924c"
- integrity sha512-mwv1YNe48hm0LVaPgofEhGCtLwNIQEjmj2dJXnAkY1b4n/NE9OtgPph4TyS+tOtYp5CKtRmDvBzWseUXQTjbTg==
+cypress@^13.17.0:
+ version "13.17.0"
+ resolved "https://registry.yarnpkg.com/cypress/-/cypress-13.17.0.tgz#34c3d68080c4497eace0f353bd1629587a5f600d"
+ integrity sha512-5xWkaPurwkIljojFidhw8lFScyxhtiFHl/i/3zov+1Z5CmY4t9tjIdvSXfu82Y3w7wt0uR9KkucbhkVvJZLQSA==
dependencies:
- "@cypress/request" "^2.88.10"
+ "@cypress/request" "^3.0.6"
"@cypress/xvfb" "^1.2.4"
- "@types/node" "^14.14.31"
"@types/sinonjs__fake-timers" "8.1.1"
"@types/sizzle" "^2.3.2"
arch "^2.2.0"
blob-util "^2.0.2"
bluebird "^3.7.2"
- buffer "^5.6.0"
+ buffer "^5.7.1"
cachedir "^2.3.0"
chalk "^4.1.0"
check-more-types "^2.24.0"
+ ci-info "^4.0.0"
cli-cursor "^3.1.0"
cli-table3 "~0.6.1"
commander "^6.2.1"
@@ -6964,7 +6963,6 @@ cypress@^12.16.0:
figures "^3.2.0"
fs-extra "^9.1.0"
getos "^3.2.1"
- is-ci "^3.0.0"
is-installed-globally "~0.4.0"
lazy-ass "^1.6.0"
listr2 "^3.8.3"
@@ -6973,11 +6971,13 @@ cypress@^12.16.0:
minimist "^1.2.8"
ospath "^1.2.2"
pretty-bytes "^5.6.0"
+ process "^0.11.10"
proxy-from-env "1.0.0"
request-progress "^3.0.0"
- semver "^7.3.2"
+ semver "^7.5.3"
supports-color "^8.1.1"
- tmp "~0.2.1"
+ tmp "~0.2.3"
+ tree-kill "1.2.2"
untildify "^4.0.0"
yauzl "^2.10.0"
@@ -8979,6 +8979,15 @@ form-data@~2.3.2:
combined-stream "^1.0.6"
mime-types "^2.1.12"
+form-data@~4.0.0:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.1.tgz#ba1076daaaa5bfd7e99c1a6cb02aa0a5cff90d48"
+ integrity sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==
+ dependencies:
+ asynckit "^0.4.0"
+ combined-stream "^1.0.8"
+ mime-types "^2.1.12"
+
forwarded@0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811"
@@ -9874,14 +9883,14 @@ http-signature@~1.2.0:
jsprim "^1.2.2"
sshpk "^1.7.0"
-http-signature@~1.3.6:
- version "1.3.6"
- resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.3.6.tgz#cb6fbfdf86d1c974f343be94e87f7fc128662cf9"
- integrity sha512-3adrsD6zqo4GsTqtO7FyrejHNv+NgiIfAfv68+jVlFmSr9OGy7zrxONceFRLKvnnZA5jbxQBX1u9PpB6Wi32Gw==
+http-signature@~1.4.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.4.0.tgz#dee5a9ba2bf49416abc544abd6d967f6a94c8c3f"
+ integrity sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==
dependencies:
assert-plus "^1.0.0"
jsprim "^2.0.2"
- sshpk "^1.14.1"
+ sshpk "^1.18.0"
https-browserify@^1.0.0:
version "1.0.0"
@@ -10323,13 +10332,6 @@ is-ci@^2.0.0:
dependencies:
ci-info "^2.0.0"
-is-ci@^3.0.0:
- version "3.0.1"
- resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-3.0.1.tgz#db6ecbed1bd659c43dac0f45661e7674103d1867"
- integrity sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==
- dependencies:
- ci-info "^3.2.0"
-
is-cidr@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/is-cidr/-/is-cidr-4.0.2.tgz#94c7585e4c6c77ceabf920f8cde51b8c0fda8814"
@@ -14525,7 +14527,7 @@ process-nextick-args@^2.0.0, process-nextick-args@~2.0.0:
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
-process@~0.11.0:
+process@^0.11.10, process@~0.11.0:
version "0.11.10"
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==
@@ -14712,12 +14714,12 @@ qs@6.13.0:
dependencies:
side-channel "^1.0.6"
-qs@~6.10.3:
- version "6.10.5"
- resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.5.tgz#974715920a80ff6a262264acd2c7e6c2a53282b4"
- integrity sha512-O5RlPh0VFtR78y79rgcgKK4wbAI0C5zGVLztOIdpWX6ep368q5Hv6XRxDvXuZ9q3C6v+e3n8UfZZJw7IIG27eQ==
+qs@6.13.1:
+ version "6.13.1"
+ resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.1.tgz#3ce5fc72bd3a8171b85c99b93c65dd20b7d1b16e"
+ integrity sha512-EJPeIn0CYrGu+hli1xilKAPXODtJ12T0sP63Ijx2/khC2JtuaN3JyNIpvmnkmaEtha9ocbG4A4cMcr+TvqvwQg==
dependencies:
- side-channel "^1.0.4"
+ side-channel "^1.0.6"
qs@~6.5.2:
version "6.5.3"
@@ -15906,6 +15908,11 @@ semver@^7.0.0, semver@^7.1.1, semver@^7.1.2, semver@^7.2.1, semver@^7.3.2, semve
resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.2.tgz#1e3b34759f896e8f14d6134732ce798aeb0c6e13"
integrity sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==
+semver@^7.5.3:
+ version "7.6.3"
+ resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143"
+ integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==
+
send@0.19.0:
version "0.19.0"
resolved "https://registry.yarnpkg.com/send/-/send-0.19.0.tgz#bbc5a388c8ea6c048967049dbeac0e4a3f09d7f8"
@@ -16388,7 +16395,22 @@ sprintf-js@~1.0.2:
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==
-sshpk@^1.14.1, sshpk@^1.7.0:
+sshpk@^1.18.0:
+ version "1.18.0"
+ resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.18.0.tgz#1663e55cddf4d688b86a46b77f0d5fe363aba028"
+ integrity sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==
+ dependencies:
+ asn1 "~0.2.3"
+ assert-plus "^1.0.0"
+ bcrypt-pbkdf "^1.0.0"
+ dashdash "^1.12.0"
+ ecc-jsbn "~0.1.1"
+ getpass "^0.1.1"
+ jsbn "~0.1.0"
+ safer-buffer "^2.0.2"
+ tweetnacl "~0.14.0"
+
+sshpk@^1.7.0:
version "1.17.0"
resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.17.0.tgz#578082d92d4fe612b13007496e543fa0fbcbe4c5"
integrity sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ==
@@ -17110,6 +17132,18 @@ tinyqueue@^2.0.3:
resolved "https://registry.yarnpkg.com/tinyqueue/-/tinyqueue-2.0.3.tgz#64d8492ebf39e7801d7bd34062e29b45b2035f08"
integrity sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==
+tldts-core@^6.1.69:
+ version "6.1.69"
+ resolved "https://registry.yarnpkg.com/tldts-core/-/tldts-core-6.1.69.tgz#079ffcac8a4407bc74567e292aecf30b943674e1"
+ integrity sha512-nygxy9n2PBUFQUtAXAc122gGo+04/j5qr5TGQFZTHafTKYvmARVXt2cA5rgero2/dnXUfkdPtiJoKmrd3T+wdA==
+
+tldts@^6.1.32:
+ version "6.1.69"
+ resolved "https://registry.yarnpkg.com/tldts/-/tldts-6.1.69.tgz#0fe1fcb1ad09510459693e72f96062cee2411f1f"
+ integrity sha512-Oh/CqRQ1NXNY7cy9NkTPUauOWiTro0jEYZTioGbOmcQh6EC45oribyIMJp0OJO3677r13tO6SKdWoGZUx2BDFw==
+ dependencies:
+ tldts-core "^6.1.69"
+
tmp@^0.0.33:
version "0.0.33"
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
@@ -17117,12 +17151,10 @@ tmp@^0.0.33:
dependencies:
os-tmpdir "~1.0.2"
-tmp@~0.2.1:
- version "0.2.1"
- resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14"
- integrity sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==
- dependencies:
- rimraf "^3.0.0"
+tmp@~0.2.3:
+ version "0.2.3"
+ resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.3.tgz#eb783cc22bc1e8bebd0671476d46ea4eb32a79ae"
+ integrity sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==
tmpl@1.0.5:
version "1.0.5"
@@ -17201,6 +17233,13 @@ tough-cookie@^4.0.0:
universalify "^0.2.0"
url-parse "^1.5.3"
+tough-cookie@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-5.0.0.tgz#6b6518e2b5c070cf742d872ee0f4f92d69eac1af"
+ integrity sha512-FRKsF7cz96xIIeMZ82ehjC3xW2E+O2+v11udrDYewUbszngYhsGa8z6YUMMzO9QJZzzyd0nGGXnML/TReX6W8Q==
+ dependencies:
+ tldts "^6.1.32"
+
tough-cookie@~2.5.0:
version "2.5.0"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2"
@@ -17233,6 +17272,11 @@ traverse@0.6.8:
resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.6.8.tgz#5e5e0c41878b57e4b73ad2f3d1e36a715ea4ab15"
integrity sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA==
+tree-kill@1.2.2:
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc"
+ integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==
+
treeverse@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/treeverse/-/treeverse-2.0.0.tgz#036dcef04bc3fd79a9b79a68d4da03e882d8a9ca"