From 5db8c63a2db84ff4f7e841a9d8a8cf98ff0113dc Mon Sep 17 00:00:00 2001 From: Ruben Aprikyan Date: Fri, 28 Oct 2022 18:17:06 +0400 Subject: [PATCH] [feat] Persist explorer state on Figures and Base Explorers (#2214) Added support to sync explorer state through url on core renderer and Figures Explorer --- CHANGELOG.md | 8 +- aim/web/ui/package-lock.json | 98 ++++++++++--- aim/web/ui/package.json | 2 + .../AutocompleteInput/AutocompleteInput.d.ts | 1 + .../AutocompleteInput/AutocompleteInput.tsx | 7 +- .../components/Explorer/Explorer.tsx | 4 +- .../components/QueryForm/QueryForm.tsx | 12 +- .../components/RangePanel/RangePanel.tsx | 44 ++---- .../components/RangePanel/helpers.ts | 33 +++++ .../BaseExplorer/getDefaultHydration.tsx | 2 + aim/web/ui/src/modules/BaseExplorer/index.tsx | 12 +- aim/web/ui/src/modules/BaseExplorer/types.ts | 6 + .../ui/src/modules/core/api/runsApi/types.ts | 4 +- .../core/engine/explorer-engine/index.ts | 125 +++++++++++----- .../core/engine/explorer/groupings/index.ts | 50 ++++++- .../src/modules/core/engine/explorer/index.ts | 48 ++++++- .../core/engine/explorer/state/controls.ts | 3 + .../src/modules/core/engine/pipeline/index.ts | 134 +++++++++++++++++- aim/web/ui/src/modules/core/engine/types.ts | 10 ++ .../core/engine/visualizations/controls.ts | 3 + .../core/engine/visualizations/index.ts | 126 +++++++++++++++- aim/web/ui/src/modules/core/pipeline/index.ts | 1 + .../modules/core/services/browserHistory.ts | 81 +++++++++++ .../core/utils/getQueryParamsFromState.ts | 26 ++++ .../modules/core/utils/getUrlSearchParam.ts | 7 + aim/web/ui/src/modules/core/utils/store.ts | 6 +- .../core/utils/updateUrlSearchParam.ts | 13 ++ .../ui/src/pages/FiguresExplorer/index.tsx | 3 +- .../ui/src/utils/getStateFromLocalStorage.ts | 9 ++ 29 files changed, 754 insertions(+), 124 deletions(-) create mode 100644 aim/web/ui/src/modules/core/services/browserHistory.ts create mode 100644 aim/web/ui/src/modules/core/utils/getQueryParamsFromState.ts create mode 100644 aim/web/ui/src/modules/core/utils/getUrlSearchParam.ts create mode 100644 aim/web/ui/src/modules/core/utils/updateUrlSearchParam.ts create mode 100644 aim/web/ui/src/utils/getStateFromLocalStorage.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 89d3f12e94..b1cf5be8bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,11 @@ # Changelog -- Support syntax error highlighting in Figures Explorer (KaroMourad) -- Fix issue with applying solid stroke styles on stroke badge in table (KaroMourad) -- Fix active runs indicators overlapping issue in LineChart (KaroMourad) - ## 3.14.2 +- Add support to sync explorer state through url on Base and Figures Explorers (rubenaprikyan) +- Add support to highlight syntax error in Figures Explorer (KaroMourad) +- Fix issue with applying solid stroke styles on stroke badge in table (KaroMourad) +- Fix active runs indicators overlapping issue in LineChart (KaroMourad) - Add support for text style formatting in the logs tab (VkoHov) - Fix "`TypeError: check()` keywords must be strings" for `Run.metrics()` method (alberttorosyan) - Fix run info API call error when tag color/description is None (alberttorosyan) diff --git a/aim/web/ui/package-lock.json b/aim/web/ui/package-lock.json index 073095e97f..5e913368ac 100755 --- a/aim/web/ui/package-lock.json +++ b/aim/web/ui/package-lock.json @@ -14,6 +14,7 @@ "@material-ui/icons": "^4.11.2", "@material-ui/lab": "^4.0.0-alpha.60", "@monaco-editor/react": "4.4.4", + "@types/history": "^5.0.0", "@types/marked": "^4.0.7", "@uiw/react-textarea-code-editor": "^1.4.14", "bs58check": "^2.1.2", @@ -25,6 +26,7 @@ "formik": "^2.2.9", "highcharts": "^9.3.1", "highcharts-react-official": "^3.1.0", + "history": "^5.3.0", "humanize-duration": "^3.27.0", "lodash-es": "^4.17.21", "marked": "^4.1.1", @@ -3971,10 +3973,13 @@ } }, "node_modules/@types/history": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.8.tgz", - "integrity": "sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA==", - "dev": true + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/history/-/history-5.0.0.tgz", + "integrity": "sha512-hy8b7Y1J8OGe6LbAjj3xniQrj3v6lsivCcrmf4TzSgPzLkhIeKgc5IZnT7ReIqmEuodjfO8EYAuoFvIrHi/+jQ==", + "deprecated": "This is a stub types definition. history provides its own type definitions, so you do not need this installed.", + "dependencies": { + "history": "*" + } }, "node_modules/@types/hoist-non-react-statics": { "version": "3.3.1", @@ -13251,11 +13256,11 @@ } }, "node_modules/history": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", - "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz", + "integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==", "dependencies": { - "@babel/runtime": "^7.1.2", + "@babel/runtime": "^7.7.6", "loose-envify": "^1.2.0", "resolve-pathname": "^3.0.0", "tiny-invariant": "^1.0.2", @@ -22438,6 +22443,32 @@ "react": ">=15" } }, + "node_modules/react-router-dom/node_modules/history": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", + "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "dependencies": { + "@babel/runtime": "^7.1.2", + "loose-envify": "^1.2.0", + "resolve-pathname": "^3.0.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0", + "value-equal": "^1.0.1" + } + }, + "node_modules/react-router/node_modules/history": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", + "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "dependencies": { + "@babel/runtime": "^7.1.2", + "loose-envify": "^1.2.0", + "resolve-pathname": "^3.0.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0", + "value-equal": "^1.0.1" + } + }, "node_modules/react-router/node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -32867,10 +32898,12 @@ } }, "@types/history": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.8.tgz", - "integrity": "sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA==", - "dev": true + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/history/-/history-5.0.0.tgz", + "integrity": "sha512-hy8b7Y1J8OGe6LbAjj3xniQrj3v6lsivCcrmf4TzSgPzLkhIeKgc5IZnT7ReIqmEuodjfO8EYAuoFvIrHi/+jQ==", + "requires": { + "history": "*" + } }, "@types/hoist-non-react-statics": { "version": "3.3.1", @@ -40263,16 +40296,11 @@ "requires": {} }, "history": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", - "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz", + "integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==", "requires": { - "@babel/runtime": "^7.1.2", - "loose-envify": "^1.2.0", - "resolve-pathname": "^3.0.0", - "tiny-invariant": "^1.0.2", - "tiny-warning": "^1.0.0", - "value-equal": "^1.0.1" + "@babel/runtime": "^7.7.6" } }, "hmac-drbg": { @@ -47477,6 +47505,19 @@ "tiny-warning": "^1.0.0" }, "dependencies": { + "history": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", + "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "requires": { + "@babel/runtime": "^7.1.2", + "loose-envify": "^1.2.0", + "resolve-pathname": "^3.0.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0", + "value-equal": "^1.0.1" + } + }, "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -47496,6 +47537,21 @@ "react-router": "5.2.1", "tiny-invariant": "^1.0.2", "tiny-warning": "^1.0.0" + }, + "dependencies": { + "history": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", + "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "requires": { + "@babel/runtime": "^7.1.2", + "loose-envify": "^1.2.0", + "resolve-pathname": "^3.0.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0", + "value-equal": "^1.0.1" + } + } } }, "react-scripts": { diff --git a/aim/web/ui/package.json b/aim/web/ui/package.json index e8f3bb99dc..3ca91f6786 100644 --- a/aim/web/ui/package.json +++ b/aim/web/ui/package.json @@ -8,6 +8,7 @@ "@material-ui/icons": "^4.11.2", "@material-ui/lab": "^4.0.0-alpha.60", "@monaco-editor/react": "4.4.4", + "@types/history": "^5.0.0", "@types/marked": "^4.0.7", "@uiw/react-textarea-code-editor": "^1.4.14", "bs58check": "^2.1.2", @@ -19,6 +20,7 @@ "formik": "^2.2.9", "highcharts": "^9.3.1", "highcharts-react-official": "^3.1.0", + "history": "^5.3.0", "humanize-duration": "^3.27.0", "lodash-es": "^4.17.21", "marked": "^4.1.1", diff --git a/aim/web/ui/src/components/AutocompleteInput/AutocompleteInput.d.ts b/aim/web/ui/src/components/AutocompleteInput/AutocompleteInput.d.ts index e814d10847..1e7c1890d5 100644 --- a/aim/web/ui/src/components/AutocompleteInput/AutocompleteInput.d.ts +++ b/aim/web/ui/src/components/AutocompleteInput/AutocompleteInput.d.ts @@ -20,4 +20,5 @@ export interface IAutocompleteInputProps { ev?: monaco.editor.IModelContentChangedEvent, ) => void; error?: ISyntaxErrorDetails; + forceRemoveError?: boolean; } diff --git a/aim/web/ui/src/components/AutocompleteInput/AutocompleteInput.tsx b/aim/web/ui/src/components/AutocompleteInput/AutocompleteInput.tsx index f7ad4e60c5..fd3115ab33 100644 --- a/aim/web/ui/src/components/AutocompleteInput/AutocompleteInput.tsx +++ b/aim/web/ui/src/components/AutocompleteInput/AutocompleteInput.tsx @@ -25,6 +25,7 @@ function AutocompleteInput({ refObject, error, disabled = false, + forceRemoveError = false, //callback functions onEnter, onChange, @@ -71,8 +72,12 @@ function AutocompleteInput({ React.useEffect(() => { setMarkers(); + if (forceRemoveError && !error) { + setErrorMessage(''); + deleteMarkers(); + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [error, monaco]); + }, [error, monaco, forceRemoveError]); React.useEffect(() => { if (focused) { diff --git a/aim/web/ui/src/modules/BaseExplorer/components/Explorer/Explorer.tsx b/aim/web/ui/src/modules/BaseExplorer/components/Explorer/Explorer.tsx index 74e1613698..e82d7eb3fc 100644 --- a/aim/web/ui/src/modules/BaseExplorer/components/Explorer/Explorer.tsx +++ b/aim/web/ui/src/modules/BaseExplorer/components/Explorer/Explorer.tsx @@ -12,9 +12,9 @@ function Explorer({ configuration, engineInstance }: ExplorerProps) { ); useEffect(() => { - engineInstance.initialize().then().catch(); + const finalize = engineInstance.initialize(); return () => { - engineInstance.finalize(); + finalize(); }; }, [engineInstance]); diff --git a/aim/web/ui/src/modules/BaseExplorer/components/QueryForm/QueryForm.tsx b/aim/web/ui/src/modules/BaseExplorer/components/QueryForm/QueryForm.tsx index 3d1fa5a1ef..b60d57eed4 100644 --- a/aim/web/ui/src/modules/BaseExplorer/components/QueryForm/QueryForm.tsx +++ b/aim/web/ui/src/modules/BaseExplorer/components/QueryForm/QueryForm.tsx @@ -22,6 +22,7 @@ import { QueryFormState, QueryRangesState, } from 'modules/core/engine/explorer/query'; +import getQueryParamsFromState from 'modules/core/utils/getQueryParamsFromState'; import { Badge, Button, Icon, Text } from 'components/kit'; import AutocompleteInput from 'components/AutocompleteInput'; @@ -120,9 +121,14 @@ function QueryForm(props: Omit) { return; } else { engine.pipeline.search({ - q: getQueryStringFromSelect(query, sequenceName), + ...getQueryParamsFromState( + { + form: query, + ranges, + }, + sequenceName, + ), report_progress: true, - ...getQueryFromRanges(ranges), }); } }, [engine, isExecuting, query, sequenceName, ranges]); @@ -259,6 +265,7 @@ function QueryForm(props: Omit) { onChange={onInputChange} onEnter={onSubmit} error={processedError} + forceRemoveError={true} /> @@ -392,6 +399,7 @@ function QueryForm(props: Omit) { context={autocompleteContext.suggestions} onEnter={onSubmit} error={processedError} + forceRemoveError={true} /> )} diff --git a/aim/web/ui/src/modules/BaseExplorer/components/RangePanel/RangePanel.tsx b/aim/web/ui/src/modules/BaseExplorer/components/RangePanel/RangePanel.tsx index 0abb13a159..65c7041563 100644 --- a/aim/web/ui/src/modules/BaseExplorer/components/RangePanel/RangePanel.tsx +++ b/aim/web/ui/src/modules/BaseExplorer/components/RangePanel/RangePanel.tsx @@ -1,14 +1,13 @@ import React from 'react'; import { QueryFormState } from 'modules/core/engine/explorer/query/state'; -import { getQueryFromRanges } from 'modules/core/utils/getQueryFromRanges'; -import { getQueryStringFromSelect } from 'modules/core/utils/getQueryStringFromSelect'; +import getQueryParamsFromState from 'modules/core/utils/getQueryParamsFromState'; import { Button, Icon, Text } from 'components/kit'; import { SequenceTypesEnum } from 'types/core/enums'; -import { getRangeAndDensityData } from './helpers'; +import { getRecordState } from './helpers'; import RangePanelItem from './RangePanelItem'; import { IRangePanelProps } from './'; @@ -35,9 +34,14 @@ function RangePanel(props: IRangePanelProps) { isApplyButtonDisabled: true, }); engine.pipeline.search({ - q: getQueryStringFromSelect(query, sequenceName), + ...getQueryParamsFromState( + { + ranges: rangeState, + form: query, + }, + sequenceName, + ), report_progress: true, - ...getQueryFromRanges(rangeState), }); } }, [engine, isFetching, query, sequenceName, rangeState]); @@ -67,35 +71,7 @@ function RangePanel(props: IRangePanelProps) { React.useEffect(() => { // creating the empty ranges state - const updatedRangesState: { - record?: { slice: [number, number]; density: number }; - index?: { slice: [number, number]; density: number }; - } = {}; - - // checking is record data exist - if (props.rangesData?.ranges?.record_range_total) { - const { record_range_used, record_range_total } = - props.rangesData?.ranges; - - // setting record range slice and density - updatedRangesState.record = getRangeAndDensityData( - record_range_total, - record_range_used, - rangeState.record?.density ?? 50, - ); - } - - // checking is index data exist - if (props.rangesData?.ranges?.index_range_total) { - const { index_range_total, index_range_used } = props.rangesData?.ranges; - - // setting index range slice and density - updatedRangesState.index = getRangeAndDensityData( - index_range_total, - index_range_used, - rangeState.index?.density ?? 5, - ); - } + const updatedRangesState = getRecordState(props.rangesData, rangeState); //updating the ranges data and setting the apply button disability engine.query.ranges.update({ diff --git a/aim/web/ui/src/modules/BaseExplorer/components/RangePanel/helpers.ts b/aim/web/ui/src/modules/BaseExplorer/components/RangePanel/helpers.ts index c65a0987c0..4e5d4836fc 100644 --- a/aim/web/ui/src/modules/BaseExplorer/components/RangePanel/helpers.ts +++ b/aim/web/ui/src/modules/BaseExplorer/components/RangePanel/helpers.ts @@ -32,3 +32,36 @@ export function getRangeAndDensityData( return { density, slice }; } + +export function getRecordState(rangesData: any, rangeState: any) { + const updatedRangesState: { + record?: { slice: [number, number]; density: number }; + index?: { slice: [number, number]; density: number }; + } = {}; + + // checking is record data exist + if (rangesData?.ranges?.record_range_total) { + const { record_range_used, record_range_total } = rangesData?.ranges; + + // setting record range slice and density + updatedRangesState.record = getRangeAndDensityData( + record_range_total, + record_range_used, + rangeState.record?.density ?? 50, + ); + } + + // checking is index data exist + if (rangesData?.ranges?.index_range_total) { + const { index_range_total, index_range_used } = rangesData?.ranges; + + // setting index range slice and density + updatedRangesState.index = getRangeAndDensityData( + index_range_total, + index_range_used, + rangeState.index?.density ?? 5, + ); + } + + return updatedRangesState; +} diff --git a/aim/web/ui/src/modules/BaseExplorer/getDefaultHydration.tsx b/aim/web/ui/src/modules/BaseExplorer/getDefaultHydration.tsx index bfd314e98c..53abfa979b 100644 --- a/aim/web/ui/src/modules/BaseExplorer/getDefaultHydration.tsx +++ b/aim/web/ui/src/modules/BaseExplorer/getDefaultHydration.tsx @@ -38,6 +38,7 @@ const controls: ControlsConfigs = { displayBoxCaption: true, selectedFields: ['run.name', 'figures.name', 'figures.context'], }, + persist: 'url', }, }, }; @@ -125,6 +126,7 @@ const defaultHydration = { documentationLink: 'https://aimstack.readthedocs.io/en/latest/ui/pages/explorers.html', box: { + persist: true, initialState: { width: 400, height: 400, diff --git a/aim/web/ui/src/modules/BaseExplorer/index.tsx b/aim/web/ui/src/modules/BaseExplorer/index.tsx index 59e297802d..785c6130bd 100644 --- a/aim/web/ui/src/modules/BaseExplorer/index.tsx +++ b/aim/web/ui/src/modules/BaseExplorer/index.tsx @@ -50,6 +50,9 @@ function createExplorer( initialState: viz.box.initialState || defaultHydration.box.initialState, component: viz.box.component, + persist: viz.box.hasOwnProperty('persist') + ? viz.box.persist + : defaultHydration.box.persist, }, }; return acc; @@ -61,8 +64,6 @@ function createExplorer( ...configuration, documentationLink: configuration.documentationLink || defaultHydration.documentationLink, - basePath: - configuration.basePath || createBasePathFromName(configuration.name), components: { groupingContainer: components?.groupingContainer || defaultHydration.Groupings, @@ -77,9 +78,14 @@ function createExplorer( enablePipelineCache: configuration.enablePipelineCache || true, }; + const basePath = + configuration.basePath || createBasePathFromName(configuration.name); + const engineName = createBasePathFromName(configuration.name); + const engine = createEngine( hydration, - configuration.basePath, + basePath, + engineName, devtool, ); diff --git a/aim/web/ui/src/modules/BaseExplorer/types.ts b/aim/web/ui/src/modules/BaseExplorer/types.ts index 8e19cda73b..4e8756f202 100644 --- a/aim/web/ui/src/modules/BaseExplorer/types.ts +++ b/aim/web/ui/src/modules/BaseExplorer/types.ts @@ -9,6 +9,7 @@ import { AimObjectDepths, SequenceTypesEnum } from 'types/core/enums'; import { VisualizationsConfig } from '../core/engine/visualizations'; import { EngineNew } from '../core/engine/explorer-engine'; +import { StatePersistOption } from '../core/engine/types'; export interface IEngineStates { [key: string]: { @@ -103,6 +104,11 @@ export interface IBaseComponentProps { } export declare interface ExplorerEngineConfiguration { + /** + * @optional + * Useful when it need to persist query and grouping states through url + */ + persist?: boolean; // TODO later use StatePersistOption; /** * Enable/disable pipeline cache * @optional diff --git a/aim/web/ui/src/modules/core/api/runsApi/types.ts b/aim/web/ui/src/modules/core/api/runsApi/types.ts index 4032901129..4a92c89890 100644 --- a/aim/web/ui/src/modules/core/api/runsApi/types.ts +++ b/aim/web/ui/src/modules/core/api/runsApi/types.ts @@ -36,12 +36,12 @@ export type RunsSearchQueryParams = { /** * This parameter is used to for simple sampling, indicates how many steps want to load including their objects */ - record_density?: number; + record_density?: number | string; /** * This parameter is used to for simple sampling, indicates how many objects want to load */ - index_density?: number; + index_density?: number | string; exclude_params?: boolean; exclude_traces?: boolean; diff --git a/aim/web/ui/src/modules/core/engine/explorer-engine/index.ts b/aim/web/ui/src/modules/core/engine/explorer-engine/index.ts index fb7bc72ec5..66e4f1e214 100644 --- a/aim/web/ui/src/modules/core/engine/explorer-engine/index.ts +++ b/aim/web/ui/src/modules/core/engine/explorer-engine/index.ts @@ -1,9 +1,13 @@ import createReact, { StoreApi, UseBoundStore } from 'zustand'; +import { Update } from 'history'; import createVanilla from 'zustand/vanilla'; -import { devtools } from 'zustand/middleware'; +import { devtools, subscribeWithSelector } from 'zustand/middleware'; import { PipelineOptions } from 'modules/core/pipeline'; import { ExplorerEngineConfiguration } from 'modules/BaseExplorer/types'; +import getUrlSearchParam from 'modules/core/utils/getUrlSearchParam'; +import browserHistory from 'modules/core/services/browserHistory'; +import getQueryParamsFromState from 'modules/core/utils/getQueryParamsFromState'; import { AimFlatObjectBase } from 'types/core/AimObjects'; import { SequenceTypesEnum } from 'types/core/enums'; @@ -33,7 +37,7 @@ export type EngineNew< visualizations: any; // methods - initialize: () => Promise; + initialize: () => () => void; finalize: () => void; // store helpers @@ -71,6 +75,7 @@ function getPipelineEngine( query: { useCache, }, + persist: config.persist, }; const pipeline = createPipelineEngine>( @@ -105,11 +110,16 @@ function getExplorerAdditionalEngines( set: any, get: any, // state: State, // mutable + persist?: boolean, //StatePersistOption, ) { - return createExplorerAdditionalEngine(config, { - setState: set, - getState: get, - }); + return createExplorerAdditionalEngine( + config, + { + setState: set, + getState: get, + }, + persist, + ); } function getVisualizationsEngine( @@ -133,6 +143,7 @@ function getVisualizationsEngine( function createEngine( config: ExplorerEngineConfiguration, + basePath: string, name: string = 'ExplorerEngine', devtool: boolean = false, ): EngineNew, typeof config.sequenceName> { @@ -147,10 +158,8 @@ function createEngine( let customStatesEngine: CustomStatesEngine; let query: any; let groupings: any; - + let initialState = {}; function buildEngine(set: any, get: any) { - let state = {}; - /** * Custom states */ @@ -162,8 +171,8 @@ function createEngine( config.states, ); - state = { - ...state, + initialState = { + ...initialState, ...customStates.state.initialState, }; customStatesEngine = customStates.engine; @@ -171,9 +180,14 @@ function createEngine( /** * Explorer Additional, includes query and groupings */ - const explorer = getExplorerAdditionalEngines(config, set, get); - state = { - ...state, + const explorer = getExplorerAdditionalEngines( + config, + set, + get, + config.persist, + ); + initialState = { + ...initialState, ...explorer.initialState, }; @@ -183,17 +197,17 @@ function createEngine( /** * Instructions */ - instructions = getInstructionsEngine(config, set, get, state); + instructions = getInstructionsEngine(config, set, get, initialState); /** * Pipeline */ - pipeline = getPipelineEngine(config, set, get, state); + pipeline = getPipelineEngine(config, set, get, initialState); /* * Visualizations */ - visualizations = getVisualizationsEngine(config, set, get, state); + visualizations = getVisualizationsEngine(config, set, get, initialState); /** Additional **/ @@ -206,20 +220,22 @@ function createEngine( /** * @TODO add events service engine here */ - return state; + return initialState; } // @ts-ignore const store = createVanilla>( // @ts-ignore - devtool - ? // @ts-ignore - devtools(buildEngine, { - name, - anonymousActionType: 'UNKNOWN_ACTION', - serialize: { options: true }, - }) - : buildEngine, + subscribeWithSelector( + devtool + ? // @ts-ignore + devtools(buildEngine, { + name, + anonymousActionType: 'UNKNOWN_ACTION', + serialize: { options: true }, + }) + : buildEngine, + ), ); // @ts-ignore @@ -227,21 +243,51 @@ function createEngine( /* * An initializer to use for url sync and bookmarks data get */ - function initialize(): Promise { + function initialize(): () => void { + const finalizeQuery = query.initialize(); + const finalizeGrouping = groupings.initialize(); + const finalizePipeline = pipeline.initialize(); + const finalizeVisualizations = visualizations.initialize(name); + // subscribe to history - return new Promise((resolve, reject) => { - instructions - .getInstructions() - .then((isEmpty) => { - if (isEmpty) { - pipeline.changeCurrentPhaseOrStatus( - PipelineStatusEnum.Insufficient_Resources, + instructions + .getInstructions() + .then((isEmpty) => { + if (isEmpty) { + pipeline.changeCurrentPhaseOrStatus( + PipelineStatusEnum.Insufficient_Resources, + ); + } else if (config.persist) { + const stateFromStorage = getUrlSearchParam('query') || {}; + if (stateFromStorage.form && stateFromStorage.ranges) { + pipeline.search( + getQueryParamsFromState(stateFromStorage, config.sequenceName), + true, ); } - }) - // eslint-disable-next-line no-console - .catch((err) => console.error(err)); - }); + } + }) + // eslint-disable-next-line no-console + .catch((err) => console.error(err)); + + const removeHistoryListener = + config.persist && + browserHistory.listen((update: Update) => { + localStorage.setItem( + 'figuresUrl', + update.location.pathname + update.location.search, + ); + }); + + return () => { + finalizeQuery(); + finalizeGrouping(); + finalizePipeline(); + finalizeVisualizations(); + removeHistoryListener && removeHistoryListener(); + + finalize(); + }; } /** * Clean ups @@ -252,7 +298,8 @@ function createEngine( function finalize() { // @ts-ignore useReactStore.destroy(); // or engine.release/commit - pipeline.destroy(); // or pipeline release/commit + useReactStore.setState(initialState); + // pipeline.destroy(); // or pipeline release/commit } // @ts-ignore diff --git a/aim/web/ui/src/modules/core/engine/explorer/groupings/index.ts b/aim/web/ui/src/modules/core/engine/explorer/groupings/index.ts index 446b881e80..f801ad6db8 100644 --- a/aim/web/ui/src/modules/core/engine/explorer/groupings/index.ts +++ b/aim/web/ui/src/modules/core/engine/explorer/groupings/index.ts @@ -1,7 +1,9 @@ -import { omit } from 'lodash-es'; +import { isEmpty, omit } from 'lodash-es'; +import browserHistory from 'modules/core/services/browserHistory'; import { Order } from 'modules/core/pipeline'; import { createSliceState } from 'modules/core/utils/store'; +import getUrlSearchParam from 'modules/core/utils/getUrlSearchParam'; import createGroupingsSlice from './state'; @@ -86,7 +88,13 @@ export type GroupingConfigs = Record< Omit, 'name'> >; -function createGroupingsEngine(config: GroupingConfigs, store: any) { +type GroupValues = Record; + +function createGroupingsEngine( + config: GroupingConfigs, + store: any, + persist?: boolean, // TODO later use StatePersistOption, +) { const groupingSliceConfig: Record = {}; Object.keys(config).forEach((name: string) => { @@ -120,20 +128,56 @@ function createGroupingsEngine(config: GroupingConfigs, store: any) { {}, ); + function update(groupValues: GroupValues) { + methods.update(groupValues); + } + function resetSlices() { slicesResetMethods.forEach((func) => { func(); }); } + function initialize() { + if (persist) { + const stateFromStorage = getUrlSearchParam('groupings') || {}; + + // update state + if (!isEmpty(stateFromStorage)) { + methods.update(stateFromStorage); + } + const removeGroupingListener = + browserHistory.listenSearchParam( + 'groupings', + (data: GroupValues | null) => { + // update state + if (!isEmpty(data)) { + methods.update(data as GroupValues); + } else { + methods.reset(); + } + }, + ['PUSH'], + ); + + return () => { + removeGroupingListener(); + }; + } + + return () => {}; + } + return { state: { groupings: state.initialState }, engine: { ...omit(state, ['initialState', 'generateMethods', 'slices']), - ...methods, + reset: methods.reset, + update, ...slices, resetSlices, styleAppliers, + initialize, }, }; } diff --git a/aim/web/ui/src/modules/core/engine/explorer/index.ts b/aim/web/ui/src/modules/core/engine/explorer/index.ts index a08b0f7bfb..d0b1cf00bd 100644 --- a/aim/web/ui/src/modules/core/engine/explorer/index.ts +++ b/aim/web/ui/src/modules/core/engine/explorer/index.ts @@ -1,14 +1,56 @@ +import { isEmpty } from 'lodash-es'; + import { ExplorerEngineConfiguration } from 'modules/BaseExplorer/types'; +import getUrlSearchParam from 'modules/core/utils/getUrlSearchParam'; +import browserHistory from 'modules/core/services/browserHistory'; -import createQueryState from './query'; +import createQueryState, { QueryState } from './query'; import createGroupingsEngine from './groupings'; function createExplorerAdditionalEngine( config: ExplorerEngineConfiguration, store: any, + persist?: boolean, // TODO later use StatePersistOption, ) { - const query = createQueryState(store); - const groupings = createGroupingsEngine(config.groupings || {}, store); + const queryState = createQueryState(store); + const query = { + ...queryState, + initialize: () => { + if (persist) { + const stateFromStorage = getUrlSearchParam('query') || {}; + + // update state + if (!isEmpty(stateFromStorage)) { + query.ranges.update(stateFromStorage.ranges); + query.form.update(stateFromStorage.form); + } + + const removeSearchParamListener = browserHistory.listenSearchParam( + 'query', + (query: QueryState) => { + if (!isEmpty(query)) { + queryState.ranges.update(query.ranges); + queryState.form.update(query.form); + } else { + queryState.reset(); + } + }, + ['PUSH'], + ); + + return () => { + removeSearchParamListener(); + }; + } + + return () => {}; + }, + }; + const groupings = createGroupingsEngine( + config.groupings || {}, + store, + persist, + ); const initialState = { query: { diff --git a/aim/web/ui/src/modules/core/engine/explorer/state/controls.ts b/aim/web/ui/src/modules/core/engine/explorer/state/controls.ts index da231cb65f..493ca3503f 100644 --- a/aim/web/ui/src/modules/core/engine/explorer/state/controls.ts +++ b/aim/web/ui/src/modules/core/engine/explorer/state/controls.ts @@ -2,6 +2,8 @@ import { omit } from 'lodash-es'; import { createSliceState } from 'modules/core/utils/store'; +import { StatePersistOption } from '../../types'; + export type ControlConfig = { /** * Name of control, using as unique key of control @@ -14,6 +16,7 @@ export type ControlConfig = { */ state?: { initialState: State; + persist?: StatePersistOption; }; /** * Static settings, i.e. diff --git a/aim/web/ui/src/modules/core/engine/pipeline/index.ts b/aim/web/ui/src/modules/core/engine/pipeline/index.ts index 9e5e8e3c0e..7c4aad4081 100644 --- a/aim/web/ui/src/modules/core/engine/pipeline/index.ts +++ b/aim/web/ui/src/modules/core/engine/pipeline/index.ts @@ -1,4 +1,4 @@ -import { isEmpty, omit } from 'lodash-es'; +import { isEmpty, isEqual, omit } from 'lodash-es'; import { IRunProgressObject, @@ -10,10 +10,17 @@ import createPipeline, { PipelineOptions, PipelinePhasesEnum, } from 'modules/core/pipeline'; +import getUrlSearchParam from 'modules/core/utils/getUrlSearchParam'; +import updateUrlSearchParam from 'modules/core/utils/updateUrlSearchParam'; +import browserHistory from 'modules/core/services/browserHistory'; +import getQueryParamsFromState from 'modules/core/utils/getQueryParamsFromState'; import { SequenceTypesEnum } from 'types/core/enums'; +import { encode } from 'utils/encoder/encoder'; + import { PipelineStatusEnum, ProgressState } from '../types'; +import { QueryState } from '../explorer/query'; import createState, { CurrentGrouping, @@ -27,11 +34,12 @@ export interface IPipelineEngine { pipeline: IPipelineState; }; engine: { - search: (params: RunsSearchQueryParams) => void; - group: (config: CurrentGrouping) => void; + search: (params: RunsSearchQueryParams, isInternal?: boolean) => void; + group: (config: CurrentGrouping, isInternal?: boolean) => void; getSequenceName: () => SequenceTypesEnum; destroy: () => void; reset: () => void; + initialize: () => () => void; } & Omit, 'selectors'> & PipelineStateBridge['selectors']; } @@ -108,11 +116,38 @@ function createPipelineEngine( * @example * pipeline.engine.search({ q: "run.hparams.batch_size>32"}) * @param {RunsSearchQueryParams} params + * @param isInternal - indicates does it need to update current query or not */ - function search(params: RunsSearchQueryParams): void { + function search( + params: RunsSearchQueryParams, + isInternal: boolean = false, + ): void { const currentGroupings = state.getCurrentGroupings(); state.setCurrentQuery(params); + state.setError(null); + + if (!isInternal && pipelineOptions.persist) { + const queryState = store.getState().query; + + if (!queryState.ranges.isInitial) { + const url = updateUrlSearchParam( + 'query', + encode({ + ...queryState, + ranges: { + ...queryState.ranges, + isApplyButtonDisabled: true, + }, + }), + ); + + // TODO move check into custom push method + if (url !== `${window.location.pathname}${window.location.search}`) { + browserHistory.push(url, null); + } + } + } const groupOptions = Object.keys(currentGroupings).map((key: string) => ({ type: key as GroupType, @@ -138,6 +173,22 @@ function createPipelineEngine( state.changeCurrentPhaseOrStatus( isEmpty(data) ? PipelineStatusEnum.Empty : state.getStatus(), ); + + if (!isInternal && pipelineOptions.persist) { + const url = updateUrlSearchParam( + 'query', + encode({ + ...store.getState().query, + ranges: { + ...store.getState().query.ranges, + isApplyButtonDisabled: true, + }, + }), + ); + if (url !== `${window.location.pathname}${window.location.search}`) { + browserHistory.push(url, null); + } + } }) .catch((err) => { state.setError(err); @@ -174,10 +225,24 @@ function createPipelineEngine( * @example * pipeline.engine.group(config) * @param {CurrentGrouping} config + * @param {boolean} isInternal - indicates called internally or from UI, if isInternal doesnt need to update current query */ - function group(config: CurrentGrouping): void { + function group(config: CurrentGrouping, isInternal: boolean = false): void { state.setCurrentGroupings(config); + const equal = isEqual(config, defaultGroupings); + + if (!isInternal && pipelineOptions.persist) { + const url = updateUrlSearchParam( + 'groupings', + equal ? null : encode(config), + ); + + if (url !== `${window.location.pathname}${window.location.search}`) { + browserHistory.push(url, null); + } + } + pipeline .execute({ query: { @@ -195,6 +260,59 @@ function createPipelineEngine( group(defaultGroupings); } + function initialize() { + if (pipelineOptions.persist) { + const stateFromStorage = getUrlSearchParam('groupings') || {}; + // update state + if (!isEmpty(stateFromStorage)) { + state.setCurrentGroupings(stateFromStorage); + } + + const removeGroupingsListener = browserHistory.listenSearchParam( + 'groupings', + (groupings: any) => { + if (!isEmpty(groupings)) { + group(groupings, true); + } else { + group(defaultGroupings, true); + } + }, + ['PUSH'], + ); + + const removeQueryListener = + browserHistory.listenSearchParam( + 'query', + (query: QueryState | null) => { + if (!isEmpty(query)) { + search( + { + ...getQueryParamsFromState( + query as QueryState, + options.sequenceName, + ), + report_progress: true, + }, + true, + ); + } else { + search({ q: '()', report_progress: true }, true); + } + }, + ['PUSH'], + ); + return () => { + removeGroupingsListener(); + removeQueryListener(); + // pipeline.clearCache(); + }; + } + + return () => { + // pipeline.clearCache(); + }; + } + return { state: { pipeline: state.initialState, @@ -206,8 +324,12 @@ function createPipelineEngine( search, group, reset, + initialize, destroy: () => { - pipeline.clearCache(); + /** + * This line creates some bugs right now, use this after creating complete clean-up mechanism for resources + */ + // pipeline.clearCache(); }, }, }; diff --git a/aim/web/ui/src/modules/core/engine/types.ts b/aim/web/ui/src/modules/core/engine/types.ts index 5e650d36de..aa6ec0480b 100644 --- a/aim/web/ui/src/modules/core/engine/types.ts +++ b/aim/web/ui/src/modules/core/engine/types.ts @@ -116,3 +116,13 @@ export interface BaseErrorType { export interface PipelineErrorType extends BaseErrorType { source: string; } + +export type PersistenceFunction = () => void; +export enum PersistenceTypesEnum { + Url = 'url', + LocalStorage = 'localStorage', +} + +export type StatePersistOption = + | `${PersistenceTypesEnum}` + | PersistenceFunction; diff --git a/aim/web/ui/src/modules/core/engine/visualizations/controls.ts b/aim/web/ui/src/modules/core/engine/visualizations/controls.ts index ea7f71d715..5d28e8abcf 100644 --- a/aim/web/ui/src/modules/core/engine/visualizations/controls.ts +++ b/aim/web/ui/src/modules/core/engine/visualizations/controls.ts @@ -2,6 +2,8 @@ import { omit } from 'lodash-es'; import { createSliceState } from 'modules/core/utils/store'; +import { StatePersistOption } from '../types'; + export type ControlConfig = { /** * Name of control, using as unique key of control @@ -14,6 +16,7 @@ export type ControlConfig = { */ state?: { initialState: State; + persist?: StatePersistOption; }; /** * Static settings, i.e. diff --git a/aim/web/ui/src/modules/core/engine/visualizations/index.ts b/aim/web/ui/src/modules/core/engine/visualizations/index.ts index 716b640849..8e8483bcaa 100644 --- a/aim/web/ui/src/modules/core/engine/visualizations/index.ts +++ b/aim/web/ui/src/modules/core/engine/visualizations/index.ts @@ -1,6 +1,6 @@ import type { FunctionComponent } from 'react'; import { StoreApi } from 'zustand'; -import { omit } from 'lodash-es'; +import { isEmpty, omit } from 'lodash-es'; import { IBoxProps, @@ -9,12 +9,20 @@ import { IVisualizationProps, } from 'modules/BaseExplorer/types'; import { createSliceState } from 'modules/core/utils/store'; +import updateUrlSearchParam from 'modules/core/utils/updateUrlSearchParam'; +import browserHistory from 'modules/core/services/browserHistory'; +import getUrlSearchParam from 'modules/core/utils/getUrlSearchParam'; + +import getStateFromLocalStorage from 'utils/getStateFromLocalStorage'; +import { encode } from 'utils/encoder/encoder'; import { ControlsConfigs } from '../explorer/state/controls'; +import { PersistenceTypesEnum } from '../types'; import { createControlsStateConfig } from './controls'; type BoxConfig = { + persist?: boolean; // TODO later use StatePersistTypesEnum initialState: { width: number; height: number; @@ -91,7 +99,7 @@ function createVisualizationEngine( ) { const controlsState = createState(store, visualizationName, config.controls); - const boxConfigState = createSliceState( + const boxConfigState = createSliceState( config.box.initialState, `${createVisualizationStatePrefix(visualizationName)}.box`, ); @@ -104,6 +112,8 @@ function createVisualizationEngine( }; const boxMethods = boxConfigState.methods(store.setState, store.getState); + const customControlResets: CallableFunction[] = []; + const engine = { [visualizationName]: { controls: { @@ -118,6 +128,104 @@ function createVisualizationEngine( boxMethods.reset(); // @ts-ignore controlsState.reset(); + + customControlResets.forEach((func) => func()); + }, + + initialize: (keyNamePrefix: string = 'core-viz') => { + const funcs: CallableFunction[] = []; + Object.keys(config.controls).forEach((key: string) => { + const control = config.controls[key]; + const persistenceType = control?.state?.persist; + const persistenceKey = [visualizationName, 'c', key].join('-'); + if (persistenceType) { + if (persistenceType === PersistenceTypesEnum.Url) { + const originalMethods = + // @ts-ignore + { ...controlsState.properties[key].methods }; + const stateFromStorage = getUrlSearchParam(persistenceKey) || {}; + + // update state + if (!isEmpty(stateFromStorage)) { + originalMethods.update(stateFromStorage); + } + // @ts-ignore + controlsState.properties[key].methods.update = (d: any) => { + originalMethods.update(d); + + const url = updateUrlSearchParam(persistenceKey, encode(d)); + + if ( + url !== `${window.location.pathname}${window.location.search}` + ) { + browserHistory.push(url, null); + } + }; + + // @ts-ignore + controlsState.properties[key].methods.reset = () => { + originalMethods.reset(); + + const url = updateUrlSearchParam(persistenceKey, null); + + if ( + url !== `${window.location.pathname}${window.location.search}` + ) { + browserHistory.push(url, null); + } + }; + customControlResets.push( + // @ts-ignore + controlsState.properties[key].methods.reset, + ); + const removeListener = browserHistory.listenSearchParam( + persistenceKey, + (data: any) => { + if (isEmpty(data)) { + // @ts-ignore + originalMethods.reset(); + } else { + // @ts-ignore + originalMethods.update(data); + } + }, + ['PUSH'], + ); + + funcs.push(removeListener); + } + } + }); + + if (config.box.persist) { + const boxPersistenceKey = `${keyNamePrefix}.${createVisualizationStatePrefix( + visualizationName, + )}.box`; + + const boxStateFromStorage = + getStateFromLocalStorage(boxPersistenceKey); + const originalMethods = { ...boxMethods }; + + if (!isEmpty(boxStateFromStorage)) { + boxMethods.update(boxStateFromStorage); + } else { + boxMethods.reset(); + } + + boxMethods.reset = () => { + originalMethods.reset(); + localStorage.removeItem(boxPersistenceKey); + }; + + boxMethods.update = (newValue: Partial) => { + originalMethods.update(newValue); + localStorage.setItem(boxPersistenceKey, encode(newValue)); + }; + } + + return () => { + funcs.forEach((func) => func()); + }; }, }, }; @@ -173,12 +281,26 @@ function createVisualizationsEngine( func(); }); } + + function initialize(engineName: string = 'core-viz') { + const funcs: CallableFunction[] = []; + Object.values(obj.engine).forEach((e: any) => { + const func = e.initialize(engineName); + funcs.push(func); + }); + + return () => { + funcs.forEach((func) => func()); + }; + } + return { state: { [VISUALIZATIONS_STATE_PREFIX]: obj.state, }, engine: { ...obj.engine, + initialize, reset: resetVisualizationsState, }, }; diff --git a/aim/web/ui/src/modules/core/pipeline/index.ts b/aim/web/ui/src/modules/core/pipeline/index.ts index 28eae1fdfe..f24ab57bd4 100644 --- a/aim/web/ui/src/modules/core/pipeline/index.ts +++ b/aim/web/ui/src/modules/core/pipeline/index.ts @@ -30,6 +30,7 @@ export type PipelineOptions = { getLatestResult?: () => void; useCache?: boolean; }; + persist?: boolean; // Later use }; export type PipelineExecutionOptions = { diff --git a/aim/web/ui/src/modules/core/services/browserHistory.ts b/aim/web/ui/src/modules/core/services/browserHistory.ts new file mode 100644 index 0000000000..7e79ef176d --- /dev/null +++ b/aim/web/ui/src/modules/core/services/browserHistory.ts @@ -0,0 +1,81 @@ +import type { Action, History, Listener, Update } from 'history'; + +import history from 'history/browser'; + +import { decode } from 'utils/encoder/encoder'; + +type ActionType = `${Action}`; + +function createListener( + param: string, + listener: (data: T | null) => void, + ignoreActions: ActionType[] = [], +): Listener { + let latest: string | null = new URLSearchParams(history.location.search).get( + param, + ); + return (update: Update) => { + const searchParams = new URLSearchParams(update.location.search); + const current = searchParams.get(param); + + if (ignoreActions.length) { + if (ignoreActions.includes(update.action)) { + latest = current; + return; + } + } + + if (latest !== current) { + if (current) { + listener(JSON.parse(decode(current))); + } else { + listener(null); + } + latest = current; + } + }; +} + +function listenSearchParam( + param: string, + listener: (data: T | null) => void, + ignoreActions: ActionType[] = [], +) { + return history.listen(createListener(param, listener, ignoreActions)); +} + +declare interface BrowserHistory extends History { + /** + * function listenSearchParam + * @description Use function when you need to only listen to a specific url search param change + *
+   *     import browserHistory = 'modules/core/services/browserHistory';
+   *
+   *     const unregister = browserHistory.listenSearchParam('x', (data) => {
+   *         console.log(data);
+   *     }, ['PUSH']);
+   *
+   *
+   *     browserHistory.push(`?x=${encode({test: true})}`, null); // this will not trigger the update, since ignored PUSH action
+   *     browserHistory.back() // will call the listener and will pass `null` as data
+   *     browserHistory.forward() // will call the listener and will pass {test: true} as data
+   *
+   *     unregister() // remove listener
+   *  
+ * @param {String} param - search param to listen for changes + * @param {(data: T | null) => void} listener - callback to call when param changed + * @param {ActionType[]} ignoreActions - ignore actions ['PUSH', 'POP', 'REPLACE'] + */ + listenSearchParam( + param: string, + listener: (data: T | null) => void, + ignoreActions?: ActionType[], + ): () => void; +} + +const browserHistory: BrowserHistory = { + ...history, + listenSearchParam, +}; + +export default browserHistory; diff --git a/aim/web/ui/src/modules/core/utils/getQueryParamsFromState.ts b/aim/web/ui/src/modules/core/utils/getQueryParamsFromState.ts new file mode 100644 index 0000000000..0d0d766ac7 --- /dev/null +++ b/aim/web/ui/src/modules/core/utils/getQueryParamsFromState.ts @@ -0,0 +1,26 @@ +import { RunsSearchQueryParams } from 'modules/core/api/runsApi'; +import { QueryState } from 'modules/core/engine/explorer/query'; + +import { SequenceTypesEnum } from 'types/core/enums'; + +import { getQueryFromRanges } from './getQueryFromRanges'; +import { getQueryStringFromSelect } from './getQueryStringFromSelect'; + +/** + * function getQueryParamsFromState + * this function is useful to generate run search query params from state object + * @param {QueryState} stateObject - the state used for explorers over base + * @param {SequenceTypesEnum} sequenceName - the name of sequence + * @return {RunsSearchQueryParams} + */ +function getQueryParamsFromState( + stateObject: QueryState, + sequenceName: SequenceTypesEnum, +): RunsSearchQueryParams { + return { + q: getQueryStringFromSelect(stateObject.form, sequenceName), + ...getQueryFromRanges(stateObject.ranges), + }; +} + +export default getQueryParamsFromState; diff --git a/aim/web/ui/src/modules/core/utils/getUrlSearchParam.ts b/aim/web/ui/src/modules/core/utils/getUrlSearchParam.ts new file mode 100644 index 0000000000..2c8153d679 --- /dev/null +++ b/aim/web/ui/src/modules/core/utils/getUrlSearchParam.ts @@ -0,0 +1,7 @@ +import getStateFromUrl from 'utils/getStateFromUrl'; + +function getUrlSearchParam(paramName: string) { + return getStateFromUrl(paramName); +} + +export default getUrlSearchParam; diff --git a/aim/web/ui/src/modules/core/utils/store.ts b/aim/web/ui/src/modules/core/utils/store.ts index 428573fd2b..514ddcb9be 100644 --- a/aim/web/ui/src/modules/core/utils/store.ts +++ b/aim/web/ui/src/modules/core/utils/store.ts @@ -2,7 +2,10 @@ import { get as getValue, set as setValue } from 'lodash-es'; import produce, { Draft } from 'immer'; import { buildObjectHash } from 'modules/core/utils/hashing'; -import { GenerateStoreMethods } from 'modules/core/engine/types'; +import { + GenerateStoreMethods, + StatePersistOption, +} from 'modules/core/engine/types'; import { SliceMethods, StateSelector } from 'utils/store/createSlice'; @@ -91,5 +94,6 @@ export type PreCreatedStateSlice = { export type CustomStates = { [key: string]: { initialState: Record; + persist?: StatePersistOption; }; }; diff --git a/aim/web/ui/src/modules/core/utils/updateUrlSearchParam.ts b/aim/web/ui/src/modules/core/utils/updateUrlSearchParam.ts new file mode 100644 index 0000000000..453933aaee --- /dev/null +++ b/aim/web/ui/src/modules/core/utils/updateUrlSearchParam.ts @@ -0,0 +1,13 @@ +function updateUrlSearchParam(param: string, value: string | null) { + // @ts-ignore + const params = new URL(window.location).searchParams; + + if (!value) { + params.has(param) && params.delete(param); + } else { + params.set(param, value); + } + return `${window.location.pathname}?${params.toString()}`; +} + +export default updateUrlSearchParam; diff --git a/aim/web/ui/src/pages/FiguresExplorer/index.tsx b/aim/web/ui/src/pages/FiguresExplorer/index.tsx index 43c73e3e08..e0ee4ee49a 100644 --- a/aim/web/ui/src/pages/FiguresExplorer/index.tsx +++ b/aim/web/ui/src/pages/FiguresExplorer/index.tsx @@ -9,6 +9,7 @@ const defaultConfig = getDefaultHydration(); const FiguresExplorer = renderer( { + persist: true, sequenceName: SequenceTypesEnum.Figures, name: 'Figures Explorer', adapter: { @@ -26,7 +27,7 @@ const FiguresExplorer = renderer( }, }, }, - __DEV__, + true, ); export default FiguresExplorer; diff --git a/aim/web/ui/src/utils/getStateFromLocalStorage.ts b/aim/web/ui/src/utils/getStateFromLocalStorage.ts new file mode 100644 index 0000000000..66b763e5d9 --- /dev/null +++ b/aim/web/ui/src/utils/getStateFromLocalStorage.ts @@ -0,0 +1,9 @@ +import { decode } from './encoder/encoder'; + +export default function getStateFromLocalStorage(key: string) { + const data: any = localStorage.getItem(key); + if (data) { + return JSON.parse(decode(data)); + } + return null; +}