From b25267d33a8ab5a136f34516ba1bc802ae9e51b3 Mon Sep 17 00:00:00 2001 From: Jen Jones Arnesen Date: Fri, 12 Jan 2024 08:30:10 +0100 Subject: [PATCH] feat: add support for hash routing in line with other analytics apps [DHIS2-15762] (#3009) Implements https://dhis2.atlassian.net/browse/DHIS2-15762 Continue to support the old url patterns for backwards compatibility Supports: /#/ZBjCfSaLSqD /#/ZBjCfSaLSqD/download /#/ZBjCfSaLSqD?interpretationId=yKqhXZdeJ6a /#/ZBjCfSaLSqD?interpretationId=yKqhXZdeJ6a&initialFocus=true /#/currentAnalyticalObject /#/download (new map, in download mode) // legacy urls /?id=ZBjCfSaLSqD /?id=ZBjCfSaLSqD&interpretationid=yKqhXZdeJ6a /?id=ZBjCfSaLSqD&interpretationId=yKqhXZdeJ6a /?currentAnalyticalObject=true The app supports legacy urls (see above) because dashboard-app still produces the legacy url for both opening the map, and opening an interpretation. In addition, urls to maps may have been shared in other ways. In maps-app, legacy urls are replaced with the new url style when the maps-app first opens (AppWrapper) FileMenu: push paths to history for the various actions. Also, some renaming was done in the file to (hopefully) increase clarity. useLoadMap: this is where all the map loading and history listening is set up. Refactoring. Previously there were 3 components that dealt with initiating the maps app: AppWrapper, App, AppLayout. App has become the former "AppLayout", and all of the loading and navigating logic has been moved to the new custom hook useLoadMap --- cypress/integration/interpretations.cy.js | 12 +- cypress/integration/mapDownload.cy.js | 100 ++++++++++ cypress/integration/routes.cy.js | 175 ++++++++++++++++++ cypress/integration/smoke.cy.js | 83 --------- i18n/en.pot | 4 +- package.json | 2 + src/AppWrapper.js | 42 ++++- src/actions/analyticalObject.js | 21 --- src/actions/map.js | 36 ---- src/components/app/App.js | 96 +++++----- src/components/app/AppLayout.js | 66 ------- src/components/app/FileMenu.js | 134 ++++++-------- src/components/app/ModalContainer.js | 21 +++ src/components/app/styles/App.module.css | 16 ++ .../app/styles/AppLayout.module.css | 15 -- src/components/app/useLoadMap.js | 113 +++++++++++ src/components/download/DownloadButton.js | 10 +- src/components/download/DownloadLegend.js | 2 +- src/components/download/DownloadMapInfo.js | 5 +- src/components/download/DownloadMenubar.js | 7 +- src/components/download/DownloadSettings.js | 16 +- src/components/download/OverviewMap.js | 6 +- .../interpretations/InterpretationsPanel.js | 69 +++---- src/components/openAs/OpenAsMapDialog.js | 173 +++++++---------- src/util/__tests__/analyticalObject.spec.js | 13 -- src/util/__tests__/history.spec.js | 80 ++++++++ src/util/analyticalObject.js | 23 +-- src/util/history.js | 59 ++++++ src/util/requests.js | 10 - yarn.lock | 43 +++++ 30 files changed, 886 insertions(+), 566 deletions(-) create mode 100644 cypress/integration/mapDownload.cy.js create mode 100644 cypress/integration/routes.cy.js delete mode 100644 cypress/integration/smoke.cy.js delete mode 100644 src/components/app/AppLayout.js create mode 100644 src/components/app/ModalContainer.js delete mode 100644 src/components/app/styles/AppLayout.module.css create mode 100644 src/components/app/useLoadMap.js create mode 100644 src/util/__tests__/history.spec.js create mode 100644 src/util/history.js diff --git a/cypress/integration/interpretations.cy.js b/cypress/integration/interpretations.cy.js index eb6d3352a..0e7e37d20 100644 --- a/cypress/integration/interpretations.cy.js +++ b/cypress/integration/interpretations.cy.js @@ -5,7 +5,7 @@ import { EXTENDED_TIMEOUT } from '../support/util.js' const MAP_TITLE = 'test ' + new Date().toUTCString().slice(-24, -4) context('Interpretations', () => { it('opens the interpretations panel for a map', () => { - cy.visit('/?id=ZBjCfSaLSqD', EXTENDED_TIMEOUT) + cy.visit('/#/ZBjCfSaLSqD', EXTENDED_TIMEOUT) const Layer = new ThematicLayer() Layer.validateCardTitle('ANC LLITN coverage') cy.get('canvas.maplibregl-canvas').should('be.visible') @@ -65,8 +65,12 @@ context('Interpretations', () => { .find('canvas.maplibregl-canvas') .should('be.visible') + cy.url().should('include', 'interpretationId=') + cy.get('button').contains('Hide interpretation').click() + cy.url().should('not.include', 'interpretationId=') + deleteMap() }) @@ -75,7 +79,7 @@ context('Interpretations', () => { 'postDataStatistics' ) cy.visit( - '/?id=ZBjCfSaLSqD&interpretationId=yKqhXZdeJ6a', + '/#/ZBjCfSaLSqD?interpretationId=yKqhXZdeJ6a', EXTENDED_TIMEOUT ) //ANC: LLITN coverage district and facility @@ -90,12 +94,16 @@ context('Interpretations', () => { ) .should('be.visible') + cy.url().should('include', 'interpretationId=') + cy.getByDataTest('interpretation-modal') .findByDataTest('dhis2-modal-close-button') .click() cy.getByDataTest('interpretation-modal').should('not.exist') + cy.url().should('not.include', 'interpretationId=') + cy.getByDataTest('interpretations-list').should('be.visible') }) }) diff --git a/cypress/integration/mapDownload.cy.js b/cypress/integration/mapDownload.cy.js new file mode 100644 index 000000000..ad7367475 --- /dev/null +++ b/cypress/integration/mapDownload.cy.js @@ -0,0 +1,100 @@ +import { EXTENDED_TIMEOUT } from '../support/util.js' + +const mapWithThematicLayer = { + id: 'eDlFx0jTtV9', + name: 'ANC: LLITN Cov Chiefdom this year', + downloadFileName: 'ANC LLITN Cov Chiefdom this year.png', + cardTitle: 'ANC LLITN coverage', +} + +const assertDownloadSettingChecked = (label, isChecked) => { + cy.getByDataTest('download-settings') + .find('label') + .contains(label) + .find('input') + .should(`${isChecked ? '' : 'not.'}be.checked`) +} + +const clickDownloadSetting = (label) => { + cy.getByDataTest('download-settings') + .find('label') + .contains(label) + .find('input') + .click() +} + +describe('Map Download', () => { + beforeEach(() => { + cy.task('emptyDownloadsFolder') + }) + + it('downloads a map', () => { + cy.visit(`/#/${mapWithThematicLayer.id}`, EXTENDED_TIMEOUT) + cy.get('canvas', EXTENDED_TIMEOUT).should('be.visible') + + cy.get('[data-test="layercard"]') + .find('h2') + .contains(mapWithThematicLayer.cardTitle, EXTENDED_TIMEOUT) + + cy.getByDataTest('dhis2-analytics-hovermenubar') + .find('button') + .contains('Download') + .click() + + cy.log('confirm that download page is open') + cy.getByDataTest('download-settings').should('be.visible') + cy.get('canvas.maplibregl-canvas').should('be.visible') + cy.get('button').contains('Exit download mode').should('be.visible') + cy.url().should('contain', `/#/${mapWithThematicLayer.id}/download`) + + // check the current settings + assertDownloadSettingChecked('Show map name', true) + + cy.getByDataTest('download-map-info') + .find('h1') + .contains(mapWithThematicLayer.name) + .should('be.visible') + + assertDownloadSettingChecked('Show map description', false) + assertDownloadSettingChecked('Show legend', true) + cy.getByDataTest('download-map-info') + .findByDataTest('download-legend-title') + .should('have.length', 1) + + assertDownloadSettingChecked('Show overview map', true) + cy.getByDataTest('download-map-info') + .findByDataTest('overview-map') + .should('be.visible') + + // make some changes + clickDownloadSetting('Show map name') + cy.getByDataTest('download-map-info').find('h1').should('not.exist') + + cy.getByDataTest('download-settings') + .find('button') + .contains('Download') + .click() + + // check for downloaded file + cy.wait(3000) // eslint-disable-line cypress/no-unnecessary-waiting + cy.waitUntil( + () => cy.task('getLastDownloadFilePath').then((result) => result), + { timeout: 3000, interval: 100 } + ).then((filePath) => { + expect(filePath).to.include(mapWithThematicLayer.downloadFileName) + + cy.readFile(filePath, EXTENDED_TIMEOUT).should((buffer) => + expect(buffer.length).to.be.gt(10000) + ) + }) + + // leave download mode + cy.get('button').contains('Exit download mode').click() + cy.url().should('contain', `/#/${mapWithThematicLayer.id}`) + cy.url().should('not.contain', '/download') + cy.getByDataTest('download-settings').should('not.exist') + cy.get('[data-test="layercard"]') + .find('h2') + .contains(mapWithThematicLayer.cardTitle, EXTENDED_TIMEOUT) + }) +}) diff --git a/cypress/integration/routes.cy.js b/cypress/integration/routes.cy.js new file mode 100644 index 000000000..62062a291 --- /dev/null +++ b/cypress/integration/routes.cy.js @@ -0,0 +1,175 @@ +import { ThematicLayer } from '../elements/thematic_layer.js' +import { EXTENDED_TIMEOUT } from '../support/util.js' + +context('Routes', () => { + it('loads root route', () => { + cy.visit('/', { timeout: 50000 }) + cy.get('canvas', EXTENDED_TIMEOUT).should('be.visible') + cy.title().should('equal', 'Maps | DHIS2') + }) + + it('loads with map id (legacy)', () => { + cy.intercept({ method: 'POST', url: /dataStatistics/ }).as( + 'postDataStatistics' + ) + cy.visit('/?id=ytkZY3ChM6J', EXTENDED_TIMEOUT) //ANC: 3rd visit coverage last year by district + + cy.wait('@postDataStatistics') + .its('response.statusCode') + .should('eq', 201) + + cy.get('canvas', EXTENDED_TIMEOUT).should('be.visible') + + const Layer = new ThematicLayer() + Layer.validateCardTitle('ANC 3 Coverage') + }) + + it('loads with map id (hash)', () => { + cy.visit('/#/zDP78aJU8nX', EXTENDED_TIMEOUT) //ANC: 1st visit coverage (%) by district last year + + cy.get('canvas', EXTENDED_TIMEOUT).should('be.visible') + + const Layer = new ThematicLayer() + Layer.validateCardTitle('ANC 1 Coverage') + }) + + it('loads currentAnalyticalObject (legacy)', () => { + cy.intercept('**/userDataStore/analytics/settings', { + fixture: 'analyticalObject.json', + }) + + cy.visit('/?currentAnalyticalObject=true', EXTENDED_TIMEOUT) + cy.get('canvas', EXTENDED_TIMEOUT).should('be.visible') + + cy.contains('button', 'Proceed').click() + + const Layer = new ThematicLayer() + Layer.validateCardTitle('ANC 1 Coverage') + cy.get('canvas.maplibregl-canvas').should('be.visible') + }) + + it('loads currentAnalyticalObject (hash)', () => { + cy.intercept('**/userDataStore/analytics/settings', { + fixture: 'analyticalObject.json', + }) + + cy.visit('/#/currentAnalyticalObject', EXTENDED_TIMEOUT) + cy.get('canvas', EXTENDED_TIMEOUT).should('be.visible') + + cy.contains('button', 'Proceed').click() + + const Layer = new ThematicLayer() + Layer.validateCardTitle('ANC 1 Coverage') + cy.get('canvas.maplibregl-canvas').should('be.visible') + }) + + it('loads with map id (legacy) and interpretationid lowercase', () => { + cy.intercept({ method: 'POST', url: /dataStatistics/ }).as( + 'postDataStatistics' + ) + cy.visit( + '/?id=ZBjCfSaLSqD&interpretationid=yKqhXZdeJ6a', + EXTENDED_TIMEOUT + ) //ANC: LLITN coverage district and facility + + cy.wait('@postDataStatistics') + .its('response.statusCode') + .should('eq', 201) + + cy.getByDataTest('interpretation-modal') + .find('h1') + .contains( + 'Viewing interpretation: ANC: LLITN coverage district and facility' + ) + .should('be.visible') + }) + + it('loads with map id (legacy) and interpretationId uppercase', () => { + cy.intercept({ method: 'POST', url: /dataStatistics/ }).as( + 'postDataStatistics' + ) + cy.visit( + '/?id=ZBjCfSaLSqD&interpretationId=yKqhXZdeJ6a', + EXTENDED_TIMEOUT + ) //ANC: LLITN coverage district and facility + + cy.wait('@postDataStatistics') + .its('response.statusCode') + .should('eq', 201) + + cy.getByDataTest('interpretation-modal') + .find('h1') + .contains( + 'Viewing interpretation: ANC: LLITN coverage district and facility' + ) + .should('be.visible') + }) + + it('loads with map id (hash) and interpretationId', () => { + cy.intercept({ method: 'POST', url: /dataStatistics/ }).as( + 'postDataStatistics' + ) + cy.visit( + '/#/ZBjCfSaLSqD?interpretationId=yKqhXZdeJ6a', + EXTENDED_TIMEOUT + ) //ANC: LLITN coverage district and facility + + cy.wait('@postDataStatistics') + .its('response.statusCode') + .should('eq', 201) + + cy.getByDataTest('interpretation-modal') + .find('h1') + .contains( + 'Viewing interpretation: ANC: LLITN coverage district and facility' + ) + .should('be.visible') + }) + + it('loads download page for map id (hash)', () => { + cy.intercept({ method: 'POST', url: /dataStatistics/ }).as( + 'postDataStatistics' + ) + cy.visit('/#/ZBjCfSaLSqD/download', EXTENDED_TIMEOUT) //ANC: LLITN coverage district and facility + + cy.wait('@postDataStatistics') + .its('response.statusCode') + .should('eq', 201) + + cy.getByDataTest('download-settings').should('be.visible') + cy.get('canvas.maplibregl-canvas').should('be.visible') + cy.get('button').contains('Exit download mode').should('be.visible') + }) + + it('loads download page currentAnalyticalObject (hash)', () => { + cy.intercept('**/userDataStore/analytics/settings', { + fixture: 'analyticalObject.json', + }) + + cy.visit('/#/currentAnalyticalObject/download', EXTENDED_TIMEOUT) + + cy.contains('button', 'Proceed').click() + + cy.getByDataTest('download-settings').should('be.visible') + cy.get('canvas.maplibregl-canvas').should('be.visible') + cy.get('button').contains('Exit download mode').should('be.visible') + }) + + it.only('loads download page for new map', () => { + cy.visit('/', EXTENDED_TIMEOUT) + + cy.get('canvas.maplibregl-canvas').should('be.visible') + cy.get('button').contains('Download').click() + + cy.getByDataTest('download-settings').should('be.visible') + cy.get('canvas.maplibregl-canvas').should('be.visible') + cy.get('button').contains('Exit download mode').should('be.visible') + cy.url().should('include', '#/download') + + cy.get('button').contains('Exit download mode').click() + + cy.url().should('not.include', 'download') + + cy.get('button').contains('Add layer').should('be.visible') + }) +}) diff --git a/cypress/integration/smoke.cy.js b/cypress/integration/smoke.cy.js deleted file mode 100644 index be0d0f82d..000000000 --- a/cypress/integration/smoke.cy.js +++ /dev/null @@ -1,83 +0,0 @@ -import { ThematicLayer } from '../elements/thematic_layer.js' -import { EXTENDED_TIMEOUT } from '../support/util.js' - -context('Smoke Test', () => { - it('loads', () => { - cy.visit('/', EXTENDED_TIMEOUT) - cy.get('canvas', EXTENDED_TIMEOUT).should('be.visible') - cy.title().should('equal', 'Maps | DHIS2') - }) - - it('loads with map id', () => { - cy.intercept({ method: 'POST', url: /dataStatistics/ }).as( - 'postDataStatistics' - ) - cy.visit('/?id=ytkZY3ChM6J', EXTENDED_TIMEOUT) //ANC: 3rd visit coverage last year by district - - cy.wait('@postDataStatistics') - .its('response.statusCode') - .should('eq', 201) - - cy.get('canvas', EXTENDED_TIMEOUT).should('be.visible') - - const Layer = new ThematicLayer() - Layer.validateCardTitle('ANC 3 Coverage') - }) - - it('loads currentAnalyticalObject', () => { - cy.intercept('**/userDataStore/analytics/settings', { - fixture: 'analyticalObject.json', - }) - - cy.visit('/?currentAnalyticalObject=true', EXTENDED_TIMEOUT) - cy.get('canvas', EXTENDED_TIMEOUT).should('be.visible') - - cy.contains('button', 'Proceed').click() - - const Layer = new ThematicLayer() - Layer.validateCardTitle('ANC 1 Coverage') - cy.get('canvas.maplibregl-canvas').should('be.visible') - }) - - it('loads with map id and interpretationid lowercase', () => { - cy.intercept({ method: 'POST', url: /dataStatistics/ }).as( - 'postDataStatistics' - ) - cy.visit( - '/?id=ZBjCfSaLSqD&interpretationid=yKqhXZdeJ6a', - EXTENDED_TIMEOUT - ) //ANC: LLITN coverage district and facility - - cy.wait('@postDataStatistics') - .its('response.statusCode') - .should('eq', 201) - - cy.getByDataTest('interpretation-modal') - .find('h1') - .contains( - 'Viewing interpretation: ANC: LLITN coverage district and facility' - ) - .should('be.visible') - }) - - it('loads with map id and interpretationId uppercase', () => { - cy.intercept({ method: 'POST', url: /dataStatistics/ }).as( - 'postDataStatistics' - ) - cy.visit( - '/?id=ZBjCfSaLSqD&interpretationId=yKqhXZdeJ6a', - EXTENDED_TIMEOUT - ) //ANC: LLITN coverage district and facility - - cy.wait('@postDataStatistics') - .its('response.statusCode') - .should('eq', 201) - - cy.getByDataTest('interpretation-modal') - .find('h1') - .contains( - 'Viewing interpretation: ANC: LLITN coverage district and facility' - ) - .should('be.visible') - }) -}) diff --git a/i18n/en.pot b/i18n/en.pot index 60ff064e8..4ae87b4de 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: 2023-10-23T12:55:26.288Z\n" -"PO-Revision-Date: 2023-10-23T12:55:26.288Z\n" +"POT-Creation-Date: 2024-01-11T09:20:59.829Z\n" +"PO-Revision-Date: 2024-01-11T09:20:59.829Z\n" msgid "Untitled map, {{date}}" msgstr "Untitled map, {{date}}" diff --git a/package.json b/package.json index 4bb29aab3..e2516fe7d 100644 --- a/package.json +++ b/package.json @@ -64,10 +64,12 @@ "d3-time": "^3.1.0", "d3-time-format": "^4.1.0", "file-saver": "^2.0.5", + "history": "^5.3.0", "html-to-image": "^1.11.1", "lodash": "^4.17.21", "loglevel": "^1.8.1", "prop-types": "^15.8.1", + "query-string": "^8.1.0", "react": "^16.14.0", "react-dom": "^16.14.0", "react-redux": "^8.1.2", diff --git a/src/AppWrapper.js b/src/AppWrapper.js index 88f05c397..282a0fa8b 100644 --- a/src/AppWrapper.js +++ b/src/AppWrapper.js @@ -1,8 +1,9 @@ import { CachedDataQueryProvider } from '@dhis2/analytics' import { D2Shim } from '@dhis2/app-runtime-adapter-d2' import { DataStoreProvider } from '@dhis2/app-service-datastore' -import { CenteredContent, CircularLoader } from '@dhis2/ui' +import { CssVariables, CenteredContent, CircularLoader } from '@dhis2/ui' import log from 'loglevel' +import queryString from 'query-string' import React from 'react' import { Provider as ReduxProvider } from 'react-redux' import App from './components/app/App.js' @@ -12,6 +13,7 @@ import store from './store/index.js' import { USER_DATASTORE_NAMESPACE } from './util/analyticalObject.js' import { appQueries, providerDataTransformation } from './util/app.js' import './locales/index.js' +import history from './util/history.js' log.setLevel( process.env.NODE_ENV === 'production' ? log.levels.INFO : log.levels.TRACE @@ -38,7 +40,44 @@ const d2Config = { ], } +const replaceLegacyUrl = () => { + // support legacy urls + const queryParams = queryString.parse(window.location.search, { + parseBooleans: true, + }) + const [base] = window.location.href.split('?') + + if (queryParams.id) { + // /?id=ytkZY3ChM6J + // /?id=ZBjCfSaLSqD&interpretationid=yKqhXZdeJ6a + // /?id=ZBjCfSaLSqD&interpretationId=yKqhXZdeJ6a + + const interpretationId = + queryParams.interpretationId || queryParams.interpretationid + + const interpretationQueryParams = interpretationId + ? `?interpretationId=${interpretationId}` + : '' + + // replace history && hash history + window.history.replaceState( + {}, + '', + `${base}#/${queryParams.id}${interpretationQueryParams}` + ) + history.replace(`/${queryParams.id}${interpretationQueryParams}`) + } else if (queryParams.currentAnalyticalObject === true) { + // /?currentAnalyticalObject=true + + // replace history && hash history + window.history.replaceState({}, '', `${base}#/currentAnalyticalObject`) + history.replace('/currentAnalyticalObject') + } +} + const AppWrapper = () => { + replaceLegacyUrl() + return ( @@ -67,6 +106,7 @@ const AppWrapper = () => { > + diff --git a/src/actions/analyticalObject.js b/src/actions/analyticalObject.js index a85a73b37..583eda8f3 100644 --- a/src/actions/analyticalObject.js +++ b/src/actions/analyticalObject.js @@ -1,11 +1,4 @@ -import log from 'loglevel' import * as types from '../constants/actionTypes.js' -import { - clearAnalyticalObjectFromUrl, - hasSingleDataDimension, - getThematicLayerFromAnalyticalObject, -} from '../util/analyticalObject.js' -import { addLayer } from './layers.js' export const setAnalyticalObject = (ao) => ({ type: types.ANALYTICAL_OBJECT_SET, @@ -15,17 +8,3 @@ export const setAnalyticalObject = (ao) => ({ export const clearAnalyticalObject = () => ({ type: types.ANALYTICAL_OBJECT_CLEAR, }) - -export const tSetAnalyticalObject = (ao) => async (dispatch) => { - try { - clearAnalyticalObjectFromUrl() - return hasSingleDataDimension(ao) - ? getThematicLayerFromAnalyticalObject(ao).then((layer) => - dispatch(addLayer(layer)) - ) - : dispatch(setAnalyticalObject(ao)) - } catch (e) { - log.error('Could not load current analytical object') - return e - } -} diff --git a/src/actions/map.js b/src/actions/map.js index 2a8596c87..7287efce4 100644 --- a/src/actions/map.js +++ b/src/actions/map.js @@ -1,9 +1,4 @@ -import log from 'loglevel' import * as types from '../constants/actionTypes.js' -import { getFallbackBasemap } from '../constants/basemaps.js' -import { dataStatisticsMutation } from '../util/apiDataStatistics.js' -import { addOrgUnitPaths } from '../util/helpers.js' -import { fetchMap } from '../util/requests.js' export const newMap = () => ({ type: types.MAP_NEW, @@ -46,34 +41,3 @@ export const showEarthEngineValue = (layerId, coordinate) => ({ export const clearAlerts = () => ({ type: types.MAP_ALERTS_CLEAR, }) - -export const tOpenMap = - ({ mapId, defaultBasemap, engine, basemaps }) => - async (dispatch) => { - try { - const map = await fetchMap(mapId, engine, defaultBasemap) - - // record visualization view - engine.mutate(dataStatisticsMutation, { - variables: { id: mapId }, - onError: (error) => console.error('Error: ', error), - }) - - const basemapConfig = - basemaps.find((bm) => bm.id === map.basemap.id) || - getFallbackBasemap() - - const basemap = { ...map.basemap, ...basemapConfig } - - dispatch( - setMap({ - ...map, - mapViews: addOrgUnitPaths(map.mapViews), - basemap, - }) - ) - } catch (e) { - log.error(e) - return e - } - } diff --git a/src/components/app/App.js b/src/components/app/App.js index cb359998c..30fb6c030 100644 --- a/src/components/app/App.js +++ b/src/components/app/App.js @@ -1,58 +1,62 @@ -import { useCachedDataQuery } from '@dhis2/analytics' -import { useDataEngine } from '@dhis2/app-runtime' -import { useSetting } from '@dhis2/app-service-datastore' -import { CssVariables } from '@dhis2/ui' -import React, { useEffect } from 'react' -import { useDispatch } from 'react-redux' -import { tSetAnalyticalObject } from '../../actions/analyticalObject.js' -import { setInterpretation } from '../../actions/interpretations.js' -import { tOpenMap } from '../../actions/map.js' -import { CURRENT_AO_KEY } from '../../util/analyticalObject.js' -import { getUrlParameter } from '../../util/requests.js' -import AppLayout from './AppLayout.js' +import cx from 'classnames' +import React, { useState } from 'react' +import { useSelector } from 'react-redux' +import BottomPanel from '../datatable/BottomPanel.js' +import DownloadModeMenu from '../download/DownloadMenubar.js' +import DownloadSettings from '../download/DownloadSettings.js' +import LayersPanel from '../layers/LayersPanel.js' +import LayersLoader from '../loaders/LayersLoader.js' +import MapPosition from '../map/MapPosition.js' +import AppMenu from './AppMenu.js' +import DetailsPanel from './DetailsPanel.js' +import ModalContainer from './ModalContainer.js' import './App.css' -import './styles/App.module.css' +import styles from './styles/App.module.css' +import { useLoadMap } from './useLoadMap.js' const App = () => { - const { systemSettings, basemaps } = useCachedDataQuery() - const defaultBasemap = systemSettings.keyDefaultBaseMap - const engine = useDataEngine() - const [currentAO] = useSetting(CURRENT_AO_KEY) - const dispatch = useDispatch() + useLoadMap() - useEffect(() => { - async function fetchData() { - const mapId = getUrlParameter('id') - if (mapId) { - await dispatch( - tOpenMap({ - mapId, - defaultBasemap, - engine, - basemaps, - }) - ) - } else if (getUrlParameter('currentAnalyticalObject') === 'true') { - await dispatch(tSetAnalyticalObject(currentAO)) - } + const [interpretationsRenderCount, setInterpretationsRenderCount] = + useState(1) - // analytics interpretation component uses camelcase - const interpretationId = - getUrlParameter('interpretationid') || - getUrlParameter('interpretationId') - - if (interpretationId) { - dispatch(setInterpretation(interpretationId)) - } - } + const dataTableOpen = useSelector((state) => !!state.dataTable) + const downloadModeOpen = useSelector( + (state) => !!state.download.downloadMode + ) + const detailsPanelOpen = useSelector( + (state) => state.ui.rightPanelOpen && !state.orgUnitProfile + ) - fetchData() - }, [engine, currentAO, defaultBasemap, basemaps, dispatch]) + const onFileMenuAction = () => + detailsPanelOpen && + setInterpretationsRenderCount(interpretationsRenderCount + 1) return ( <> - - + {downloadModeOpen ? ( + + ) : ( + + )} +
+ {downloadModeOpen ? : } +
+ + {dataTableOpen && } +
+ {!downloadModeOpen && ( + + )} +
+ + ) } diff --git a/src/components/app/AppLayout.js b/src/components/app/AppLayout.js deleted file mode 100644 index 21b42a987..000000000 --- a/src/components/app/AppLayout.js +++ /dev/null @@ -1,66 +0,0 @@ -import cx from 'classnames' -import React, { useState } from 'react' -import { useSelector } from 'react-redux' -import AlertStack from '../alerts/AlertStack.js' -import BottomPanel from '../datatable/BottomPanel.js' -import DownloadModeMenu from '../download/DownloadMenubar.js' -import DownloadSettings from '../download/DownloadSettings.js' -import LayerEdit from '../edit/LayerEdit.js' -import LayersPanel from '../layers/LayersPanel.js' -import LayersLoader from '../loaders/LayersLoader.js' -import ContextMenu from '../map/ContextMenu.js' -import MapPosition from '../map/MapPosition.js' -import OpenAsMapDialog from '../openAs/OpenAsMapDialog.js' -import AppMenu from './AppMenu.js' -import DetailsPanel from './DetailsPanel.js' -import styles from './styles/AppLayout.module.css' - -const AppLayout = () => { - const [interpretationsRenderCount, setInterpretationsRenderCount] = - useState(1) - - const dataTableOpen = useSelector((state) => !!state.dataTable) - const downloadModeOpen = useSelector( - (state) => !!state.download.downloadMode - ) - const detailsPanelOpen = useSelector( - (state) => state.ui.rightPanelOpen && !state.orgUnitProfile - ) - - const onFileMenuAction = () => - detailsPanelOpen && - setInterpretationsRenderCount(interpretationsRenderCount + 1) - - return ( - <> - {downloadModeOpen ? ( - - ) : ( - - )} -
- {downloadModeOpen ? : } -
- - {dataTableOpen && } -
- {!downloadModeOpen && ( - - )} -
- - - - - - - ) -} - -export default AppLayout diff --git a/src/components/app/FileMenu.js b/src/components/app/FileMenu.js index 65dca2f30..77f79eb80 100644 --- a/src/components/app/FileMenu.js +++ b/src/components/app/FileMenu.js @@ -1,22 +1,21 @@ import { FileMenu as UiFileMenu, useCachedDataQuery } from '@dhis2/analytics' -import { useDataMutation, useDataEngine } from '@dhis2/app-runtime' +import { useDataMutation } from '@dhis2/app-runtime' import { useAlert } from '@dhis2/app-service-alerts' import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' import React from 'react' import { useSelector, useDispatch } from 'react-redux' -import { newMap, tOpenMap, setMapProps } from '../../actions/map.js' +import { setMapProps } from '../../actions/map.js' import { ALERT_CRITICAL, ALERT_MESSAGE_DYNAMIC, ALERT_OPTIONS_DYNAMIC, ALERT_SUCCESS_DELAY, } from '../../constants/alerts.js' -import { dataStatisticsMutation } from '../../util/apiDataStatistics.js' import { cleanMapConfig } from '../../util/favorites.js' -import { fetchMap } from '../../util/requests.js' +import history from '../../util/history.js' -const saveMapMutation = { +const updateMapMutation = { resource: 'maps', type: 'update', id: ({ id }) => id, @@ -27,7 +26,7 @@ const saveMapMutation = { data: ({ data }) => data, } -const saveAsNewMapMutation = { +const createMapMutation = { resource: 'maps', type: 'create', data: ({ data }) => data, @@ -52,10 +51,9 @@ const getSaveFailureMessage = (message) => }) const FileMenu = ({ onFileMenuAction }) => { - const engine = useDataEngine() const map = useSelector((state) => state.map) const dispatch = useDispatch() - const { systemSettings, currentUser, basemaps } = useCachedDataQuery() + const { systemSettings, currentUser } = useCachedDataQuery() const defaultBasemap = systemSettings.keyDefaultBaseMap //alerts const saveAlert = useAlert(ALERT_MESSAGE_DYNAMIC, ALERT_OPTIONS_DYNAMIC) @@ -65,33 +63,26 @@ const FileMenu = ({ onFileMenuAction }) => { ALERT_SUCCESS_DELAY ) const fileMenuErrorAlert = useAlert(ALERT_MESSAGE_DYNAMIC, ALERT_CRITICAL) - const openMapErrorAlert = useAlert(ALERT_MESSAGE_DYNAMIC, ALERT_CRITICAL) - const [saveMapMutate] = useDataMutation(saveMapMutation, { - onError: (e) => + const [putMap] = useDataMutation(updateMapMutation, { + onError: (e) => { saveAlert.show({ msg: getSaveFailureMessage(e.message), isError: true, - }), + }) + }, }) - const [saveAsNewMapMutate] = useDataMutation(saveAsNewMapMutation, { - onError: (e) => + + const [postMap] = useDataMutation(createMapMutation, { + onError: (e) => { saveAsAlert.show({ msg: getSaveFailureMessage(e.message), isError: true, - }), - }) - - const [postDataStatistics] = useDataMutation(dataStatisticsMutation, { - onError: (e) => console.error('Error:', e.message), + }) + }, }) - const onFileMenuError = (e) => - fileMenuErrorAlert.show({ - msg: e.message, - }) - - const saveMap = async () => { + const onSave = async () => { const config = cleanMapConfig({ config: map, defaultBasemapId: defaultBasemap, @@ -101,74 +92,41 @@ const FileMenu = ({ onFileMenuAction }) => { config.mapViews.forEach((view) => delete view.id) } - await saveMapMutate({ + await putMap({ id: map.id, data: config, }) - postDataStatistics({ id: map.id }) - - saveAlert.show({ msg: getSavedMessage(config.name) }) - } - - const openMap = async (id) => { - const error = await dispatch( - tOpenMap({ - mapId: id, - defaultBasemap, - engine, - basemaps, - }) - ) - if (error) { - openMapErrorAlert.show({ - msg: i18n.t(`Error while opening map: ${error.message}`, { - nsSeparator: ';', - }), - }) + saveAlert.show({ msg: getSavedMessage(map.name) }) + if (map.id) { + history.replace(`/${map.id}`) } } - const saveAsNewMap = async ({ name, description }) => { - const config = { + const onSaveAs = async ({ name, description }) => { + const data = { ...cleanMapConfig({ config: map, defaultBasemapId: defaultBasemap, }), name: getMapName(name), - description: description, + description, } - delete config.id + delete data.id - if (config.mapViews) { - config.mapViews.forEach((view) => delete view.id) + if (data.mapViews) { + data.mapViews.forEach((view) => delete view.id) } - const response = await saveAsNewMapMutate({ - data: config, - }) - - if (response.status === 'OK') { - const newMapId = response.response.uid - postDataStatistics({ id: newMapId }) - const newMapConfig = await fetchMap( - newMapId, - engine, - defaultBasemap - ) - - delete newMapConfig.basemap - delete newMapConfig.mapViews + const res = await postMap({ data }) - dispatch(setMapProps(newMapConfig)) + if (res.status === 'OK') { + saveAsAlert.show({ msg: getSavedMessage(getMapName(name)) }) - saveAsAlert.show({ msg: getSavedMessage(config.name) }) - } else { - saveAsAlert.show({ - msg: getSaveFailureMessage(response.message), - isError: true, - }) + if (res.response.uid) { + history.push(`/${res.response.uid}`) + } } } @@ -182,7 +140,27 @@ const FileMenu = ({ onFileMenuAction }) => { deleteAlert.show() } - const onNew = () => dispatch(newMap()) + const onNew = () => { + if (history.location.pathname === '/') { + history.replace('/') + } else { + history.push('/') + } + } + + const onOpen = async (id) => { + const path = `/${id}` + if (history.location.pathname === path) { + history.replace(path) + } else { + history.push(path) + } + } + + const onFileMenuError = (e) => + fileMenuErrorAlert.show({ + msg: e.message, + }) return ( { fileType="map" fileObject={map} onNew={onNew} - onOpen={openMap} - onSave={saveMap} - onSaveAs={saveAsNewMap} + onOpen={onOpen} + onSave={onSave} + onSaveAs={onSaveAs} onRename={onRename} onDelete={onDelete} onError={onFileMenuError} diff --git a/src/components/app/ModalContainer.js b/src/components/app/ModalContainer.js new file mode 100644 index 000000000..3b6766997 --- /dev/null +++ b/src/components/app/ModalContainer.js @@ -0,0 +1,21 @@ +import React from 'react' +import { useSelector } from 'react-redux' +import AlertStack from '../alerts/AlertStack.js' +import LayerEdit from '../edit/LayerEdit.js' +import ContextMenu from '../map/ContextMenu.js' +import OpenAsMapDialog from '../openAs/OpenAsMapDialog.js' + +const ModalContainer = () => { + const analyticalObject = useSelector((state) => state.analyticalObject) + + return ( + <> + + + + {analyticalObject && } + + ) +} + +export default ModalContainer diff --git a/src/components/app/styles/App.module.css b/src/components/app/styles/App.module.css index c8ccb4856..53f48d602 100644 --- a/src/components/app/styles/App.module.css +++ b/src/components/app/styles/App.module.css @@ -30,3 +30,19 @@ background: #cbcdcf; border-radius: 3px; } + +.content { + display: flex; + flex-direction: row; + height: calc(100vh - var(--header-height) - var(--toolbar-height)); +} + +.downloadContent { + margin-top: var(--header-height); + height: calc(100vh - var(--header-height)); +} + +.appMapAndTable { + flex: auto; + position: relative; +} diff --git a/src/components/app/styles/AppLayout.module.css b/src/components/app/styles/AppLayout.module.css deleted file mode 100644 index be2ac384e..000000000 --- a/src/components/app/styles/AppLayout.module.css +++ /dev/null @@ -1,15 +0,0 @@ -.content { - display: flex; - flex-direction: row; - height: calc(100vh - var(--header-height) - var(--toolbar-height)); -} - -.downloadContent { - margin-top: var(--header-height); - height: calc(100vh - var(--header-height)); -} - -.appMapAndTable { - flex: auto; - position: relative; -} diff --git a/src/components/app/useLoadMap.js b/src/components/app/useLoadMap.js new file mode 100644 index 000000000..7660df3c0 --- /dev/null +++ b/src/components/app/useLoadMap.js @@ -0,0 +1,113 @@ +import { useCachedDataQuery } from '@dhis2/analytics' +import { useDataEngine } from '@dhis2/app-runtime' +import log from 'loglevel' +import { useRef, useEffect, useCallback } from 'react' +import { useDispatch } from 'react-redux' +import { setAnalyticalObject } from '../../actions/analyticalObject.js' +import { setDownloadMode } from '../../actions/download.js' +import { setInterpretation } from '../../actions/interpretations.js' +import { newMap, setMap } from '../../actions/map.js' +import { getFallbackBasemap } from '../../constants/basemaps.js' +import { CURRENT_AO_KEY } from '../../util/analyticalObject.js' +import { dataStatisticsMutation } from '../../util/apiDataStatistics.js' +import { addOrgUnitPaths } from '../../util/helpers.js' +import history, { + getHashUrlParams, + defaultHashUrlParams, +} from '../../util/history.js' +import { fetchMap } from '../../util/requests.js' + +export const useLoadMap = () => { + const previousParamsRef = useRef(defaultHashUrlParams) + const { systemSettings, basemaps } = useCachedDataQuery() + const defaultBasemap = systemSettings.keyDefaultBaseMap + const engine = useDataEngine() + const dispatch = useDispatch() + + const loadMap = useCallback( + async (hashLocation) => { + previousParamsRef.current = getHashUrlParams(hashLocation) + + if (hashLocation.pathname === '/') { + dispatch(newMap()) + return + } + + const { mapId, isDownload, interpretationId } = + previousParamsRef.current + + if (mapId === CURRENT_AO_KEY) { + dispatch(newMap()) + dispatch(setAnalyticalObject(true)) + if (isDownload) { + dispatch(setDownloadMode(true)) + } + return + } + + try { + const map = await fetchMap(mapId, engine, defaultBasemap) + + engine.mutate(dataStatisticsMutation, { + variables: { id: mapId }, + onError: (error) => log.error('Error: ', error), + }) + + const basemapConfig = + basemaps.find(({ id }) => id === map.basemap.id) || + basemaps.find(({ id }) => id === defaultBasemap) || + getFallbackBasemap() + + dispatch( + setMap({ + ...map, + mapViews: addOrgUnitPaths(map.mapViews), + basemap: { ...map.basemap, ...basemapConfig }, + }) + ) + + if (interpretationId) { + dispatch(setInterpretation(interpretationId)) + } else if (isDownload) { + dispatch(setDownloadMode(true)) + } + } catch (e) { + log.error(e) + dispatch(newMap()) + } + }, + [basemaps, defaultBasemap, dispatch, engine] + ) + + useEffect(() => { + loadMap(history.location) + }, [loadMap]) + + useEffect(() => { + const unlisten = history.listen(({ action, location }) => { + const { + mapId: prevMapId, + interpretationId: prevInterpretationId, + isDownload: prevIsDownload, + } = previousParamsRef.current + + const { mapId, interpretationId, isDownload } = + getHashUrlParams(location) + + if (action === 'REPLACE' || prevMapId !== mapId) { + loadMap(location) + return + } + + if (isDownload !== prevIsDownload) { + dispatch(setDownloadMode(isDownload)) + previousParamsRef.current.isDownload = isDownload + } else if (interpretationId !== prevInterpretationId) { + dispatch(setInterpretation(interpretationId)) + previousParamsRef.current.interpretationId = interpretationId + } + }) + + return () => unlisten && unlisten() + }, [loadMap, dispatch]) +} diff --git a/src/components/download/DownloadButton.js b/src/components/download/DownloadButton.js index b9e738dc6..971d76e9f 100644 --- a/src/components/download/DownloadButton.js +++ b/src/components/download/DownloadButton.js @@ -1,17 +1,11 @@ import i18n from '@dhis2/d2-i18n' import React from 'react' -import { useDispatch } from 'react-redux' -import { setDownloadMode } from '../../actions/download.js' +import { openDownloadMode } from '../../util/history.js' import styles from './styles/DownloadButton.module.css' const DownloadButton = () => { - const dispatch = useDispatch() - return ( - ) diff --git a/src/components/download/DownloadLegend.js b/src/components/download/DownloadLegend.js index c1b775b20..c89439ff8 100644 --- a/src/components/download/DownloadLegend.js +++ b/src/components/download/DownloadLegend.js @@ -10,7 +10,7 @@ const DownloadLegend = ({ layers }) => .reverse() .map((legend, index) => (
-

