Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Data Explorer] Migrate VisBuilder to Data Explorer #5627

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
- [Workspace] Add workspaces column to saved objects page ([#6225](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6225))
- [Multiple Datasource] Enhanced data source selector with default datasource shows as first choice ([#6293](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6293))
- [Multiple Datasource] Add multi data source support to sample vega visualizations ([#6218](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6218))
- [Data Explorer] Migrate VisBuilder to Data Explorer ([#5627](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5627))
- [Multiple Datasource] Fetch data source title for DataSourceView when only id is provided ([#6315](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6315)
- [Multiple Datasource] Get data source label when only id is provided in DataSourceSelectable ([#6358](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6358)
- [Workspace] Add permission control logic ([#6052](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6052))
Expand Down
10 changes: 9 additions & 1 deletion src/core/public/application/scoped_history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,10 @@ export class ScopedHistory<HistoryLocationState = unknown>
private setupHistoryListener() {
const unlisten = this.parentHistory.listen((location, action) => {
// If the user navigates outside the scope of this basePath, tear it down.
if (!location.pathname.startsWith(this.basePath)) {
if (
!location.pathname.startsWith(this.basePath) &&
!this.isPathnameAcceptable(location.pathname)
) {
unlisten();
this.isActive = false;
return;
Expand Down Expand Up @@ -340,4 +343,9 @@ export class ScopedHistory<HistoryLocationState = unknown>
});
});
}

private isPathnameAcceptable(pathname: string): boolean {
const normalizedPathname = pathname.replace('/data-explorer', '');
return normalizedPathname.startsWith(this.basePath);
}
}
24 changes: 1 addition & 23 deletions src/plugins/data_explorer/public/components/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,12 @@
* SPDX-License-Identifier: Apache-2.0
*/

import React, { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import React from 'react';
import { AppMountParameters } from '../../../../core/public';
import { useView } from '../utils/use';
import { AppContainer } from './app_container';
import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public';
import { DataExplorerServices } from '../types';
import { syncQueryStateWithUrl } from '../../../data/public';

export const DataExplorerApp = ({ params }: { params: AppMountParameters }) => {
const { view } = useView();
const {
services: {
data: { query },
osdUrlStateStorage,
},
} = useOpenSearchDashboards<DataExplorerServices>();
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]);

return <AppContainer view={view} params={params} />;
};
114 changes: 70 additions & 44 deletions src/plugins/data_explorer/public/components/app_container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,49 +12,75 @@ import { NoView } from './no_view';
import { View } from '../services/view_service/view';
import './app_container.scss';

export const AppContainer = ({ view, params }: { view?: View; params: AppMountParameters }) => {
const isMobile = useIsWithinBreakpoints(['xs', 's', 'm']);
// TODO: Make this more robust.
if (!view) {
return <NoView />;
export const AppContainer = React.memo(
({ view, params }: { view?: View; params: AppMountParameters }) => {
const isMobile = useIsWithinBreakpoints(['xs', 's', 'm']);
// TODO: Make this more robust.
if (!view) {
return <NoView />;
}

const { Canvas, Panel, Context } = view;

const MemoizedPanel = memo(Panel);
const MemoizedCanvas = memo(Canvas);

// Render the application DOM.
return (
<EuiPage className="deLayout" paddingSize="none">
{/* TODO: improve fallback state */}
<Suspense fallback={<div>Loading...</div>}>
<Context {...params}>
<EuiResizableContainer direction={isMobile ? 'vertical' : 'horizontal'}>
{(EuiResizablePanel, EuiResizableButton) => (
<>
<EuiResizablePanel
initialSize={20}
minSize="260px"
mode={['collapsible', { position: 'top' }]}
paddingSize="none"
>
<Sidebar>
<MemoizedPanel {...params} />
</Sidebar>
</EuiResizablePanel>
<EuiResizableButton />

<EuiResizablePanel initialSize={80} minSize="65%" mode="main" paddingSize="none">
<EuiPageBody className="deLayout__canvas">
<MemoizedCanvas {...params} />
</EuiPageBody>
</EuiResizablePanel>
</>
)}
</EuiResizableContainer>
</Context>
</Suspense>
</EuiPage>
);
},
(prevProps, nextProps) => {
return (
prevProps.view === nextProps.view &&
shallowEqual(prevProps.params, nextProps.params, ['history'])
);
}
);

// A simple shallow equal function that can ignore specified keys
function shallowEqual(object1: any, object2: any, ignoreKeys: any) {
const keys1 = Object.keys(object1).filter((key) => !ignoreKeys.includes(key));
const keys2 = Object.keys(object2).filter((key) => !ignoreKeys.includes(key));

if (keys1.length !== keys2.length) {
return false;
}

for (const key of keys1) {
if (object1[key] !== object2[key]) {
return false;
}
}

const { Canvas, Panel, Context } = view;

const MemoizedPanel = memo(Panel);
const MemoizedCanvas = memo(Canvas);

// Render the application DOM.
return (
<EuiPage className="deLayout" paddingSize="none">
{/* TODO: improve fallback state */}
<Suspense fallback={<div>Loading...</div>}>
<Context {...params}>
<EuiResizableContainer direction={isMobile ? 'vertical' : 'horizontal'}>
{(EuiResizablePanel, EuiResizableButton) => (
<>
<EuiResizablePanel
initialSize={20}
minSize="260px"
mode={['collapsible', { position: 'top' }]}
paddingSize="none"
>
<Sidebar>
<MemoizedPanel {...params} />
</Sidebar>
</EuiResizablePanel>
<EuiResizableButton />

<EuiResizablePanel initialSize={80} minSize="65%" mode="main" paddingSize="none">
<EuiPageBody className="deLayout__canvas">
<MemoizedCanvas {...params} />
</EuiPageBody>
</EuiResizablePanel>
</>
)}
</EuiResizableContainer>
</Context>
</Suspense>
</EuiPage>
);
};
return true;
}
2 changes: 2 additions & 0 deletions src/plugins/data_explorer/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export function plugin() {
export { DataExplorerPluginSetup, DataExplorerPluginStart, DataExplorerServices } from './types';
export { ViewProps, ViewDefinition, DefaultViewState } from './services/view_service';
export {
AppDispatch,
MetadataState,
RootState,
Store,
useTypedSelector,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
* 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';
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 = () => {
export const createDataExplorerStartServicesMock = () => {
const coreStartMock = coreMock.createStart();
const dataMock = dataPluginMock.createStartContract();
const embeddableMock = embeddablePluginMock.createStartContract();
Expand All @@ -33,3 +33,16 @@ export const createDataExplorerServicesMock = () => {

return (dataExplorerServicesMock as unknown) as jest.Mocked<DataExplorerServices>;
};

export const createDataExplorerSetupServicesMock = () => {
const setupMock = {
registerView: jest.fn(),
};

return setupMock;
};

export const dataExplorerPluginMock = {
createDataExplorerStartServicesMock,
createDataExplorerSetupServicesMock,
};
12 changes: 10 additions & 2 deletions src/plugins/data_explorer/public/services/view_service/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Slice } from '@reduxjs/toolkit';
import { LazyExoticComponent } from 'react';
import { AppMountParameters } from '../../../../../core/public';
import { RootState } from '../../utils/state_management';
import { Store } from '../../utils/state_management';

interface ViewListItem {
id: string;
Expand All @@ -20,12 +21,19 @@ export interface DefaultViewState<T = unknown> {

export type ViewProps = AppMountParameters;

type SideEffect<T = any> = (store: Store, state: T, previousState?: T, services?: T) => void;

export interface ViewDefinition<T = any> {
readonly id: string;
readonly title: string;
readonly ui?: {
defaults: DefaultViewState | (() => DefaultViewState) | (() => Promise<DefaultViewState>);
slice: Slice<T>;
defaults:
| DefaultViewState
| (() => DefaultViewState)
| (() => Promise<DefaultViewState>)
| (() => Promise<Array<Promise<DefaultViewState<any>>>>);
slices: Array<Slice<T>>;
sideEffects?: Array<SideEffect<T>>;
};
readonly Canvas: LazyExoticComponent<(props: ViewProps) => React.ReactElement>;
readonly Panel: LazyExoticComponent<(props: ViewProps) => React.ReactElement>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,39 @@ export const getPreloadedState = async (
return;
}

const { defaults } = view.ui;
const { defaults, slices } = view.ui;

try {
// defaults can be a function or an object
const preloadedState = typeof defaults === 'function' ? await defaults() : defaults;
rootState[view.id] = preloadedState.state;

// if the view wants to override the root state, we do that here
if (preloadedState.root) {
rootState = {
...rootState,
...preloadedState.root,
};
if (Array.isArray(preloadedState)) {
await Promise.all(
preloadedState.map(async (statePromise, index) => {
try {
const state = await statePromise;
const slice = slices[index];
const prefixedSliceName =
slice.name === view.id ? slice.name : `${view.id}-${slice.name}`;
rootState[prefixedSliceName] = state.state;
} catch (e) {
// eslint-disable-next-line no-console
console.error(`Error initializing slice: ${e}`);
}
})
);
} else {
slices.forEach((slice) => {
const prefixedSliceName =
slice.name === view.id ? slice.name : `${view.id}-${slice.name}`;
rootState[prefixedSliceName] = preloadedState.state;
});
// if the view wants to override the root state, we do that here
if (preloadedState.root) {
rootState = {
...rootState,
...preloadedState.root,
};
}
}
} catch (e) {
// eslint-disable-next-line no-console
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@
*/

import { DataExplorerServices } from '../../types';
import { createDataExplorerServicesMock } from '../mocks';
import { createDataExplorerStartServicesMock } from '../../mocks';
import { loadReduxState, persistReduxState } from './redux_persistence';

describe('test redux state persistence', () => {
let mockServices: jest.Mocked<DataExplorerServices>;
let reduxStateParams: any;

beforeEach(() => {
mockServices = createDataExplorerServicesMock();
mockServices = createDataExplorerStartServicesMock();
reduxStateParams = {
discover: 'visualization',
metadata: 'metadata',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,18 @@ export const configurePreloadedStore = (preloadedState: PreloadedState<RootState
export const getPreloadedStore = async (services: DataExplorerServices) => {
// For each view preload the data and register the slice
const views = services.viewRegistry.all();
const viewSideEffectsMap: Record<string, Function[]> = {};

views.forEach((view) => {
if (!view.ui) return;

const { slice } = view.ui;
registerSlice(slice);
const { slices, sideEffects } = view.ui;
registerSlices(slices, view.id);

// Save side effects if they exist
if (sideEffects) {
viewSideEffectsMap[view.id] = sideEffects;
}
});

const preloadedState = await loadReduxState(services);
Expand All @@ -72,7 +79,17 @@ export const getPreloadedStore = async (services: DataExplorerServices) => {

if (isEqual(state, previousState)) return;

// Add Side effects here to apply after changes to the store are made. None for now.
// Execute view-specific side effects.
Object.entries(viewSideEffectsMap).forEach(([viewId, effects]) => {
effects.forEach((effect) => {
try {
effect(store, state, previousState, services);
} catch (e) {
// eslint-disable-next-line no-console
console.error(`Error executing side effect for view ${viewId}:`, e);
}
});
});

previousState = state;
};
Expand Down Expand Up @@ -103,11 +120,14 @@ export const getPreloadedStore = async (services: DataExplorerServices) => {
return { store, unsubscribe: onUnsubscribe };
};

export const registerSlice = (slice: Slice) => {
if (dynamicReducers[slice.name]) {
throw new Error(`Slice ${slice.name} already registered`);
}
dynamicReducers[slice.name] = slice.reducer;
export const registerSlices = (slices: Slice[], id: string) => {
slices.forEach((slice) => {
const prefixedSliceName = slice.name === id ? slice.name : `${id}-${slice.name}`;
if (dynamicReducers[prefixedSliceName]) {
throw new Error(`Slice ${prefixedSliceName} already registered`);
}
dynamicReducers[prefixedSliceName] = slice.reducer;
});
};

// Infer the `RootState` and `AppDispatch` types from the store itself
Expand Down
Loading
Loading