From 6dc108964d66b57d2433bc17769b624f399d8b8e Mon Sep 17 00:00:00 2001 From: "Qingyang(Abby) Hu" Date: Wed, 7 Dec 2022 11:14:15 -0800 Subject: [PATCH] [Vis Builder] Add global data persistence for vis builder (#2896) * Add global data persistence for vis builder Persist filters, time range, time refresh interval for vis builder when we refresh or navigate to other apps such as dashboard, discover, timeline and visualize Signed-off-by: abbyhu2000 Signed-off-by: David Sinclair --- CHANGELOG.md | 2 + .../vis_builder/public/application/app.tsx | 24 ++++- .../public/application/components/top_nav.tsx | 5 ++ .../application/utils/get_top_nav_config.tsx | 16 ++-- .../utils/state_management/metadata_slice.ts | 15 +++- src/plugins/vis_builder/public/plugin.test.ts | 1 + src/plugins/vis_builder/public/plugin.ts | 89 ++++++++++++++++--- .../vis_builder/public/plugin_services.ts | 4 + src/plugins/vis_builder/public/types.ts | 5 ++ 9 files changed, 140 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 728f558a12ef..fa6d9b80fcf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,8 +70,10 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Vis Builder] Fix empty workspace animation does not work in firefox ([#2853](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2853)) - Bumped `del` version to fix MacOS race condition ([#2847](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2873)) - [Build] Fixed "Last Access Time" not being set by `scanCopy` on Windows ([#2964](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2964)) +- [Vis Builder] Add global data persistence for vis builder #2896 ([#2896](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2896)) - Update `leaflet-vega` and fix its usage ([#3005](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3005)) + ### 🚞 Infrastructure - Add CHANGELOG.md and related workflows ([#2414](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2414)) diff --git a/src/plugins/vis_builder/public/application/app.tsx b/src/plugins/vis_builder/public/application/app.tsx index bd5f2be1feda..2bdc2b1c631b 100644 --- a/src/plugins/vis_builder/public/application/app.tsx +++ b/src/plugins/vis_builder/public/application/app.tsx @@ -3,17 +3,39 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; +import React, { useEffect } from 'react'; import { I18nProvider } from '@osd/i18n/react'; import { EuiPage, EuiResizableContainer } from '@elastic/eui'; +import { useLocation } from 'react-router-dom'; import { DragDropProvider } from './utils/drag_drop/drag_drop_context'; import { LeftNav } from './components/left_nav'; import { TopNav } from './components/top_nav'; import { Workspace } from './components/workspace'; import './app.scss'; import { RightNav } from './components/right_nav'; +import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; +import { VisBuilderServices } from '../types'; +import { syncQueryStateWithUrl } from '../../../data/public'; export const VisBuilderApp = () => { + const { + services: { + data: { query }, + osdUrlStateStorage, + }, + } = useOpenSearchDashboards(); + const { pathname } = useLocation(); + + useEffect(() => { + // syncs `_g` portion of url with query services + const { stop } = syncQueryStateWithUrl(query, osdUrlStateStorage); + + return () => stop(); + + // this effect should re-run when pathname is changed to preserve querystring part, + // so the global state is always preserved + }, [query, osdUrlStateStorage, pathname]); + // Render the application DOM. return ( diff --git a/src/plugins/vis_builder/public/application/components/top_nav.tsx b/src/plugins/vis_builder/public/application/components/top_nav.tsx index 62d3bb78cc52..fe9b735b5acf 100644 --- a/src/plugins/vis_builder/public/application/components/top_nav.tsx +++ b/src/plugins/vis_builder/public/application/components/top_nav.tsx @@ -36,6 +36,9 @@ export const TopNav = () => { const savedVisBuilderVis = useSavedVisBuilderVis(visualizationIdFromUrl); const { selected: indexPattern } = useIndexPatterns(); const [config, setConfig] = useState(); + const originatingApp = useTypedSelector((state) => { + return state.metadata.originatingApp; + }); useEffect(() => { const getConfig = () => { @@ -47,6 +50,7 @@ export const TopNav = () => { savedVisBuilderVis: saveStateToSavedObject(savedVisBuilderVis, rootState, indexPattern), saveDisabledReason, dispatch, + originatingApp, }, services ); @@ -61,6 +65,7 @@ export const TopNav = () => { saveDisabledReason, dispatch, indexPattern, + originatingApp, ]); // reset validity before component destroyed diff --git a/src/plugins/vis_builder/public/application/utils/get_top_nav_config.tsx b/src/plugins/vis_builder/public/application/utils/get_top_nav_config.tsx index c88bb13f3cb3..9df321822852 100644 --- a/src/plugins/vis_builder/public/application/utils/get_top_nav_config.tsx +++ b/src/plugins/vis_builder/public/application/utils/get_top_nav_config.tsx @@ -46,22 +46,24 @@ export interface TopNavConfigParams { savedVisBuilderVis: VisBuilderVisSavedObject; saveDisabledReason?: string; dispatch: AppDispatch; + originatingApp?: string; } export const getTopNavConfig = ( - { visualizationIdFromUrl, savedVisBuilderVis, saveDisabledReason, dispatch }: TopNavConfigParams, + { + visualizationIdFromUrl, + savedVisBuilderVis, + saveDisabledReason, + dispatch, + originatingApp, + }: TopNavConfigParams, services: VisBuilderServices ) => { const { i18n: { Context: I18nContext }, embeddable, - scopedHistory, } = services; - const { originatingApp, embeddableId } = - embeddable - .getStateTransfer(scopedHistory) - .getIncomingEditorState({ keysToRemoveAfterFetch: ['id', 'input'] }) || {}; const stateTransfer = embeddable.getStateTransfer(); const topNavConfig: TopNavMenuData[] = [ @@ -105,7 +107,7 @@ export const getTopNavConfig = ( showSaveModal(saveModal, I18nContext); }, }, - ...(originatingApp && ((savedVisBuilderVis && savedVisBuilderVis.id) || embeddableId) + ...(originatingApp && savedVisBuilderVis && savedVisBuilderVis.id ? [ { id: 'saveAndReturn', diff --git a/src/plugins/vis_builder/public/application/utils/state_management/metadata_slice.ts b/src/plugins/vis_builder/public/application/utils/state_management/metadata_slice.ts index 8cc71804f12e..c1e23b52823d 100644 --- a/src/plugins/vis_builder/public/application/utils/state_management/metadata_slice.ts +++ b/src/plugins/vis_builder/public/application/utils/state_management/metadata_slice.ts @@ -21,6 +21,7 @@ export interface MetadataState { }; state: EditorState; }; + originatingApp?: string; } const initialState: MetadataState = { @@ -28,13 +29,20 @@ const initialState: MetadataState = { validity: {}, state: 'loading', }, + originatingApp: undefined, }; export const getPreloadedState = async ({ types, data, + embeddable, + scopedHistory, }: VisBuilderServices): Promise => { - const preloadedState = { ...initialState }; + const { originatingApp } = + embeddable + .getStateTransfer(scopedHistory) + .getIncomingEditorState({ keysToRemoveAfterFetch: ['id', 'input'] }) || {}; + const preloadedState = { ...initialState, originatingApp }; return preloadedState; }; @@ -50,6 +58,9 @@ export const slice = createSlice({ setEditorState: (state, action: PayloadAction<{ state: EditorState }>) => { state.editor.state = action.payload.state; }, + setOriginatingApp: (state, action: PayloadAction<{ state?: string }>) => { + state.originatingApp = action.payload.state; + }, setState: (_state, action: PayloadAction) => { return action.payload; }, @@ -57,4 +68,4 @@ export const slice = createSlice({ }); export const { reducer } = slice; -export const { setValidity, setEditorState, setState } = slice.actions; +export const { setValidity, setEditorState, setOriginatingApp, setState } = slice.actions; diff --git a/src/plugins/vis_builder/public/plugin.test.ts b/src/plugins/vis_builder/public/plugin.test.ts index f5fac728420b..35e17865649a 100644 --- a/src/plugins/vis_builder/public/plugin.test.ts +++ b/src/plugins/vis_builder/public/plugin.test.ts @@ -28,6 +28,7 @@ describe('VisBuilderPlugin', () => { const setupDeps = { visualizations: visualizationsPluginMock.createSetupContract(), embeddable: embeddablePluginMock.createSetupContract(), + data: dataPluginMock.createSetupContract(), }; const setup = plugin.setup(coreSetup, setupDeps); diff --git a/src/plugins/vis_builder/public/plugin.ts b/src/plugins/vis_builder/public/plugin.ts index 8e90a04784cd..a619958653b6 100644 --- a/src/plugins/vis_builder/public/plugin.ts +++ b/src/plugins/vis_builder/public/plugin.ts @@ -4,13 +4,17 @@ */ import { i18n } from '@osd/i18n'; +import { BehaviorSubject } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; import { AppMountParameters, AppNavLinkStatus, + AppUpdater, CoreSetup, CoreStart, Plugin, PluginInitializerContext, + ScopedHistory, } from '../../../core/public'; import { VisBuilderPluginSetupDependencies, @@ -41,10 +45,17 @@ import { setUISettings, setTypeService, setReactExpressionRenderer, + setQueryService, } from './plugin_services'; import { createSavedVisBuilderLoader } from './saved_visualizations'; import { registerDefaultTypes } from './visualizations'; import { ConfigSchema } from '../config'; +import { + createOsdUrlStateStorage, + createOsdUrlTracker, + withNotifyOnErrors, +} from '../../opensearch_dashboards_utils/public'; +import { opensearchFilters } from '../../data/public'; export class VisBuilderPlugin implements @@ -55,13 +66,45 @@ export class VisBuilderPlugin VisBuilderPluginStartDependencies > { private typeService = new TypeService(); + private appStateUpdater = new BehaviorSubject(() => ({})); + private stopUrlTracking?: () => void; + private currentHistory?: ScopedHistory; constructor(public initializerContext: PluginInitializerContext) {} public setup( core: CoreSetup, - { embeddable, visualizations }: VisBuilderPluginSetupDependencies + { embeddable, visualizations, data }: VisBuilderPluginSetupDependencies ) { + const { appMounted, appUnMounted, stop: stopUrlTracker } = createOsdUrlTracker({ + baseUrl: core.http.basePath.prepend(`/app/${PLUGIN_ID}`), + defaultSubUrl: '#/', + storageKey: `lastUrl:${core.http.basePath.get()}:${PLUGIN_ID}`, + navLinkUpdater$: this.appStateUpdater, + toastNotifications: core.notifications.toasts, + stateParams: [ + { + osdUrlKey: '_g', + stateUpdate$: data.query.state$.pipe( + filter( + ({ changes }) => !!(changes.globalFilters || changes.time || changes.refreshInterval) + ), + map(({ state }) => ({ + ...state, + filters: state.filters?.filter(opensearchFilters.isFilterPinned), + })) + ), + }, + ], + getHistory: () => { + return this.currentHistory!; + }, + }); + this.stopUrlTracking = () => { + stopUrlTracker(); + }; + + // Register Default Visualizations const typeService = this.typeService; registerDefaultTypes(typeService.setup()); @@ -70,43 +113,62 @@ export class VisBuilderPlugin id: PLUGIN_ID, title: PLUGIN_NAME, navLinkStatus: AppNavLinkStatus.hidden, - async mount(params: AppMountParameters) { + defaultPath: '#/', + mount: async (params: AppMountParameters) => { // Load application bundle const { renderApp } = await import('./application'); // Get start services as specified in opensearch_dashboards.json const [coreStart, pluginsStart, selfStart] = await core.getStartServices(); - const { data, savedObjects, navigation, expressions } = pluginsStart; + const { savedObjects, navigation, expressions } = pluginsStart; + this.currentHistory = params.history; // make sure the index pattern list is up to date - data.indexPatterns.clearCache(); + pluginsStart.data.indexPatterns.clearCache(); // make sure a default index pattern exists // if not, the page will be redirected to management and visualize won't be rendered // TODO: Add the redirect await pluginsStart.data.indexPatterns.ensureDefaultIndexPattern(); - // Register Default Visualizations + appMounted(); + + // dispatch synthetic hash change event to update hash history objects + // this is necessary because hash updates triggered by using popState won't trigger this event naturally. + const unlistenParentHistory = this.currentHistory.listen(() => { + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); const services: VisBuilderServices = { ...coreStart, + scopedHistory: this.currentHistory, + history: this.currentHistory, + osdUrlStateStorage: createOsdUrlStateStorage({ + history: this.currentHistory, + useHash: coreStart.uiSettings.get('state:storeInSessionStorage'), + ...withNotifyOnErrors(coreStart.notifications.toasts), + }), toastNotifications: coreStart.notifications.toasts, - data, + data: pluginsStart.data, savedObjectsPublic: savedObjects, navigation, expressions, - history: params.history, setHeaderActionMenu: params.setHeaderActionMenu, types: typeService.start(), savedVisBuilderLoader: selfStart.savedVisBuilderLoader, embeddable: pluginsStart.embeddable, - scopedHistory: params.history, + dashboard: pluginsStart.dashboard, }; // Instantiate the store const store = await getPreloadedStore(services); + const unmount = renderApp(params, services, store); // Render the application - return renderApp(params, services, store); + return () => { + unlistenParentHistory(); + unmount(); + appUnMounted(); + }; }, }); @@ -154,7 +216,7 @@ export class VisBuilderPlugin public start( core: CoreStart, - { data, expressions }: VisBuilderPluginStartDependencies + { expressions, data }: VisBuilderPluginStartDependencies ): VisBuilderStart { const typeService = this.typeService.start(); @@ -176,6 +238,7 @@ export class VisBuilderPlugin setTimeFilter(data.query.timefilter.timefilter); setTypeService(typeService); setUISettings(core.uiSettings); + setQueryService(data.query); return { ...typeService, @@ -183,5 +246,9 @@ export class VisBuilderPlugin }; } - public stop() {} + public stop() { + if (this.stopUrlTracking) { + this.stopUrlTracking(); + } + } } diff --git a/src/plugins/vis_builder/public/plugin_services.ts b/src/plugins/vis_builder/public/plugin_services.ts index f979f3a22b11..c5583e3c5e43 100644 --- a/src/plugins/vis_builder/public/plugin_services.ts +++ b/src/plugins/vis_builder/public/plugin_services.ts @@ -37,3 +37,7 @@ export const [getTimeFilter, setTimeFilter] = createGetterSetter('TypeService'); export const [getUISettings, setUISettings] = createGetterSetter('UISettings'); + +export const [getQueryService, setQueryService] = createGetterSetter< + DataPublicPluginStart['query'] +>('Query'); diff --git a/src/plugins/vis_builder/public/types.ts b/src/plugins/vis_builder/public/types.ts index 131c9cc1f6bb..2d323a13b213 100644 --- a/src/plugins/vis_builder/public/types.ts +++ b/src/plugins/vis_builder/public/types.ts @@ -14,6 +14,8 @@ import { DataPublicPluginStart } from '../../data/public'; import { TypeServiceSetup, TypeServiceStart } from './services/type_service'; import { SavedObjectLoader } from '../../saved_objects/public'; import { AppMountParameters, CoreStart, ToastsStart, ScopedHistory } from '../../../core/public'; +import { IOsdUrlStateStorage } from '../../opensearch_dashboards_utils/public'; +import { DataPublicPluginSetup } from '../../data/public'; export type VisBuilderSetup = TypeServiceSetup; export interface VisBuilderStart extends TypeServiceStart { @@ -23,6 +25,7 @@ export interface VisBuilderStart extends TypeServiceStart { export interface VisBuilderPluginSetupDependencies { embeddable: EmbeddableSetup; visualizations: VisualizationsSetup; + data: DataPublicPluginSetup; } export interface VisBuilderPluginStartDependencies { embeddable: EmbeddableStart; @@ -45,6 +48,8 @@ export interface VisBuilderServices extends CoreStart { history: History; embeddable: EmbeddableStart; scopedHistory: ScopedHistory; + osdUrlStateStorage: IOsdUrlStateStorage; + dashboard: DashboardStart; } export interface ISavedVis {