+

{legend.title} {legend.period}

diff --git a/src/components/download/DownloadMapInfo.js b/src/components/download/DownloadMapInfo.js index aa68f223e..10d4d9662 100644 --- a/src/components/download/DownloadMapInfo.js +++ b/src/components/download/DownloadMapInfo.js @@ -26,7 +26,10 @@ const DownloadMapInfo = ({ map, isSplitView }) => { }, [showName, showDescription, showInLegend, height]) return ( -
+
{showName && name &&

{name}

} {showDescription && description &&

{description}

} diff --git a/src/components/download/DownloadMenubar.js b/src/components/download/DownloadMenubar.js index a6b7c476a..5486ea4cb 100644 --- a/src/components/download/DownloadMenubar.js +++ b/src/components/download/DownloadMenubar.js @@ -1,13 +1,10 @@ import i18n from '@dhis2/d2-i18n' import { Button, IconChevronLeft24, colors } from '@dhis2/ui' import React, { useEffect } from 'react' -import { useDispatch } from 'react-redux' -import { setDownloadMode } from '../../actions/download.js' +import { closeDownloadMode } from '../../util/history.js' import styles from './styles/DownloadMenubar.module.css' const DownloadMenubar = () => { - const dispatch = useDispatch() - useEffect(() => { const header = document.getElementsByTagName('header')[0] header.style.display = 'none' @@ -18,7 +15,7 @@ const DownloadMenubar = () => { return (
- diff --git a/src/components/download/DownloadSettings.js b/src/components/download/DownloadSettings.js index 533752a47..b6efedb22 100644 --- a/src/components/download/DownloadSettings.js +++ b/src/components/download/DownloadSettings.js @@ -2,10 +2,11 @@ import i18n from '@dhis2/d2-i18n' import { Button, ButtonStrip } from '@dhis2/ui' import React, { useState, useMemo, useCallback, useEffect } from 'react' import { useSelector, useDispatch } from 'react-redux' -import { setDownloadMode, setDownloadConfig } from '../../actions/download.js' +import { setDownloadConfig } from '../../actions/download.js' import { standardizeFilename } from '../../util/dataDownload.js' import { downloadMapImage, downloadSupport } from '../../util/export-image.js' import { getSplitViewLayer } from '../../util/helpers.js' +import { closeDownloadMode } from '../../util/history.js' import { getMapName } from '../app/FileMenu.js' import Drawer from '../core/Drawer.js' import { Checkbox, Help } from '../core/index.js' @@ -35,11 +36,6 @@ const DownloadSettings = () => { [mapViews] ) - const onClose = useCallback( - () => dispatch(setDownloadMode(false)), - [dispatch] - ) - const onDownload = useCallback(() => { const filename = standardizeFilename(getMapName(name), 'png') let mapEl = document.getElementById('dhis2-map-container') @@ -76,7 +72,10 @@ const DownloadSettings = () => { const showMarginsCheckbox = false // Not in use return ( -
+

{i18n.t('Download map')}

@@ -85,6 +84,7 @@ const DownloadSettings = () => { <> @@ -199,7 +199,7 @@ const DownloadSettings = () => {
- - - - - - ) - } - - onSelectDataDim = (selectedDataDims) => { - this.setState({ selectedDataDims }) - } + const [selectedDataDims, setSelectedDataDims] = useState([firstDimensionId]) - onProceedClick = async () => { - const { ao, addLayer, clearAnalyticalObject } = this.props - const dataDims = [...this.state.selectedDataDims].reverse() - const lastDataId = dataDims[dataDims.length - 1] + const addLayersToMap = async () => { + const selectedDimensions = [...selectedDataDims].reverse() + const lastDataId = allDataDimensions[selectedDimensions.length - 1] // Call in sequence - for (const dataId of dataDims) { + for (const dataId of selectedDimensions) { const layer = await getThematicLayerFromAnalyticalObject( - ao, + currentAO, dataId, dataId === lastDataId ) if (layer) { - addLayer(layer) + dispatch(addLayer(layer)) } } - clearAnalyticalObject() + dispatch(clearAnalyticalObject()) + } + + if (!allDataDimensions.length) { + log.info('No data items found in analytical object') + return null // TODO show error } -} -export default connect( - (state) => ({ - showDialog: !!state.analyticalObject, - ao: state.analyticalObject, - }), - { - addLayer, - clearAnalyticalObject, + if (allDataDimensions.length === 1) { + addLayersToMap() + return null } -)(OpenAsMapDialog) + + return ( + + {i18n.t('Open as map')} + +
+
+ {i18n.t( + 'This chart/table contains {{numItems}} data items. Choose which items you want to import from the list below. Each data item will be created as a map layer.', + { + numItems: allDataDimensions.length, + } + )} +
+ +
+
+ + + + + + +
+ ) +} + +export default OpenAsMapDialog diff --git a/src/util/__tests__/analyticalObject.spec.js b/src/util/__tests__/analyticalObject.spec.js index 958db1887..69b7378a6 100644 --- a/src/util/__tests__/analyticalObject.spec.js +++ b/src/util/__tests__/analyticalObject.spec.js @@ -1,5 +1,4 @@ import { - hasSingleDataDimension, getDataDimensionsFromAnalyticalObject, getThematicLayerFromAnalyticalObject, getAnalyticalObjectFromThematicLayer, @@ -30,18 +29,6 @@ jest.mock('../legend') describe('analytical object utils', () => { describe('analytical object handling', () => { - it('returns true if analytic object contains one data item', () => { - const result = hasSingleDataDimension({ - columns, - }) - expect(result).toBeTruthy() - }) - - it('returns false if analytic object is without data item', () => { - const result = hasSingleDataDimension({}) - expect(result).toBeFalsy() - }) - it('returns data items from columns in analytical object', () => { const result = getDataDimensionsFromAnalyticalObject({ columns, diff --git a/src/util/__tests__/history.spec.js b/src/util/__tests__/history.spec.js new file mode 100644 index 000000000..9ddc28aba --- /dev/null +++ b/src/util/__tests__/history.spec.js @@ -0,0 +1,80 @@ +import queryString from 'query-string' +import { getHashUrlParams } from '../history.js' + +// jest.mock('query-string') +jest.mock('query-string', () => ({ + parse: jest.fn(() => {}), +})) + +describe('getHashUrlParams', () => { + it('should return isDownload=true when pathname is /download', () => { + queryString.parse.mockImplementationOnce(() => ({})) + const loc = { search: '', pathname: '/download' } + expect(getHashUrlParams(loc)).toEqual({ isDownload: true, mapId: '' }) + }) + + it('should return mapId="currentAnalyticalObject" when pathname is /currentAnalyticalObject', () => { + queryString.parse.mockImplementationOnce(() => ({})) + const loc = { search: '', pathname: '/currentAnalyticalObject' } + expect(getHashUrlParams(loc)).toEqual({ + mapId: 'currentAnalyticalObject', + }) + }) + + it('should return param and mapId when search is ?param=true and pathname is /xyzpdq', () => { + queryString.parse.mockImplementationOnce(() => ({ param: true })) + const loc = { search: '?param=true', pathname: '/xyzpdq' } + expect(getHashUrlParams(loc)).toEqual({ param: true, mapId: 'xyzpdq' }) + }) + + it('should return param, mapId, and isDownload when search is ?param=false and pathname is /xyzpdq/download', () => { + queryString.parse.mockImplementationOnce(() => ({ param: false })) + const loc = { search: '?param=false', pathname: '/xyzpdq/download' } + expect(getHashUrlParams(loc)).toEqual({ + param: false, + mapId: 'xyzpdq', + isDownload: true, + }) + }) + + it('should return interpretationId and isDownload when search is ?interpretationId=xyzpdq and pathname is /download', () => { + queryString.parse.mockImplementationOnce(() => ({ + interpretationId: 'xyzpdq', + })) + const loc = { + search: '?interpretationId=xyzpdq', + pathname: '/download', + } + expect(getHashUrlParams(loc)).toEqual({ + interpretationId: 'xyzpdq', + isDownload: true, + mapId: '', + }) + }) + + it('should return interpretationId and mapId when search is ?interpretationId=xyzpdq and pathname is /xyzpdq', () => { + queryString.parse.mockImplementationOnce(() => ({ + interpretationId: 'xyzpdq', + })) + const loc = { search: '?interpretationId=xyzpdq', pathname: '/xyzpdq' } + expect(getHashUrlParams(loc)).toEqual({ + interpretationId: 'xyzpdq', + mapId: 'xyzpdq', + }) + }) + + it('should return interpretationId, mapId, and isDownload when search is ?interpretationId=xyzpdq and pathname is /xyzpdq/download', () => { + queryString.parse.mockImplementationOnce(() => ({ + interpretationId: 'xyzpdq', + })) + const loc = { + search: '?interpretationId=xyzpdq', + pathname: '/xyzpdq/download', + } + expect(getHashUrlParams(loc)).toEqual({ + interpretationId: 'xyzpdq', + mapId: 'xyzpdq', + isDownload: true, + }) + }) +}) diff --git a/src/util/analyticalObject.js b/src/util/analyticalObject.js index 5a94487b6..f04097949 100644 --- a/src/util/analyticalObject.js +++ b/src/util/analyticalObject.js @@ -12,7 +12,7 @@ export const APP_URLS = { } // Combines all dimensions in columns, rows and filters -const getDimensionsFromAnalyticalObject = (ao) => { +const getDimensionsFromAnalyticalObject = (ao = {}) => { const { columns = [], rows = [], filters = [] } = ao return [...columns, ...rows, ...filters] } @@ -26,12 +26,6 @@ export const getDataDimensionsFromAnalyticalObject = (ao) => { return dataDim ? dataDim.items : [] } -// Returns true if analytical object contains a single data dimension item -export const hasSingleDataDimension = (ao) => { - const dataItems = getDataDimensionsFromAnalyticalObject(ao) - return dataItems.length === 1 -} - // Returns a thematic layer config from an analytical object export const getThematicLayerFromAnalyticalObject = async ( ao = {}, @@ -98,18 +92,3 @@ export const getAnalyticalObjectFromThematicLayer = (layer = {}) => { aggregationType, } } - -// Temporary fix until we switch to hash and react router -export const clearAnalyticalObjectFromUrl = () => { - const [base, params] = window.location.href.split('?') - - if (params && history && history.pushState) { - const leftParams = params - .split('&') - .filter((p) => !p.includes('currentAnalyticalObject')) - - const url = base + (leftParams.length ? `?${leftParams.join('&')}` : '') - - history.pushState({}, null, url) - } -} diff --git a/src/util/history.js b/src/util/history.js new file mode 100644 index 000000000..52a26f358 --- /dev/null +++ b/src/util/history.js @@ -0,0 +1,59 @@ +import { createHashHistory } from 'history' +import queryString from 'query-string' + +const history = createHashHistory() +export default history + +const defaultHashUrlParams = { + mapId: '', + isDownload: false, + interpretationId: null, +} + +const DOWNLOAD = 'download' + +const getHashUrlParams = (loc) => { + const params = queryString.parse(loc.search || '', { + parseBooleans: true, + }) + + const pathParts = loc.pathname.slice(1).split('/') + if (pathParts.length > 0) { + if (pathParts[0] === DOWNLOAD) { + params.mapId = '' + params.isDownload = true + } else { + params.mapId = pathParts[0] + + if (pathParts[1] === DOWNLOAD) { + params.isDownload = true + } + } + } + + return params +} + +const openDownloadMode = () => { + if (history.location.pathname === '/') { + history.push(`/${DOWNLOAD}`) + } else { + history.push(`${history.location.pathname}/${DOWNLOAD}`) + } +} + +const closeDownloadMode = () => { + if (history.location.pathname === `/${DOWNLOAD}`) { + history.push('/') + } else { + const rootPath = history.location.pathname.split(`/${DOWNLOAD}`)[0] + history.push(rootPath) + } +} + +export { + getHashUrlParams, + defaultHashUrlParams, + openDownloadMode, + closeDownloadMode, +} diff --git a/src/util/requests.js b/src/util/requests.js index f51b2fbd1..7e5a2cbd5 100644 --- a/src/util/requests.js +++ b/src/util/requests.js @@ -39,13 +39,3 @@ export const getExternalLayer = async (id) => { const d2 = await getD2() return d2.models.externalMapLayers.get(id) } - -// https://davidwalsh.name/query-string-javascript -export const getUrlParameter = (name) => { - name = name.replace(/[[]/, '\\[').replace(/[\]]/, '\\]') - const regex = new RegExp('[\\?&]' + name + '=([^&#]*)') - const results = regex.exec(location.search) - return results === null - ? '' - : decodeURIComponent(results[1].replace(/\+/g, ' ')) -} diff --git a/yarn.lock b/yarn.lock index 707d578c6..ee30f8a69 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1148,6 +1148,13 @@ dependencies: regenerator-runtime "^0.13.11" +"@babel/runtime@^7.7.6": + version "7.22.10" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.10.tgz#ae3e9631fd947cb7e3610d3e9d8fef5f76696682" + integrity sha512-21t/fkKLMZI4pqP2wlmsQAWnYW1PDyKyyUV4vCi+B25ydmdaYTKXPwCj0BzSUnZf4seIiYvSA3jcZ3gdsMFkLQ== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/template@^7.18.10", "@babel/template@^7.20.7", "@babel/template@^7.3.3": version "7.20.7" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.20.7.tgz#a15090c2839a83b02aa996c0b4994005841fd5a8" @@ -6674,6 +6681,11 @@ decode-uri-component@^0.2.0: resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz#e69dbe25d37941171dd540e024c444cd5188e1e9" integrity sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ== +decode-uri-component@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.4.1.tgz#2ac4859663c704be22bf7db760a1494a49ab2cc5" + integrity sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ== + decompress-response@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3" @@ -8160,6 +8172,11 @@ fill-range@^7.0.1: dependencies: to-regex-range "^5.0.1" +filter-obj@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/filter-obj/-/filter-obj-5.1.0.tgz#5bd89676000a713d7db2e197f660274428e524ed" + integrity sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng== + final-form@^4.20.2: version "4.20.9" resolved "https://registry.yarnpkg.com/final-form/-/final-form-4.20.9.tgz#647b459f8c504d77ec8f6e280015ab172982af2f" @@ -8928,6 +8945,13 @@ highcharts@^10.3.3: resolved "https://registry.yarnpkg.com/highcharts/-/highcharts-10.3.3.tgz#b8acca24f2d4b1f2f726540734166e59e07b35c4" integrity sha512-r7wgUPQI9tr3jFDn3XT36qsNwEIZYcfgz4mkKEA6E4nn5p86y+u1EZjazIG4TRkl5/gmGRtkBUiZW81g029RIw== +history@^5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/history/-/history-5.3.0.tgz#1548abaa245ba47992f063a0783db91ef201c73b" + integrity sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ== + dependencies: + "@babel/runtime" "^7.7.6" + hmac-drbg@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" @@ -13161,6 +13185,15 @@ qs@~6.5.2: resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.3.tgz#3aeeffc91967ef6e35c0e488ef46fb296ab76aad" integrity sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA== +query-string@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/query-string/-/query-string-8.1.0.tgz#e7f95367737219544cd360a11a4f4ca03836e115" + integrity sha512-BFQeWxJOZxZGix7y+SByG3F36dA0AbTy9o6pSmKFcFz7DAj0re9Frkty3saBn3nHo3D0oZJ/+rx3r8H8r8Jbpw== + dependencies: + decode-uri-component "^0.4.1" + filter-obj "^5.1.0" + split-on-first "^3.0.0" + querystring-es3@~0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" @@ -13671,6 +13704,11 @@ regenerator-runtime@^0.13.11, regenerator-runtime@^0.13.9: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9" integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg== +regenerator-runtime@^0.14.0: + version "0.14.0" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz#5e19d68eb12d486f797e15a3c6a918f7cec5eb45" + integrity sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA== + regenerator-transform@^0.15.1: version "0.15.1" resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.15.1.tgz#f6c4e99fc1b4591f780db2586328e4d9a9d8dc56" @@ -14599,6 +14637,11 @@ spdy@^4.0.2: select-hose "^2.0.0" spdy-transport "^3.0.0" +split-on-first@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/split-on-first/-/split-on-first-3.0.0.tgz#f04959c9ea8101b9b0bbf35a61b9ebea784a23e7" + integrity sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA== + split-string@^3.0.1, split-string@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2"