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 ( - - - - {mode === 'play' ? pauseBtn : playBtn} - - - {this.getPeriodRects()} - - (this.node = node)} - /> - - ) - } - - // 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 ( + + {/* Play/Pause Button */} + + + {mode === 'play' ? PAUSE_ICON : PLAY_ICON} + + {/* Period Rectangles */} + + {this.getPeriodRects()} + + {/* X-Axis */} + (this.node = node)} + /> + + ) + } + + // 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"