From 0539129f176c4690e1604669d4558c2acf3c0530 Mon Sep 17 00:00:00 2001 From: Danko Kozar Date: Tue, 31 Mar 2020 18:27:29 +0100 Subject: [PATCH] feat: Using JSURL for data serialization in links --- package.json | 1 + src/algorithms/types/Param.types.ts | 7 +- src/components/Main/Main.tsx | 25 +++- .../Main/Results/ResponsiveTooltipContent.tsx | 6 +- .../Main/state/URLSerializer.test.ts | 120 ++++++++++++++++++ src/components/Main/state/URLSerializer.ts | 86 ++++++------- .../state/serialization/encodeURIComponent.ts | 44 +++++++ .../Main/state/serialization/jsUrl.test.ts | 63 +++++++++ .../Main/state/serialization/jsUrl.ts | 66 ++++++++++ src/types/jsurl-module.d.ts | 1 + yarn.lock | 5 + 11 files changed, 363 insertions(+), 61 deletions(-) create mode 100644 src/components/Main/state/URLSerializer.test.ts create mode 100644 src/components/Main/state/serialization/encodeURIComponent.ts create mode 100644 src/components/Main/state/serialization/jsUrl.test.ts create mode 100644 src/components/Main/state/serialization/jsUrl.ts create mode 100644 src/types/jsurl-module.d.ts diff --git a/package.json b/package.json index 3ea1e41e8..431d74cdf 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "i18next-browser-languagedetector": "4.0.2", "immer": "5.3.6", "immutable": "3.8.2", + "jsurl": "0.1.5", "jszip": "3.2.2", "katex": "0.11.1", "lodash": "4.17.15", diff --git a/src/algorithms/types/Param.types.ts b/src/algorithms/types/Param.types.ts index 210f93d33..425e8f993 100644 --- a/src/algorithms/types/Param.types.ts +++ b/src/algorithms/types/Param.types.ts @@ -43,11 +43,8 @@ export interface ScenarioData { simulation: SimulationData } -export interface AllParams { - population: PopulationData - epidemiological: EpidemiologicalData - simulation: SimulationData - containment: ContainmentData +export interface AllParams extends ScenarioData { + current: string } export type AllParamsFlat = PopulationData & EpidemiologicalData & SimulationData diff --git a/src/components/Main/Main.tsx b/src/components/Main/Main.tsx index 15ab45811..7a967b5c0 100644 --- a/src/components/Main/Main.tsx +++ b/src/components/Main/Main.tsx @@ -26,7 +26,7 @@ import { schema } from './validation/schema' import { setContainmentData, setPopulationData, setEpidemiologicalData, setSimulationData } from './state/actions' import { scenarioReducer } from './state/reducer' import { defaultScenarioState, State } from './state/state' -import { serializeScenarioToURL, deserializeScenarioFromURL } from './state/URLSerializer' +import { serialize, deserialize } from './state/URLSerializer' import { ResultsCard } from './Results/ResultsCard' import { ScenarioCard } from './Scenario/ScenarioCard' @@ -60,7 +60,7 @@ async function runSimulation( return } - if (params.population.cases !== "none" && !isRegion(params.population.cases)) { + if (params.population.cases !== 'none' && !isRegion(params.population.cases)) { console.error(`The given confirmed cases region is invalid: ${params.population.cases}`) return } @@ -70,7 +70,8 @@ async function runSimulation( const containmentData = params.containment.reduction - serializeScenarioToURL(scenarioState, params) + const queryString = serialize(scenarioState, params) + window.history.pushState('', '', `?${queryString}`) const newResult = await run(paramsFlat, severity, ageDistribution, containmentData) setResult(newResult) @@ -88,13 +89,29 @@ const isRegion = (region: string): region is keyof typeof countryCaseCountData = return Object.prototype.hasOwnProperty.call(countryCaseCountData, region) } +const deserializeScenarioFromUrl = (state: State): State => { + const { search } = window.location + + if (search) { + const queryString = search.slice(1) // removing first char ('?') + + try { + return deserialize(state, queryString) + } catch (error) { + console.error('Error while parsing URL') + } + } + + return state +} + function Main() { const [result, setResult] = useState() const [autorunSimulation, setAutorunSimulation] = useState(false) const [scenarioState, scenarioDispatch] = useReducer( scenarioReducer, defaultScenarioState, - deserializeScenarioFromURL, + deserializeScenarioFromUrl, ) // TODO: Can this complex state be handled by formik too? diff --git a/src/components/Main/Results/ResponsiveTooltipContent.tsx b/src/components/Main/Results/ResponsiveTooltipContent.tsx index cb7e6e908..38394b1bb 100644 --- a/src/components/Main/Results/ResponsiveTooltipContent.tsx +++ b/src/components/Main/Results/ResponsiveTooltipContent.tsx @@ -1,16 +1,16 @@ -import React from 'react' +import React, { ReactNode } from 'react' import './ResponsiveTooltipContent.scss' interface TooltipItem { name: string - value: string | number + value: string | number | ReactNode color: string } interface TooltipContentProps { active: boolean - label: string | number + label?: string | number payload: TooltipItem[] formatter?: Function labelFormatter?: Function diff --git a/src/components/Main/state/URLSerializer.test.ts b/src/components/Main/state/URLSerializer.test.ts new file mode 100644 index 000000000..d911e1c67 --- /dev/null +++ b/src/components/Main/state/URLSerializer.test.ts @@ -0,0 +1,120 @@ +import { serialize, deserialize } from './URLSerializer' +import { State } from './state' +import { AllParams } from '../../../algorithms/types/Param.types' + +const SCENARIOS = ['CHE-Basel-Landschaft', 'CHE-Basel-Stadt'] + +const INITIAL_STATE: State = { + scenarios: SCENARIOS, + current: 'CHE-Basel-Stadt', + data: { + population: { + ICUBeds: 80, + cases: 'CHE-Basel-Stadt', + country: 'Switzerland', + hospitalBeds: 698, + importsPerDay: 0.1, + populationServed: 195000, + suspectedCasesToday: 10, + }, + epidemiological: { + infectiousPeriod: 3, + latencyTime: 5, + lengthHospitalStay: 4, + lengthICUStay: 14, + overflowSeverity: 2, + peakMonth: 0, + r0: 2, + seasonalForcing: 0.2, + }, + containment: { + reduction: [ + { t: new Date('2020-01-31T00:00:00.000Z'), y: 1 }, + { t: new Date('2020-02-23T18:40:00.000Z'), y: 1 }, + { t: new Date('2020-03-18T13:20:00.000Z'), y: 1 }, + { t: new Date('2020-04-11T08:00:00.000Z'), y: 1 }, + { t: new Date('2020-05-05T02:40:00.000Z'), y: 1 }, + { t: new Date('2020-05-28T21:20:00.000Z'), y: 1 }, + { t: new Date('2020-06-21T16:00:00.000Z'), y: 1 }, + { t: new Date('2020-07-15T10:40:00.000Z'), y: 1 }, + { t: new Date('2020-08-08T05:20:00.000Z'), y: 1 }, + { t: new Date('2020-09-01T00:00:00.000Z'), y: 1 }, + ], + numberPoints: 10, + }, + simulation: { + simulationTimeRange: { tMin: new Date('2020-01-31T00:00:00.000Z'), tMax: new Date('2020-09-01T00:00:00.000Z') }, + numberStochasticRuns: 0, + }, + }, +} + +const PARAMS: AllParams = { + population: { + ICUBeds: 80, + cases: 'CHE-Basel-Stadt', + country: 'Switzerland', + hospitalBeds: 698, + importsPerDay: 0.1, + populationServed: 195000, + suspectedCasesToday: 10, + }, + epidemiological: { + infectiousPeriod: 3, + latencyTime: 5, + lengthHospitalStay: 4, + lengthICUStay: 14, + overflowSeverity: 2, + peakMonth: 0, + r0: 2, + seasonalForcing: 0.2, + }, + simulation: { + simulationTimeRange: { tMax: new Date('2020-09-01T00:00:00.000Z'), tMin: new Date('2020-01-31T00:00:00.000Z') }, + numberStochasticRuns: 0, + }, + containment: { + reduction: [ + { y: 1, t: new Date('2020-01-31T00:00:00.000Z') }, + { y: 1, t: new Date('2020-02-23T18:40:00.000Z') }, + { y: 1, t: new Date('2020-03-18T13:20:00.000Z') }, + { y: 1, t: new Date('2020-04-11T08:00:00.000Z') }, + { y: 1, t: new Date('2020-05-05T02:40:00.000Z') }, + { y: 1, t: new Date('2020-05-28T21:20:00.000Z') }, + { y: 1, t: new Date('2020-06-21T16:00:00.000Z') }, + { y: 1, t: new Date('2020-07-15T10:40:00.000Z') }, + { y: 1, t: new Date('2020-08-08T05:20:00.000Z') }, + { y: 0.5, t: new Date('2020-09-01T00:00:00.000Z') }, // last slider moved to 0.5 + ], + numberPoints: 10, + }, + current: 'CHE-Basel-Stadt', +} + +const SERIALIZED_STRING = + "~(population~(ICUBeds~80~cases~'CHE-Basel-Stadt~country~'Switzerland~hospitalBeds~698~importsPerDay~0.1~populationServed~195000~suspectedCasesToday~10)~epidemiological~(infectiousPeriod~3~latencyTime~5~lengthHospitalStay~4~lengthICUStay~14~overflowSeverity~2~peakMonth~0~r0~2~seasonalForcing~0.2)~simulation~(simulationTimeRange~(tMin~1580428800000~tMax~1598918400000)~numberStochasticRuns~0)~containment~(reduction~(~(y~1~t~1580428800000)~(y~1~t~1582483200000)~(y~1~t~1584537600000)~(y~1~t~1586592000000)~(y~1~t~1588646400000)~(y~1~t~1590700800000)~(y~1~t~1592755200000)~(y~1~t~1594809600000)~(y~1~t~1596864000000)~(y~0.5~t~1598918400000))~numberPoints~10)~current~'CHE-Basel-Stadt)" + +describe('URLSerializer', () => { + it('serialize', () => { + expect(serialize(INITIAL_STATE, PARAMS)).toBe(SERIALIZED_STRING) + }) + + it('deserialize', () => { + expect(deserialize(INITIAL_STATE, SERIALIZED_STRING)).toEqual({ + current: 'CHE-Basel-Stadt', + scenarios: INITIAL_STATE.scenarios, + data: { + containment: PARAMS.containment, + epidemiological: PARAMS.epidemiological, + population: PARAMS.population, + simulation: PARAMS.simulation, + }, + }) + }) + + it('throws', () => { + expect(() => { + deserialize(INITIAL_STATE, 'some random string') + }).toThrow('URLSerializer: Error while parsing URL') + }) +}) diff --git a/src/components/Main/state/URLSerializer.ts b/src/components/Main/state/URLSerializer.ts index 7c34d6b01..d76f0509f 100644 --- a/src/components/Main/state/URLSerializer.ts +++ b/src/components/Main/state/URLSerializer.ts @@ -1,65 +1,53 @@ import type { AllParams } from '../../../algorithms/types/Param.types' - import { State } from './state' - -/* - -Quick and dirty helper to serialize/deserialize parameters within the URL, -so people can share/save it and keep their parameters - -This could have been done inside a redux middleware, but with some refacto. - -We use JSON.stringify to serialize things out. It's not the most optimized way at all, -but it works, and it's simple enough. Note that Date object is serialized as a string, -so some extra work is needed during deserialization. - -*/ - -export async function serializeScenarioToURL(scenarioState: State, params: AllParams) { - const toSave = { +import jsUrl from './serialization/jsUrl' +import encodeURIComponent from './serialization/encodeURIComponent' + +/** + * Serializes using JSURL (new format) + */ +export function serialize(state: State, params: AllParams) { + return jsUrl.serialize({ ...params, - current: scenarioState.current, - containment: scenarioState.data.containment.reduction, - } - - window.history.pushState('', '', `?${encodeURIComponent(JSON.stringify(toSave))}`) + current: state.current, + }) } -export function deserializeScenarioFromURL(initState: State): State { - if (window.location.search) { - try { - /* - We deserialise the URL by removing the first char ('?'), and applying JSON.parse - */ - const obj = JSON.parse(decodeURIComponent(window.location.search.slice(1))) - - // Be careful of dates object that have been serialized to string - - // safe to mutate here - obj.simulation.simulationTimeRange.tMin = new Date(obj.simulation.simulationTimeRange.tMin) - obj.simulation.simulationTimeRange.tMax = new Date(obj.simulation.simulationTimeRange.tMax) +/** + * Deserializes using: + * 1. JSURL (new format) + * 2. encodeURIComponent (old format for backward compatibility) + */ +export function deserialize(state: State, queryString: string): State { + let obj: AllParams - const containmentDataReduction = obj.containment.map((c: { t: string; y: number }) => ({ - y: c.y, - t: new Date(c.t), - })) + if (queryString) { + try { + // 1. Trying to deserialize as JSURL + obj = jsUrl.deserialize(queryString) + } catch (error) { + try { + // 2. This is not JSURL, the old (URL encoded) format + obj = encodeURIComponent.deserialize(queryString) + } catch (error) { + // 3. None of the two formats + throw new Error('URLSerializer: Error while parsing URL') + } + } + if (obj) { return { - ...initState, + ...state, current: obj.current, data: { - population: initState.data.population, - containment: { - reduction: containmentDataReduction, - numberPoints: containmentDataReduction.length, - }, - epidemiological: initState.data.epidemiological, + containment: obj.containment, + epidemiological: obj.epidemiological, + population: obj.population, simulation: obj.simulation, }, } - } catch (error) { - console.error('Error while parsing URL :', error.message) } } - return initState + + return state } diff --git a/src/components/Main/state/serialization/encodeURIComponent.ts b/src/components/Main/state/serialization/encodeURIComponent.ts new file mode 100644 index 000000000..5b9bc33c7 --- /dev/null +++ b/src/components/Main/state/serialization/encodeURIComponent.ts @@ -0,0 +1,44 @@ +import type { AllParams } from '../../../../algorithms/types/Param.types' + +const serialize = (params: AllParams): string => { + return encodeURIComponent(JSON.stringify(params)) +} + +const deserialize = (queryString: string): AllParams => { + try { + const obj = JSON.parse(decodeURIComponent(queryString)) + + const simulationTimeRangeOriginal = obj.simulation.simulationTimeRange + const simulationTimeRangeConverted = { + tMin: new Date(simulationTimeRangeOriginal.tMin), + tMax: new Date(simulationTimeRangeOriginal.tMax), + } + + // NOTE: Error in original parser. It should be: obj.containment + const containmentOriginal = obj.containment + const containmentConverted = { + ...containmentOriginal, + // NOTE: Error in original parser. It should be: containmentOriginal.reduction.map + reduction: containmentOriginal.map((c: { t: number; y: number }) => ({ + y: c.y, + t: new Date(c.t), + })), + } + + return { + ...obj, + containment: containmentConverted, + simulation: { + ...obj.simulation, + simulationTimeRange: simulationTimeRangeConverted, + }, + } + } catch (error) { + throw new Error('encodeURIComponent: Error while parsing URL') + } +} + +export default { + serialize, + deserialize, +} diff --git a/src/components/Main/state/serialization/jsUrl.test.ts b/src/components/Main/state/serialization/jsUrl.test.ts new file mode 100644 index 000000000..5f0bd25e1 --- /dev/null +++ b/src/components/Main/state/serialization/jsUrl.test.ts @@ -0,0 +1,63 @@ +import jsUrl from './jsUrl' +import { AllParams } from '../../../../algorithms/types/Param.types' + +const PARAMS: AllParams = { + population: { + ICUBeds: 80, + cases: 'CHE-Basel-Stadt', + country: 'Switzerland', + hospitalBeds: 698, + importsPerDay: 0.1, + populationServed: 195000, + suspectedCasesToday: 10, + }, + epidemiological: { + infectiousPeriod: 3, + latencyTime: 5, + lengthHospitalStay: 4, + lengthICUStay: 14, + overflowSeverity: 2, + peakMonth: 0, + r0: 2, + seasonalForcing: 0.2, + }, + simulation: { + simulationTimeRange: { tMax: new Date('2020-09-01T00:00:00.000Z'), tMin: new Date('2020-01-31T00:00:00.000Z') }, + numberStochasticRuns: 0, + }, + containment: { + reduction: [ + { t: new Date('2020-01-31T00:00:00.000Z'), y: 1 }, + { t: new Date('2020-02-23T18:40:00.000Z'), y: 1 }, + { t: new Date('2020-03-18T13:20:00.000Z'), y: 1 }, + { t: new Date('2020-04-11T08:00:00.000Z'), y: 1 }, + { t: new Date('2020-05-05T02:40:00.000Z'), y: 1 }, + { t: new Date('2020-05-28T21:20:00.000Z'), y: 1 }, + { t: new Date('2020-06-21T16:00:00.000Z'), y: 1 }, + { t: new Date('2020-07-15T10:40:00.000Z'), y: 1 }, + { t: new Date('2020-08-08T05:20:00.000Z'), y: 1 }, + { t: new Date('2020-09-01T00:00:00.000Z'), y: 0.5 }, + ], + numberPoints: 10, + }, + current: 'CHE-Basel-Stadt', +} + +const SERIALIZED_STRING = + "~(population~(ICUBeds~80~cases~'CHE-Basel-Stadt~country~'Switzerland~hospitalBeds~698~importsPerDay~0.1~populationServed~195000~suspectedCasesToday~10)~epidemiological~(infectiousPeriod~3~latencyTime~5~lengthHospitalStay~4~lengthICUStay~14~overflowSeverity~2~peakMonth~0~r0~2~seasonalForcing~0.2)~simulation~(simulationTimeRange~(tMin~1580428800000~tMax~1598918400000)~numberStochasticRuns~0)~containment~(reduction~(~(y~1~t~1580428800000)~(y~1~t~1582483200000)~(y~1~t~1584537600000)~(y~1~t~1586592000000)~(y~1~t~1588646400000)~(y~1~t~1590700800000)~(y~1~t~1592755200000)~(y~1~t~1594809600000)~(y~1~t~1596864000000)~(y~0.5~t~1598918400000))~numberPoints~10)~current~'CHE-Basel-Stadt)" + +describe('jsUrl', () => { + it('serializes', () => { + expect(jsUrl.serialize(PARAMS)).toBe(SERIALIZED_STRING) + }) + + it('deserializes', () => { + expect(jsUrl.deserialize(SERIALIZED_STRING)).toEqual(PARAMS) + }) + + it('throws', () => { + expect(() => { + jsUrl.deserialize('some random string') + }).toThrow('JSURL: Error while parsing URL') + }) +}) diff --git a/src/components/Main/state/serialization/jsUrl.ts b/src/components/Main/state/serialization/jsUrl.ts new file mode 100644 index 000000000..2c7e1dba3 --- /dev/null +++ b/src/components/Main/state/serialization/jsUrl.ts @@ -0,0 +1,66 @@ +import JSURL from 'jsurl' +import type { AllParams } from '../../../../algorithms/types/Param.types' + +const serialize = (params: AllParams) => { + const simulationTimeRangeOriginal = params.simulation.simulationTimeRange + const simulationTimeRangeConverted = { + tMin: simulationTimeRangeOriginal.tMin.getTime(), + tMax: simulationTimeRangeOriginal.tMax.getTime(), + } + + const containmentOriginal = params.containment + const containmentConverted = { + ...containmentOriginal, + reduction: containmentOriginal.reduction.map((item) => ({ + y: item.y, + t: item.t.getTime(), + })), + } + + const dto = { + ...params, + simulation: { + ...params.simulation, + simulationTimeRange: simulationTimeRangeConverted, + }, + containment: containmentConverted, + } + + return JSURL.stringify(dto) +} + +const deserialize = (queryString: string): AllParams => { + try { + const obj = JSURL.parse(queryString) + const simulationTimeRangeOriginal = obj.simulation.simulationTimeRange + const simulationTimeRangeConverted = { + tMin: new Date(simulationTimeRangeOriginal.tMin), + tMax: new Date(simulationTimeRangeOriginal.tMax), + } + + const containmentOriginal = obj.containment + const containmentConverted = { + ...containmentOriginal, + reduction: containmentOriginal.reduction.map((c: { t: number; y: number }) => ({ + y: c.y, + t: new Date(c.t), + })), + } + + return { + ...obj, + containment: containmentConverted, + simulation: { + ...obj.simulation, + simulationTimeRange: simulationTimeRangeConverted, + }, + } + } catch (error) { + throw new Error('JSURL: Error while parsing URL') + } +} + +export default { + serialize, + deserialize, +} diff --git a/src/types/jsurl-module.d.ts b/src/types/jsurl-module.d.ts new file mode 100644 index 000000000..054372cc3 --- /dev/null +++ b/src/types/jsurl-module.d.ts @@ -0,0 +1 @@ +declare module 'jsurl' diff --git a/yarn.lock b/yarn.lock index e20fbd7b7..dd4d91b44 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9471,6 +9471,11 @@ jsprim@^1.2.2: json-schema "0.2.3" verror "1.10.0" +jsurl@0.1.5: + version "0.1.5" + resolved "https://registry.yarnpkg.com/jsurl/-/jsurl-0.1.5.tgz#2a5c8741de39cacafc12f448908bf34e960dcee8" + integrity sha1-KlyHQd45ysr8EvRIkIvzTpYNzug= + jsx-ast-utils@^2.2.1, jsx-ast-utils@^2.2.3: version "2.2.3" resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-2.2.3.tgz#8a9364e402448a3ce7f14d357738310d9248054f"