From 521f306bab8c35f73b8fcea36bbbff8704ca4a97 Mon Sep 17 00:00:00 2001 From: Ashwin P Chandran Date: Thu, 27 Jul 2023 16:12:33 -0700 Subject: [PATCH] [Data Explorer] State management + enhancements (#4580) * Adds toggle between legacy and new discover Signed-off-by: Ashwin P Chandran * Fixes header offset Signed-off-by: Ashwin P Chandran * adds basic state management Signed-off-by: Ashwin P Chandran * attempt 1 at dynamic state management Signed-off-by: Ashwin P Chandran * Working multi view state management Signed-off-by: Ashwin P Chandran * Adds global state persistence to data explorer Signed-off-by: Ashwin P Chandran --------- Signed-off-by: Ashwin P Chandran --- .../data_explorer/opensearch_dashboards.json | 14 ++- .../data_explorer/public/application.tsx | 23 +++-- .../public/components/app_container.tsx | 61 ++---------- .../public/components/sidebar.tsx | 40 -------- .../public/components/sidebar/index.tsx | 99 +++++++++++++++++++ src/plugins/data_explorer/public/index.ts | 3 +- src/plugins/data_explorer/public/plugin.ts | 90 +++++++++++++++-- .../public/services/view_service/types.ts | 15 ++- .../public/services/view_service/view.ts | 6 +- .../services/view_service/view_service.ts | 12 +-- src/plugins/data_explorer/public/types.ts | 31 ++++-- .../data_explorer/public/utils/mocks.ts | 35 +++++++ .../public/utils/state_management/hooks.ts | 14 +++ .../public/utils/state_management/index.ts | 7 ++ .../utils/state_management/metadata_slice.ts | 56 +++++++++++ .../public/utils/state_management/preload.ts | 37 +++++++ .../redux_persistence.test.tsx | 45 +++++++++ .../state_management/redux_persistence.ts | 30 ++++++ .../public/utils/state_management/store.ts | 79 +++++++++++++++ .../public/utils/use/use_view.ts | 23 +++-- .../utils/state_management/discover_slice.tsx | 74 ++++++++++++++ .../utils/state_management/index.ts | 17 ++++ .../view_components/canvas/index.tsx | 23 ++--- .../view_components/panel/index.tsx | 18 ++-- .../view_components/panel/panel.tsx | 4 +- src/plugins/discover/public/build_services.ts | 4 +- src/plugins/discover/public/plugin.ts | 78 ++++++--------- 27 files changed, 722 insertions(+), 216 deletions(-) delete mode 100644 src/plugins/data_explorer/public/components/sidebar.tsx create mode 100644 src/plugins/data_explorer/public/components/sidebar/index.tsx create mode 100644 src/plugins/data_explorer/public/utils/mocks.ts create mode 100644 src/plugins/data_explorer/public/utils/state_management/hooks.ts create mode 100644 src/plugins/data_explorer/public/utils/state_management/index.ts create mode 100644 src/plugins/data_explorer/public/utils/state_management/metadata_slice.ts create mode 100644 src/plugins/data_explorer/public/utils/state_management/preload.ts create mode 100644 src/plugins/data_explorer/public/utils/state_management/redux_persistence.test.tsx create mode 100644 src/plugins/data_explorer/public/utils/state_management/redux_persistence.ts create mode 100644 src/plugins/data_explorer/public/utils/state_management/store.ts create mode 100644 src/plugins/discover/public/application/utils/state_management/discover_slice.tsx create mode 100644 src/plugins/discover/public/application/utils/state_management/index.ts diff --git a/src/plugins/data_explorer/opensearch_dashboards.json b/src/plugins/data_explorer/opensearch_dashboards.json index 7b5a78c5503..23db353b2cc 100644 --- a/src/plugins/data_explorer/opensearch_dashboards.json +++ b/src/plugins/data_explorer/opensearch_dashboards.json @@ -4,7 +4,15 @@ "opensearchDashboardsVersion": "opensearchDashboards", "server": true, "ui": true, - "requiredPlugins": ["data", "navigation"], + "requiredPlugins": [ + "data", + "navigation", + "embeddable", + "expressions" + ], "optionalPlugins": [], - "requiredBundles": ["opensearchDashboardsReact"] -} + "requiredBundles": [ + "opensearchDashboardsReact", + "opensearchDashboardsUtils" + ] +} \ No newline at end of file diff --git a/src/plugins/data_explorer/public/application.tsx b/src/plugins/data_explorer/public/application.tsx index 505209358ff..ae57070f745 100644 --- a/src/plugins/data_explorer/public/application.tsx +++ b/src/plugins/data_explorer/public/application.tsx @@ -5,28 +5,33 @@ import React from 'react'; import ReactDOM from 'react-dom'; +import { Provider as ReduxProvider } from 'react-redux'; import { Router, Route, Switch } from 'react-router-dom'; import { AppMountParameters, CoreStart } from '../../../core/public'; import { OpenSearchDashboardsContextProvider } from '../../opensearch_dashboards_react/public'; import { DataExplorerServices } from './types'; import { DataExplorerApp } from './components/app'; +import { Store } from './utils/state_management'; export const renderApp = ( - { notifications, http }: CoreStart, + core: CoreStart, services: DataExplorerServices, - params: AppMountParameters + params: AppMountParameters, + store: Store ) => { const { history, element } = params; ReactDOM.render( - - - - - - - + + + + + + + + + , element diff --git a/src/plugins/data_explorer/public/components/app_container.tsx b/src/plugins/data_explorer/public/components/app_container.tsx index 8923d2c5b17..91b75a12423 100644 --- a/src/plugins/data_explorer/public/components/app_container.tsx +++ b/src/plugins/data_explorer/public/components/app_container.tsx @@ -3,73 +3,31 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useLayoutEffect, useRef, useState } from 'react'; +import React from 'react'; import { EuiPageTemplate } from '@elastic/eui'; +import { Suspense } from 'react'; import { AppMountParameters } from '../../../../core/public'; import { Sidebar } from './sidebar'; import { NoView } from './no_view'; import { View } from '../services/view_service/view'; export const AppContainer = ({ view, params }: { view?: View; params: AppMountParameters }) => { - const [showSpinner, setShowSpinner] = useState(false); - const canvasRef = useRef(null); - const panelRef = useRef(null); - const unmountRef = useRef(null); - - useLayoutEffect(() => { - const unmount = () => { - if (unmountRef.current) { - unmountRef.current(); - unmountRef.current = null; - } - }; - - // Do nothing if the view is not defined or if the view is the same as the previous view - if (!view || (unmountRef.current && unmountRef.current.viewId === view.id)) { - return; - } - - // unmount the previous view - unmount(); - - const mount = async () => { - setShowSpinner(true); - try { - unmountRef.current = - (await view.mount({ - canvasElement: canvasRef.current!, - panelElement: panelRef.current!, - appParams: params, - })) || null; - } catch (e) { - // TODO: add error UI - // eslint-disable-next-line no-console - console.error(e); - } finally { - // if (canvasRef.current && panelRef.current) { - if (canvasRef.current) { - setShowSpinner(false); - } - } - }; - - mount(); - - return unmount; - }, [params, view]); - // TODO: Make this more robust. if (!view) { return ; } + const { Canvas, Panel } = view; + // Render the application DOM. // Note that `navigation.ui.TopNavMenu` is a stateful component exported on the `navigation` plugin's start contract. return ( -
+ Loading...
}> + + } className="dePageTemplate" @@ -78,8 +36,9 @@ export const AppContainer = ({ view, params }: { view?: View; params: AppMountPa paddingSize="none" > {/* TODO: improve loading state */} - {showSpinner &&
Loading...
} -
+ Loading...
}> + +
); }; diff --git a/src/plugins/data_explorer/public/components/sidebar.tsx b/src/plugins/data_explorer/public/components/sidebar.tsx deleted file mode 100644 index 3d6e5070a5e..00000000000 --- a/src/plugins/data_explorer/public/components/sidebar.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React, { useMemo, FC } from 'react'; -import { EuiPanel, EuiComboBox, EuiSelect, EuiSelectOption } from '@elastic/eui'; -import { useView } from '../utils/use'; - -export const Sidebar: FC = ({ children }) => { - const { view, viewRegistry } = useView(); - const views = viewRegistry.all(); - const viewOptions: EuiSelectOption[] = useMemo( - () => - views.map(({ id, title }) => ({ - value: id, - text: title, - })), - [views] - ); - return ( - <> - - {}} - /> - - - {children} - - ); -}; diff --git a/src/plugins/data_explorer/public/components/sidebar/index.tsx b/src/plugins/data_explorer/public/components/sidebar/index.tsx new file mode 100644 index 00000000000..f58bc776982 --- /dev/null +++ b/src/plugins/data_explorer/public/components/sidebar/index.tsx @@ -0,0 +1,99 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useMemo, FC, useEffect, useState } from 'react'; +import { i18n } from '@osd/i18n'; +import { EuiPanel, EuiComboBox, EuiSelect, EuiComboBoxOptionOption } from '@elastic/eui'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { useView } from '../../utils/use'; +import { DataExplorerServices } from '../../types'; +import { useTypedDispatch, useTypedSelector, setIndexPattern } from '../../utils/state_management'; +import { setView } from '../../utils/state_management/metadata_slice'; + +export const Sidebar: FC = ({ children }) => { + const { indexPattern: indexPatternId } = useTypedSelector((state) => state.metadata); + const dispatch = useTypedDispatch(); + const [options, setOptions] = useState>>([]); + const [selectedOption, setSelectedOption] = useState>(); + const { view, viewRegistry } = useView(); + const views = viewRegistry.all(); + const viewOptions = useMemo( + () => + views.map(({ id, title }) => ({ + value: id, + text: title, + })), + [views] + ); + + const { + services: { + data: { indexPatterns }, + notifications: { toasts }, + }, + } = useOpenSearchDashboards(); + + useEffect(() => { + const fetchIndexPatterns = async () => { + await indexPatterns.ensureDefaultIndexPattern(); + const cache = await indexPatterns.getCache(); + const currentOptions = (cache || []).map((indexPattern) => ({ + label: indexPattern.attributes.title, + value: indexPattern.id, + })); + setOptions(currentOptions); + }; + fetchIndexPatterns(); + }, [indexPatterns]); + + // Set option to the current index pattern + useEffect(() => { + if (indexPatternId) { + const option = options.find((o) => o.value === indexPatternId); + setSelectedOption(option); + } + }, [indexPatternId, options]); + + return ( + <> + + { + // TODO: There are many issues with this approach, but it's a start + // 1. Combo box can delete a selected index pattern. This should not be possible + // 2. Combo box is severely truncated. This should be fixed in the EUI component + // 3. The onchange can fire with a option that is not valid. discuss where to handle this. + // 4. value is optional. If the combobox needs to act as a slecet, this should be required. + const { value } = selected[0] || {}; + + if (!value) { + toasts.addWarning({ + id: 'index-pattern-not-found', + title: i18n.translate('dataExplorer.indexPatternError', { + defaultMessage: 'Index pattern not found', + }), + }); + return; + } + + dispatch(setIndexPattern(value)); + }} + /> + { + dispatch(setView(e.target.value)); + }} + /> + + {children} + + ); +}; diff --git a/src/plugins/data_explorer/public/index.ts b/src/plugins/data_explorer/public/index.ts index ce419fbdce0..0a0575e339c 100644 --- a/src/plugins/data_explorer/public/index.ts +++ b/src/plugins/data_explorer/public/index.ts @@ -13,4 +13,5 @@ export function plugin() { return new DataExplorerPlugin(); } export { DataExplorerPluginSetup, DataExplorerPluginStart, ViewRedirectParams } from './types'; -export { ViewMountParameters, ViewDefinition } from './services/view_service'; +export { ViewProps, ViewDefinition } from './services/view_service'; +export { RootState, useTypedSelector, useTypedDispatch } from './utils/state_management'; diff --git a/src/plugins/data_explorer/public/plugin.ts b/src/plugins/data_explorer/public/plugin.ts index d44e6713cc7..5935ceb44d8 100644 --- a/src/plugins/data_explorer/public/plugin.ts +++ b/src/plugins/data_explorer/public/plugin.ts @@ -3,12 +3,16 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { BehaviorSubject } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; import { AppMountParameters, CoreSetup, CoreStart, Plugin, AppNavLinkStatus, + ScopedHistory, + AppUpdater, } from '../../../core/public'; import { DataExplorerPluginSetup, @@ -19,6 +23,13 @@ import { } from './types'; import { PLUGIN_ID, PLUGIN_NAME } from '../common'; import { ViewService } from './services/view_service'; +import { + createOsdUrlStateStorage, + createOsdUrlTracker, + withNotifyOnErrors, +} from '../../opensearch_dashboards_utils/public'; +import { getPreloadedStore } from './utils/state_management'; +import { opensearchFilters } from '../../data/public'; export class DataExplorerPlugin implements @@ -29,32 +40,92 @@ export class DataExplorerPlugin DataExplorerPluginStartDependencies > { private viewService = new ViewService(); + private appStateUpdater = new BehaviorSubject(() => ({})); + private stopUrlTracking?: () => void; + private currentHistory?: ScopedHistory; public setup( - core: CoreSetup + core: CoreSetup, + { data }: DataExplorerPluginSetupDependencies ): DataExplorerPluginSetup { const viewService = this.viewService; + // TODO: Remove this before merge to main + // eslint-disable-next-line no-console + console.log('data_explorer: Setup'); + + 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 an application into the side navigation menu core.application.register({ id: PLUGIN_ID, title: PLUGIN_NAME, navLinkStatus: AppNavLinkStatus.hidden, - async mount(params: AppMountParameters) { + mount: async (params: AppMountParameters) => { + // TODO: Remove this before merge to main + // eslint-disable-next-line no-console + console.log('data_explorer: Mounted'); + // Load application bundle + const { renderApp } = await import('./application'); + const [coreStart, pluginsStart] = await core.getStartServices(); + this.currentHistory = params.history; // make sure the index pattern list is up to date pluginsStart.data.indexPatterns.clearCache(); const services: DataExplorerServices = { ...coreStart, + scopedHistory: this.currentHistory, + data: pluginsStart.data, + embeddable: pluginsStart.embeddable, + expressions: pluginsStart.expressions, + osdUrlStateStorage: createOsdUrlStateStorage({ + history: this.currentHistory, + useHash: coreStart.uiSettings.get('state:storeInSessionStorage'), + ...withNotifyOnErrors(coreStart.notifications.toasts), + }), viewRegistry: viewService.start(), }; - // Load application bundle - const { renderApp } = await import('./application'); // Get start services as specified in opensearch_dashboards.json // Render the application - return renderApp(coreStart, services, params); + const { store, unsubscribe: unsubscribeStore } = await getPreloadedStore(services); + services.store = store; + + const unmount = renderApp(coreStart, services, params, store); + appMounted(); + + return () => { + unsubscribeStore(); + appUnMounted(); + unmount(); + }; }, }); @@ -64,8 +135,15 @@ export class DataExplorerPlugin } public start(core: CoreStart): DataExplorerPluginStart { + // TODO: Remove this before merge to main + // eslint-disable-next-line no-console + console.log('data_explorer: Started'); return {}; } - public stop() {} + public stop() { + if (this.stopUrlTracking) { + this.stopUrlTracking(); + } + } } diff --git a/src/plugins/data_explorer/public/services/view_service/types.ts b/src/plugins/data_explorer/public/services/view_service/types.ts index d56f8f3058b..2aa3915da46 100644 --- a/src/plugins/data_explorer/public/services/view_service/types.ts +++ b/src/plugins/data_explorer/public/services/view_service/types.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { Slice } from '@reduxjs/toolkit'; +import { LazyExoticComponent } from 'react'; import { AppMountParameters } from '../../../../../core/public'; // TODO: State management props @@ -12,20 +14,17 @@ interface ViewListItem { label: string; } -export interface ViewMountParameters { - canvasElement: HTMLDivElement; - panelElement: HTMLDivElement; - appParams: AppMountParameters; -} +export type ViewProps = AppMountParameters; export interface ViewDefinition { readonly id: string; readonly title: string; readonly ui?: { - defaults: T; - reducer: (state: T, action: any) => T; + defaults: T | (() => T) | (() => Promise); + slice: Slice; }; - readonly mount: (params: ViewMountParameters) => Promise<() => void>; + readonly Canvas: LazyExoticComponent<(props: ViewProps) => React.ReactElement>; + readonly Panel: LazyExoticComponent<(props: ViewProps) => React.ReactElement>; readonly defaultPath: string; readonly appExtentions: { savedObject: { diff --git a/src/plugins/data_explorer/public/services/view_service/view.ts b/src/plugins/data_explorer/public/services/view_service/view.ts index 1e994a21b03..6268aa73149 100644 --- a/src/plugins/data_explorer/public/services/view_service/view.ts +++ b/src/plugins/data_explorer/public/services/view_service/view.ts @@ -13,7 +13,8 @@ export class View implements IView { public readonly defaultPath: string; public readonly appExtentions: IView['appExtentions']; readonly shouldShow?: (state: any) => boolean; - readonly mount: IView['mount']; + readonly Canvas: IView['Canvas']; + readonly Panel: IView['Panel']; constructor(options: ViewDefinition) { this.id = options.id; @@ -22,6 +23,7 @@ export class View implements IView { this.defaultPath = options.defaultPath; this.appExtentions = options.appExtentions; this.shouldShow = options.shouldShow; - this.mount = options.mount; + this.Canvas = options.Canvas; + this.Panel = options.Panel; } } diff --git a/src/plugins/data_explorer/public/services/view_service/view_service.ts b/src/plugins/data_explorer/public/services/view_service/view_service.ts index e527e164573..02d30d838e4 100644 --- a/src/plugins/data_explorer/public/services/view_service/view_service.ts +++ b/src/plugins/data_explorer/public/services/view_service/view_service.ts @@ -38,13 +38,13 @@ import { View } from './view'; * @internal */ export class ViewService implements CoreService { - private views: Record = {}; + private views: Record = {}; - private registerView(viewDefinition: View) { - if (this.views[viewDefinition.id]) { - throw new Error(`A view with this the id ${viewDefinition.id} already exists!`); + private registerView(view: View) { + if (this.views[view.id]) { + throw new Error(`A view with this the id ${view.id} already exists!`); } - this.views[viewDefinition.id] = viewDefinition; + this.views[view.id] = view; } public setup() { @@ -53,7 +53,7 @@ export class ViewService implements CoreService { + registerView: (config: ViewDefinition): void => { const view = new View(config); this.registerView(view); }, diff --git a/src/plugins/data_explorer/public/types.ts b/src/plugins/data_explorer/public/types.ts index 8976745eb3f..1c7b21c191d 100644 --- a/src/plugins/data_explorer/public/types.ts +++ b/src/plugins/data_explorer/public/types.ts @@ -3,19 +3,26 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { CoreStart } from 'opensearch-dashboards/public'; -import { ViewService } from './services/view_service'; -import { DataPublicPluginStart } from '../../data/public'; +import { CoreStart, ScopedHistory } from 'opensearch-dashboards/public'; +import { EmbeddableStart } from '../../embeddable/public'; +import { ExpressionsStart } from '../../expressions/public'; +import { ViewServiceStart, ViewServiceSetup } from './services/view_service'; +import { IOsdUrlStateStorage } from '../../opensearch_dashboards_utils/public'; +import { DataPublicPluginSetup, DataPublicPluginStart } from '../../data/public'; +import { Store } from './utils/state_management'; + +export type DataExplorerPluginSetup = ViewServiceSetup; -export interface DataExplorerPluginSetup { - registerView: ViewService['registerView']; -} // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface DataExplorerPluginStart {} -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface DataExplorerPluginSetupDependencies {} +export interface DataExplorerPluginSetupDependencies { + data: DataPublicPluginSetup; +} + export interface DataExplorerPluginStartDependencies { + expressions: ExpressionsStart; + embeddable: EmbeddableStart; data: DataPublicPluginStart; } @@ -25,5 +32,11 @@ export interface ViewRedirectParams { } export interface DataExplorerServices extends CoreStart { - viewRegistry: ReturnType; + store?: Store; + viewRegistry: ViewServiceStart; + expressions: ExpressionsStart; + embeddable: EmbeddableStart; + data: DataPublicPluginStart; + scopedHistory: ScopedHistory; + osdUrlStateStorage: IOsdUrlStateStorage; } diff --git a/src/plugins/data_explorer/public/utils/mocks.ts b/src/plugins/data_explorer/public/utils/mocks.ts new file mode 100644 index 00000000000..b0bda1a9c60 --- /dev/null +++ b/src/plugins/data_explorer/public/utils/mocks.ts @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ScopedHistory } from '../../../../core/public'; +import { coreMock, scopedHistoryMock } from '../../../../core/public/mocks'; +import { dataPluginMock } from '../../../data/public/mocks'; +import { embeddablePluginMock } from '../../../embeddable/public/mocks'; +import { expressionsPluginMock } from '../../../expressions/public/mocks'; +import { createOsdUrlStateStorage } from '../../../opensearch_dashboards_utils/public'; +import { DataExplorerServices } from '../types'; + +export const createDataExplorerServicesMock = () => { + const coreStartMock = coreMock.createStart(); + const dataMock = dataPluginMock.createStartContract(); + const embeddableMock = embeddablePluginMock.createStartContract(); + const expressionMock = expressionsPluginMock.createStartContract(); + const osdUrlStateStorageMock = createOsdUrlStateStorage({ useHash: false }); + + const dataExplorerServicesMock: DataExplorerServices = { + ...coreStartMock, + expressions: expressionMock, + data: dataMock, + osdUrlStateStorage: osdUrlStateStorageMock, + embeddable: embeddableMock, + scopedHistory: (scopedHistoryMock.create() as unknown) as ScopedHistory, + viewRegistry: { + get: jest.fn(), + all: jest.fn(), + }, + }; + + return (dataExplorerServicesMock as unknown) as jest.Mocked; +}; diff --git a/src/plugins/data_explorer/public/utils/state_management/hooks.ts b/src/plugins/data_explorer/public/utils/state_management/hooks.ts new file mode 100644 index 00000000000..d4194da3702 --- /dev/null +++ b/src/plugins/data_explorer/public/utils/state_management/hooks.ts @@ -0,0 +1,14 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useDispatch, useSelector } from 'react-redux'; +import type { RootState, AppDispatch } from './store'; + +// Use throughout the app instead of plain `useDispatch` and `useSelector` +export const useTypedDispatch = () => useDispatch(); +export const useTypedSelector: ( + selector: (state: TState) => TSelected, + equalityFn?: (left: TSelected, right: TSelected) => boolean +) => TSelected = useSelector; diff --git a/src/plugins/data_explorer/public/utils/state_management/index.ts b/src/plugins/data_explorer/public/utils/state_management/index.ts new file mode 100644 index 00000000000..edb5c2a1718 --- /dev/null +++ b/src/plugins/data_explorer/public/utils/state_management/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './store'; +export * from './hooks'; diff --git a/src/plugins/data_explorer/public/utils/state_management/metadata_slice.ts b/src/plugins/data_explorer/public/utils/state_management/metadata_slice.ts new file mode 100644 index 00000000000..e9fe8471312 --- /dev/null +++ b/src/plugins/data_explorer/public/utils/state_management/metadata_slice.ts @@ -0,0 +1,56 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { DataExplorerServices } from '../../types'; + +export interface MetadataState { + indexPattern?: string; + originatingApp?: string; + view?: string; +} + +const initialState: MetadataState = {}; + +export const getPreloadedState = async ({ + embeddable, + scopedHistory, + data, +}: DataExplorerServices): Promise => { + const { originatingApp } = + embeddable + .getStateTransfer(scopedHistory) + .getIncomingEditorState({ keysToRemoveAfterFetch: ['id', 'input'] }) || {}; + const defaultIndexPattern = await data.indexPatterns.getDefault(); + const preloadedState: MetadataState = { + ...initialState, + originatingApp, + indexPattern: defaultIndexPattern?.id, + }; + + return preloadedState; +}; + +export const slice = createSlice({ + name: 'metadata', + initialState, + reducers: { + setIndexPattern: (state, action: PayloadAction) => { + state.indexPattern = action.payload; + }, + setOriginatingApp: (state, action: PayloadAction) => { + state.originatingApp = action.payload; + }, + setView: (state, action: PayloadAction) => { + state.view = action.payload; + }, + setState: (_state, action: PayloadAction) => { + return action.payload; + }, + }, +}); + +export const { reducer } = slice; +export const { setIndexPattern, setOriginatingApp, setView, setState } = slice.actions; diff --git a/src/plugins/data_explorer/public/utils/state_management/preload.ts b/src/plugins/data_explorer/public/utils/state_management/preload.ts new file mode 100644 index 00000000000..399f2d806c0 --- /dev/null +++ b/src/plugins/data_explorer/public/utils/state_management/preload.ts @@ -0,0 +1,37 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PreloadedState } from '@reduxjs/toolkit'; +import { getPreloadedState as getPreloadedMetadataState } from './metadata_slice'; +import { RootState } from './store'; +import { DataExplorerServices } from '../../types'; + +export const getPreloadedState = async ( + services: DataExplorerServices +): Promise> => { + const rootState: RootState = { + metadata: await getPreloadedMetadataState(services), + }; + + // initialize the default state for each view + const views = services.viewRegistry.all(); + const promises = views.map(async (view) => { + if (!view.ui) { + return; + } + + const { defaults } = view.ui; + + // defaults can be a function or an object + if (typeof defaults === 'function') { + rootState[view.id] = await defaults(); + } else { + rootState[view.id] = defaults; + } + }); + await Promise.all(promises); + + return rootState; +}; diff --git a/src/plugins/data_explorer/public/utils/state_management/redux_persistence.test.tsx b/src/plugins/data_explorer/public/utils/state_management/redux_persistence.test.tsx new file mode 100644 index 00000000000..4acf56d0fc5 --- /dev/null +++ b/src/plugins/data_explorer/public/utils/state_management/redux_persistence.test.tsx @@ -0,0 +1,45 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DataExplorerServices } from '../../types'; +import { createDataExplorerServicesMock } from '../mocks'; +import { loadReduxState, persistReduxState } from './redux_persistence'; +import { RootState } from './store'; + +describe('test redux state persistence', () => { + let mockServices: jest.Mocked; + let reduxStateParams: any; + + beforeEach(() => { + mockServices = createDataExplorerServicesMock(); + reduxStateParams = { + style: 'style', + visualization: 'visualization', + metadata: 'metadata', + ui: 'ui', + }; + }); + + test('test load redux state when url is empty', async () => { + const defaultStates: RootState = { + metadata: {}, + }; + + const returnStates = await loadReduxState(mockServices); + expect(returnStates).toStrictEqual(defaultStates); + }); + + test('test load redux state', async () => { + mockServices.osdUrlStateStorage.set('_a', reduxStateParams, { replace: true }); + const returnStates = await loadReduxState(mockServices); + expect(returnStates).toStrictEqual(reduxStateParams); + }); + + test('test persist redux state', () => { + persistReduxState(reduxStateParams, mockServices); + const urlStates = mockServices.osdUrlStateStorage.get('_a'); + expect(urlStates).toStrictEqual(reduxStateParams); + }); +}); diff --git a/src/plugins/data_explorer/public/utils/state_management/redux_persistence.ts b/src/plugins/data_explorer/public/utils/state_management/redux_persistence.ts new file mode 100644 index 00000000000..81517f3e9f4 --- /dev/null +++ b/src/plugins/data_explorer/public/utils/state_management/redux_persistence.ts @@ -0,0 +1,30 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DataExplorerServices } from '../../types'; +import { getPreloadedState } from './preload'; +import { RootState } from './store'; + +export const loadReduxState = async (services: DataExplorerServices) => { + try { + const serializedState = services.osdUrlStateStorage.get('_a'); + if (serializedState !== null) return serializedState; + } catch (err) { + // eslint-disable-next-line no-console + console.error(err); + } + + return await getPreloadedState(services); +}; + +export const persistReduxState = (root: RootState, services: DataExplorerServices) => { + try { + services.osdUrlStateStorage.set('_a', root, { + replace: true, + }); + } catch (err) { + return; + } +}; diff --git a/src/plugins/data_explorer/public/utils/state_management/store.ts b/src/plugins/data_explorer/public/utils/state_management/store.ts new file mode 100644 index 00000000000..ccc939237dd --- /dev/null +++ b/src/plugins/data_explorer/public/utils/state_management/store.ts @@ -0,0 +1,79 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { combineReducers, configureStore, PreloadedState, Reducer, Slice } from '@reduxjs/toolkit'; +import { isEqual } from 'lodash'; +import { Provider } from 'react-redux'; +import { reducer as metadataReducer, MetadataState } from './metadata_slice'; +import { loadReduxState, persistReduxState } from './redux_persistence'; +import { DataExplorerServices } from '../../types'; + +const dynamicReducers: { + metadata: Reducer; + [key: string]: Reducer; +} = { + metadata: metadataReducer, +}; + +const rootReducer = combineReducers(dynamicReducers); + +export const configurePreloadedStore = (preloadedState: PreloadedState) => { + // After registering the slices the root reducer needs to be updated + const updatedRootReducer = combineReducers(dynamicReducers); + + return configureStore({ + reducer: updatedRootReducer, + preloadedState, + }); +}; + +export const getPreloadedStore = async (services: DataExplorerServices) => { + // For each view preload the data and register the slice + const views = services.viewRegistry.all(); + views.forEach((view) => { + if (!view.ui) return; + + const { slice } = view.ui; + registerSlice(slice); + }); + + const preloadedState = await loadReduxState(services); + const store = configurePreloadedStore(preloadedState); + + let previousState = store.getState(); + + // Listen to changes + const handleChange = () => { + const state = store.getState(); + persistReduxState(state, services); + + if (isEqual(state, previousState)) return; + + // Add Side effects here to apply after changes to the store are made. None for now. + + previousState = state; + }; + + // the store subscriber will automatically detect changes and call handleChange function + const unsubscribe = store.subscribe(handleChange); + + return { store, unsubscribe }; +}; + +export const registerSlice = (slice: Slice) => { + if (dynamicReducers[slice.name]) { + throw new Error(`Slice ${slice.name} already registered`); + } + dynamicReducers[slice.name] = slice.reducer; +}; + +// Infer the `RootState` and `AppDispatch` types from the store itself +export type RootState = ReturnType; +export type RenderState = Omit; // Remaining state after auxillary states are removed +export type Store = ReturnType; +export type AppDispatch = Store['dispatch']; + +export { MetadataState, setIndexPattern, setOriginatingApp } from './metadata_slice'; diff --git a/src/plugins/data_explorer/public/utils/use/use_view.ts b/src/plugins/data_explorer/public/utils/use/use_view.ts index edda514a113..10f67c08907 100644 --- a/src/plugins/data_explorer/public/utils/use/use_view.ts +++ b/src/plugins/data_explorer/public/utils/use/use_view.ts @@ -3,24 +3,33 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo } from 'react'; import { useParams } from 'react-router-dom'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; -import { View } from '../../services/view_service/view'; import { DataExplorerServices } from '../../types'; +import { useTypedDispatch, useTypedSelector } from '../state_management'; +import { setView } from '../state_management/metadata_slice'; export const useView = () => { - // TODO: Move the view to the redux store once the store is ready - const [view, setView] = useState(); - const { appId } = useParams<{ appId: string }>(); + const viewId = useTypedSelector((state) => state.metadata.view); const { services: { viewRegistry }, } = useOpenSearchDashboards(); + const dispatch = useTypedDispatch(); + const { appId } = useParams<{ appId: string }>(); + + const view = useMemo(() => { + if (!viewId) return undefined; + return viewRegistry.get(viewId); + }, [viewId, viewRegistry]); useEffect(() => { const currentView = viewRegistry.get(appId); - setView(currentView); - }, [appId, viewRegistry]); + + if (!currentView) return; + + dispatch(setView(currentView?.id)); + }, [appId, dispatch, viewRegistry]); return { view, viewRegistry }; }; diff --git a/src/plugins/discover/public/application/utils/state_management/discover_slice.tsx b/src/plugins/discover/public/application/utils/state_management/discover_slice.tsx new file mode 100644 index 00000000000..d664c5e1d6d --- /dev/null +++ b/src/plugins/discover/public/application/utils/state_management/discover_slice.tsx @@ -0,0 +1,74 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { Filter, Query } from '../../../../../data/public'; +import { DiscoverServices } from '../../../build_services'; +import { RootState } from '../../../../../data_explorer/public'; + +export interface DiscoverState { + /** + * Columns displayed in the table + */ + columns?: string[]; + /** + * Array of applied filters + */ + filters?: Filter[]; + /** + * Used interval of the histogram + */ + interval?: string; + /** + * Lucence or DQL query + */ + query?: Query; + /** + * Array of the used sorting [[field,direction],...] + */ + sort?: string[][]; + /** + * id of the used saved query + */ + savedQuery?: string; +} + +export interface DiscoverRootState extends RootState { + discover: DiscoverState; +} + +const initialState = {} as DiscoverState; + +export const getPreloadedState = async ({ data }: DiscoverServices): Promise => { + return { + ...initialState, + interval: data.query.timefilter.timefilter.getRefreshInterval().value.toString(), + }; +}; + +export const discoverSlice = createSlice({ + name: 'discover', + initialState, + reducers: { + setState(state: T, action: PayloadAction) { + return action.payload; + }, + updateState(state: T, action: PayloadAction>) { + state = { + ...state, + ...action.payload, + }; + + return state; + }, + }, +}); + +// Exposing the state functions as generics +export const setState = discoverSlice.actions.setState as (payload: T) => PayloadAction; +export const updateState = discoverSlice.actions.updateState as ( + payload: Partial +) => PayloadAction>; +export const { reducer } = discoverSlice; diff --git a/src/plugins/discover/public/application/utils/state_management/index.ts b/src/plugins/discover/public/application/utils/state_management/index.ts new file mode 100644 index 00000000000..d72cc772e6c --- /dev/null +++ b/src/plugins/discover/public/application/utils/state_management/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TypedUseSelectorHook } from 'react-redux'; +import { RootState, useTypedDispatch, useTypedSelector } from '../../../../../data_explorer/public'; +import { DiscoverState } from './discover_slice'; + +export * from './discover_slice'; + +export interface DiscoverRootState extends RootState { + discover: DiscoverState; +} + +export const useSelector: TypedUseSelectorHook = useTypedSelector; +export const useDispatch = useTypedDispatch; diff --git a/src/plugins/discover/public/application/view_components/canvas/index.tsx b/src/plugins/discover/public/application/view_components/canvas/index.tsx index 2e911832d14..34fd6a0bf10 100644 --- a/src/plugins/discover/public/application/view_components/canvas/index.tsx +++ b/src/plugins/discover/public/application/view_components/canvas/index.tsx @@ -4,28 +4,21 @@ */ import React from 'react'; -import ReactDOM from 'react-dom'; -import { ViewMountParameters } from '../../../../../data_explorer/public'; +import { ViewProps } from '../../../../../data_explorer/public'; import { OpenSearchDashboardsContextProvider } from '../../../../../opensearch_dashboards_react/public'; -import { DiscoverServices } from '../../../build_services'; import { Canvas } from './canvas'; +import { getServices } from '../../../opensearch_dashboards_services'; -export const renderCanvas = ( - { canvasElement, appParams }: ViewMountParameters, - services: DiscoverServices -) => { - const { setHeaderActionMenu } = appParams; - - ReactDOM.render( +// eslint-disable-next-line import/no-default-export +export default function CanvasApp({ setHeaderActionMenu }: ViewProps) { + const services = getServices(); + return ( - , - canvasElement + ); - - return () => ReactDOM.unmountComponentAtNode(canvasElement); -}; +} diff --git a/src/plugins/discover/public/application/view_components/panel/index.tsx b/src/plugins/discover/public/application/view_components/panel/index.tsx index f92e5af0bfd..c05807d3a63 100644 --- a/src/plugins/discover/public/application/view_components/panel/index.tsx +++ b/src/plugins/discover/public/application/view_components/panel/index.tsx @@ -4,19 +4,17 @@ */ import React from 'react'; -import ReactDOM from 'react-dom'; -import { ViewMountParameters } from '../../../../../data_explorer/public'; +import { ViewProps } from '../../../../../data_explorer/public'; import { OpenSearchDashboardsContextProvider } from '../../../../../opensearch_dashboards_react/public'; -import { DiscoverServices } from '../../../build_services'; import { Panel } from './panel'; +import { getServices } from '../../../opensearch_dashboards_services'; -export const renderPanel = ({ panelElement }: ViewMountParameters, services: DiscoverServices) => { - ReactDOM.render( +// eslint-disable-next-line import/no-default-export +export default function PanelApp(props: ViewProps) { + const services = getServices(); + return ( - , - panelElement + ); - - return () => ReactDOM.unmountComponentAtNode(panelElement); -}; +} diff --git a/src/plugins/discover/public/application/view_components/panel/panel.tsx b/src/plugins/discover/public/application/view_components/panel/panel.tsx index fe3b36f6e87..fda7f8a4431 100644 --- a/src/plugins/discover/public/application/view_components/panel/panel.tsx +++ b/src/plugins/discover/public/application/view_components/panel/panel.tsx @@ -4,7 +4,9 @@ */ import React from 'react'; +import { useSelector } from '../../utils/state_management'; export const Panel = () => { - return
Side Panel
; + const interval = useSelector((state) => state.discover.interval); + return
{interval}
; }; diff --git a/src/plugins/discover/public/build_services.ts b/src/plugins/discover/public/build_services.ts index 0d7fa433cee..a2b70f5c509 100644 --- a/src/plugins/discover/public/build_services.ts +++ b/src/plugins/discover/public/build_services.ts @@ -83,11 +83,11 @@ export interface DiscoverServices { visualizations: VisualizationsStart; } -export async function buildServices( +export function buildServices( core: CoreStart, plugins: DiscoverStartPlugins, context: PluginInitializerContext -): Promise { +): DiscoverServices { const services: SavedObjectOpenSearchDashboardsServices = { savedObjectsClient: core.savedObjects.client, indexPatterns: plugins.data.indexPatterns, diff --git a/src/plugins/discover/public/plugin.ts b/src/plugins/discover/public/plugin.ts index 39d2f698c8f..81c322fa4f0 100644 --- a/src/plugins/discover/public/plugin.ts +++ b/src/plugins/discover/public/plugin.ts @@ -1,31 +1,6 @@ /* + * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Any modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. */ import { i18n } from '@osd/i18n'; @@ -53,9 +28,10 @@ import { import { UrlForwardingSetup, UrlForwardingStart } from 'src/plugins/url_forwarding/public'; import { HomePublicPluginSetup } from 'src/plugins/home/public'; import { Start as InspectorPublicPluginStart } from 'src/plugins/inspector/public'; +import { lazy } from 'react'; import { DataPublicPluginStart, DataPublicPluginSetup, opensearchFilters } from '../../data/public'; import { SavedObjectLoader } from '../../saved_objects/public'; -import { createOsdUrlTracker, url } from '../../opensearch_dashboards_utils/public'; +import { createOsdUrlTracker } from '../../opensearch_dashboards_utils/public'; import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; import { UrlGeneratorState } from '../../share/public'; import { DocViewInput, DocViewInputFn } from './application/doc_views/doc_views_types'; @@ -84,6 +60,11 @@ import { import { NEW_DISCOVER_APP, PLUGIN_ID } from '../common'; import { DataExplorerPluginSetup, ViewRedirectParams } from '../../data_explorer/public'; import { registerFeature } from './register_feature'; +import { + DiscoverState, + discoverSlice, + getPreloadedState, +} from './application/utils/state_management/discover_slice'; declare module '../../share/public' { export interface UrlGeneratorStateMapping { @@ -170,9 +151,12 @@ export class DiscoverPlugin private stopUrlTracking: (() => void) | undefined = undefined; private servicesInitialized: boolean = false; private urlGenerator?: DiscoverStart['urlGenerator']; - private initializeServices?: () => Promise<{ core: CoreStart; plugins: DiscoverStartPlugins }>; + private initializeServices?: () => { core: CoreStart; plugins: DiscoverStartPlugins }; setup(core: CoreSetup, plugins: DiscoverSetupPlugins) { + // TODO: Remove this before merge to main + // eslint-disable-next-line no-console + console.log('DiscoverPlugin.setup()'); const baseUrl = core.http.basePath.prepend('/app/discover'); if (plugins.share) { @@ -247,6 +231,9 @@ export class DiscoverPlugin defaultPath: '#/', category: DEFAULT_APP_CATEGORIES.opensearchDashboards, mount: async (params: AppMountParameters) => { + // TODO: Remove this before merge to main + // eslint-disable-next-line no-console + console.log('DiscoverPlugin.mount()'); if (!this.initializeServices) { throw Error('Discover plugin method initializeServices is undefined'); } @@ -278,9 +265,6 @@ export class DiscoverPlugin }); } - // TODO: Carry this over to the view - // make sure the index pattern list is up to date - // await dataStart.indexPatterns.clearCache(); return () => { appUnMounted(); }; @@ -314,7 +298,7 @@ export class DiscoverPlugin registerFeature(plugins.home); } - plugins.dataExplorer.registerView({ + plugins.dataExplorer.registerView({ id: PLUGIN_ID, title: 'Discover', defaultPath: '#/', @@ -328,20 +312,17 @@ export class DiscoverPlugin }, }, ui: { - defaults: {}, - reducer: () => ({}), + defaults: async () => { + this.initializeServices?.(); + const services = getServices(); + return await getPreloadedState(services); + }, + slice: discoverSlice, }, shouldShow: () => true, - mount: async (params) => { - const { renderCanvas, renderPanel } = await import('./application/view_components'); - const [coreStart, pluginsStart] = await core.getStartServices(); - const services = await buildServices(coreStart, pluginsStart, this.initializerContext); - - renderCanvas(params, services); - renderPanel(params, services); - - return () => {}; - }, + // ViewCompon + Canvas: lazy(() => import('./application/view_components/canvas')), + Panel: lazy(() => import('./application/view_components/panel')), }); // this.registerEmbeddable(core, plugins); @@ -354,19 +335,24 @@ export class DiscoverPlugin } start(core: CoreStart, plugins: DiscoverStartPlugins) { + // TODO: Remove this before merge to main + // eslint-disable-next-line no-console + console.log('DiscoverPlugin.start()'); setUiActions(plugins.uiActions); - this.initializeServices = async () => { + this.initializeServices = () => { if (this.servicesInitialized) { return { core, plugins }; } - const services = await buildServices(core, plugins, this.initializerContext); + const services = buildServices(core, plugins, this.initializerContext); setServices(services); this.servicesInitialized = true; return { core, plugins }; }; + this.initializeServices(); + return { urlGenerator: this.urlGenerator, savedSearchLoader: createSavedSearchesLoader({