[Dashboard] Fast Navigation Between Dashboards (#157437)

## Summary
Makes all navigation from one Dashboard to another feel snappier.
This commit is contained in:
Devon Thomson 2023-05-25 14:40:48 -04:00 committed by GitHub
parent 83b7939e03
commit 5342563a22
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 299 additions and 230 deletions

View file

@ -14,7 +14,11 @@ import { EuiPanel, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui';
import { controlGroupInputBuilder } from '@kbn/controls-plugin/public'; import { controlGroupInputBuilder } from '@kbn/controls-plugin/public';
import { getDefaultControlGroupInput } from '@kbn/controls-plugin/common'; import { getDefaultControlGroupInput } from '@kbn/controls-plugin/common';
import { FILTER_DEBUGGER_EMBEDDABLE } from '@kbn/embeddable-examples-plugin/public'; import { FILTER_DEBUGGER_EMBEDDABLE } from '@kbn/embeddable-examples-plugin/public';
import { AwaitingDashboardAPI, DashboardRenderer } from '@kbn/dashboard-plugin/public'; import {
AwaitingDashboardAPI,
DashboardRenderer,
DashboardCreationOptions,
} from '@kbn/dashboard-plugin/public';
export const DashboardWithControlsExample = ({ dataView }: { dataView: DataView }) => { export const DashboardWithControlsExample = ({ dataView }: { dataView: DataView }) => {
const [dashboard, setDashboard] = useState<AwaitingDashboardAPI>(); const [dashboard, setDashboard] = useState<AwaitingDashboardAPI>();
@ -48,7 +52,7 @@ export const DashboardWithControlsExample = ({ dataView }: { dataView: DataView
<EuiSpacer size="m" /> <EuiSpacer size="m" />
<EuiPanel hasBorder={true}> <EuiPanel hasBorder={true}>
<DashboardRenderer <DashboardRenderer
getCreationOptions={async () => { getCreationOptions={async (): Promise<DashboardCreationOptions> => {
const builder = controlGroupInputBuilder; const builder = controlGroupInputBuilder;
const controlGroupInput = getDefaultControlGroupInput(); const controlGroupInput = getDefaultControlGroupInput();
await builder.addDataControlFromField(controlGroupInput, { await builder.addDataControlFromField(controlGroupInput, {
@ -68,11 +72,11 @@ export const DashboardWithControlsExample = ({ dataView }: { dataView: DataView
return { return {
useControlGroupIntegration: true, useControlGroupIntegration: true,
initialInput: { getInitialInput: () => ({
timeRange: { from: 'now-30d', to: 'now' }, timeRange: { from: 'now-30d', to: 'now' },
viewMode: ViewMode.VIEW, viewMode: ViewMode.VIEW,
controlGroupInput, controlGroupInput,
}, }),
}; };
}} }}
ref={setDashboard} ref={setDashboard}

View file

@ -136,10 +136,10 @@ export const DynamicByReferenceExample = () => {
getCreationOptions={async () => { getCreationOptions={async () => {
const persistedInput = getPersistableInput(); const persistedInput = getPersistableInput();
return { return {
initialInput: { getInitialInput: () => ({
...persistedInput, ...persistedInput,
timeRange: { from: 'now-30d', to: 'now' }, // need to set the time range for the by value vis timeRange: { from: 'now-30d', to: 'now' }, // need to set the time range for the by value vis
}, }),
}; };
}} }}
ref={setdashboard} ref={setdashboard}

View file

@ -50,12 +50,15 @@ export const StaticByReferenceExample = ({
const field = dataView.getFieldByName('machine.os.keyword'); const field = dataView.getFieldByName('machine.os.keyword');
let filter: Filter; let filter: Filter;
let creationOptions: DashboardCreationOptions = { let creationOptions: DashboardCreationOptions = {
initialInput: { viewMode: ViewMode.VIEW }, getInitialInput: () => ({ viewMode: ViewMode.VIEW }),
}; };
if (field) { if (field) {
filter = buildPhraseFilter(field, 'win xp', dataView); filter = buildPhraseFilter(field, 'win xp', dataView);
filter.meta.negate = true; filter.meta.negate = true;
creationOptions = { ...creationOptions, initialInput: { filters: [filter] } }; creationOptions = {
...creationOptions,
getInitialInput: () => ({ filters: [filter] }),
};
} }
return creationOptions; // if can't find the field, then just return no special creation options return creationOptions; // if can't find the field, then just return no special creation options
}} }}

View file

@ -29,11 +29,11 @@ export const StaticByValueExample = () => {
<DashboardRenderer <DashboardRenderer
getCreationOptions={async () => { getCreationOptions={async () => {
return { return {
initialInput: { getInitialInput: () => ({
timeRange: { from: 'now-30d', to: 'now' }, timeRange: { from: 'now-30d', to: 'now' },
viewMode: ViewMode.VIEW, viewMode: ViewMode.VIEW,
panels: panelsJson as DashboardPanelMap, panels: panelsJson as DashboardPanelMap,
}, }),
}; };
}} }}
/> />

View file

@ -190,6 +190,19 @@ export class ControlGroupContainer extends Container<
); );
}; };
public updateInputAndReinitialize = (newInput: Partial<ControlGroupInput>) => {
this.subscriptions.unsubscribe();
this.subscriptions = new Subscription();
this.initialized$.next(false);
this.updateInput(newInput);
this.untilAllChildrenReady().then(() => {
this.recalculateDataViews();
this.recalculateFilters();
this.setupSubscriptions();
this.initialized$.next(true);
});
};
public setLastUsedDataViewId = (lastUsedDataViewId: string) => { public setLastUsedDataViewId = (lastUsedDataViewId: string) => {
this.lastUsedDataViewId = lastUsedDataViewId; this.lastUsedDataViewId = lastUsedDataViewId;
}; };

View file

@ -83,11 +83,6 @@ export function DashboardApp({
customBranding, customBranding,
} = pluginServices.getServices(); } = pluginServices.getServices();
const showPlainSpinner = useObservable(customBranding.hasCustomBranding$, false); const showPlainSpinner = useObservable(customBranding.hasCustomBranding$, false);
const incomingEmbeddable = getStateTransfer().getIncomingEmbeddablePackage(
DASHBOARD_APP_ID,
true
);
const { scopedHistory: getScopedHistory } = useDashboardMountContext(); const { scopedHistory: getScopedHistory } = useDashboardMountContext();
useExecutionContext(executionContext, { useExecutionContext(executionContext, {
@ -125,13 +120,28 @@ export function DashboardApp({
/** /**
* Create options to pass into the dashboard renderer * Create options to pass into the dashboard renderer
*/ */
const stateFromLocator = loadDashboardHistoryLocationState(getScopedHistory);
const getCreationOptions = useCallback((): Promise<DashboardCreationOptions> => { const getCreationOptions = useCallback((): Promise<DashboardCreationOptions> => {
const initialUrlState = loadAndRemoveDashboardState(kbnUrlStateStorage);
const searchSessionIdFromURL = getSearchSessionIdFromURL(history); const searchSessionIdFromURL = getSearchSessionIdFromURL(history);
const getInitialInput = () => {
const stateFromLocator = loadDashboardHistoryLocationState(getScopedHistory);
const initialUrlState = loadAndRemoveDashboardState(kbnUrlStateStorage);
return Promise.resolve({ // Override all state with URL + Locator input
incomingEmbeddable, return {
// State loaded from the dashboard app URL and from the locator overrides all other dashboard state.
...initialUrlState,
...stateFromLocator,
// if print mode is active, force viewMode.PRINT
...(isScreenshotMode() && getScreenshotContext('layout') === 'print'
? { viewMode: ViewMode.PRINT }
: {}),
};
};
return Promise.resolve<DashboardCreationOptions>({
getIncomingEmbeddable: () =>
getStateTransfer().getIncomingEmbeddablePackage(DASHBOARD_APP_ID, true),
// integrations // integrations
useControlGroupIntegration: true, useControlGroupIntegration: true,
@ -148,28 +158,16 @@ export function DashboardApp({
getSearchSessionIdFromURL: () => getSearchSessionIdFromURL(history), getSearchSessionIdFromURL: () => getSearchSessionIdFromURL(history),
removeSessionIdFromUrl: () => removeSearchSessionIdFromURL(kbnUrlStateStorage), removeSessionIdFromUrl: () => removeSearchSessionIdFromURL(kbnUrlStateStorage),
}, },
getInitialInput,
// Override all state with URL + Locator input
initialInput: {
// State loaded from the dashboard app URL and from the locator overrides all other dashboard state.
...initialUrlState,
...stateFromLocator,
// if print mode is active, force viewMode.PRINT
...(isScreenshotMode() && getScreenshotContext('layout') === 'print'
? { viewMode: ViewMode.PRINT }
: {}),
},
validateLoadedSavedObject: validateOutcome, validateLoadedSavedObject: validateOutcome,
}); });
}, [ }, [
history, history,
validateOutcome, validateOutcome,
stateFromLocator, getScopedHistory,
isScreenshotMode, isScreenshotMode,
getStateTransfer,
kbnUrlStateStorage, kbnUrlStateStorage,
incomingEmbeddable,
getScreenshotContext, getScreenshotContext,
]); ]);
@ -183,7 +181,7 @@ export function DashboardApp({
dashboardAPI, dashboardAPI,
}); });
return () => stopWatchingAppStateInUrl(); return () => stopWatchingAppStateInUrl();
}, [dashboardAPI, kbnUrlStateStorage]); }, [dashboardAPI, kbnUrlStateStorage, savedDashboardId]);
return ( return (
<div className="dshAppWrapper"> <div className="dshAppWrapper">

View file

@ -9,13 +9,13 @@
import './_dashboard_app.scss'; import './_dashboard_app.scss';
import React from 'react'; import React from 'react';
import { History } from 'history';
import { parse, ParsedQuery } from 'query-string'; import { parse, ParsedQuery } from 'query-string';
import { render, unmountComponentAtNode } from 'react-dom'; import { render, unmountComponentAtNode } from 'react-dom';
import { Switch, RouteComponentProps, HashRouter, Redirect } from 'react-router-dom'; import { Switch, RouteComponentProps, HashRouter, Redirect } from 'react-router-dom';
import { Route } from '@kbn/shared-ux-router'; import { Route } from '@kbn/shared-ux-router';
import { I18nProvider } from '@kbn/i18n-react'; import { I18nProvider } from '@kbn/i18n-react';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { AppMountParameters, CoreSetup } from '@kbn/core/public'; import { AppMountParameters, CoreSetup } from '@kbn/core/public';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { createKbnUrlStateStorage, withNotifyOnErrors } from '@kbn/kibana-utils-plugin/public'; import { createKbnUrlStateStorage, withNotifyOnErrors } from '@kbn/kibana-utils-plugin/public';
@ -56,6 +56,7 @@ export async function mountApp({ core, element, appUnMounted, mountContext }: Da
chrome: { setBadge, docTitle, setHelpExtension }, chrome: { setBadge, docTitle, setHelpExtension },
dashboardCapabilities: { showWriteControls }, dashboardCapabilities: { showWriteControls },
documentationLinks: { dashboardDocLink }, documentationLinks: { dashboardDocLink },
application: { navigateToApp },
settings: { uiSettings }, settings: { uiSettings },
data: dataStart, data: dataStart,
notifications, notifications,
@ -63,7 +64,6 @@ export async function mountApp({ core, element, appUnMounted, mountContext }: Da
} = pluginServices.getServices(); } = pluginServices.getServices();
let globalEmbedSettings: DashboardEmbedSettings | undefined; let globalEmbedSettings: DashboardEmbedSettings | undefined;
let routerHistory: History;
const getUrlStateStorage = (history: RouteComponentProps['history']) => const getUrlStateStorage = (history: RouteComponentProps['history']) =>
createKbnUrlStateStorage({ createKbnUrlStateStorage({
@ -73,17 +73,17 @@ export async function mountApp({ core, element, appUnMounted, mountContext }: Da
}); });
const redirect = (redirectTo: RedirectToProps) => { const redirect = (redirectTo: RedirectToProps) => {
if (!routerHistory) return; let path;
const historyFunction = redirectTo.useReplace ? routerHistory.replace : routerHistory.push; let state;
let destination;
if (redirectTo.destination === 'dashboard') { if (redirectTo.destination === 'dashboard') {
destination = redirectTo.id path = redirectTo.id ? createDashboardEditUrl(redirectTo.id) : CREATE_NEW_DASHBOARD_URL;
? createDashboardEditUrl(redirectTo.id, redirectTo.editMode) if (redirectTo.editMode) {
: CREATE_NEW_DASHBOARD_URL; state = { viewMode: ViewMode.EDIT };
}
} else { } else {
destination = createDashboardListingFilterUrl(redirectTo.filter); path = createDashboardListingFilterUrl(redirectTo.filter);
} }
historyFunction(destination); navigateToApp(DASHBOARD_APP_ID, { path: `#/${path}`, state, replace: redirectTo.useReplace });
}; };
const getDashboardEmbedSettings = ( const getDashboardEmbedSettings = (
@ -102,9 +102,6 @@ export async function mountApp({ core, element, appUnMounted, mountContext }: Da
if (routeParams.embed && !globalEmbedSettings) { if (routeParams.embed && !globalEmbedSettings) {
globalEmbedSettings = getDashboardEmbedSettings(routeParams); globalEmbedSettings = getDashboardEmbedSettings(routeParams);
} }
if (!routerHistory) {
routerHistory = routeProps.history;
}
return ( return (
<DashboardApp <DashboardApp
history={routeProps.history} history={routeProps.history}
@ -120,9 +117,6 @@ export async function mountApp({ core, element, appUnMounted, mountContext }: Da
const routeParams = parse(routeProps.history.location.search); const routeParams = parse(routeProps.history.location.search);
const title = (routeParams.title as string) || undefined; const title = (routeParams.title as string) || undefined;
const filter = (routeParams.filter as string) || undefined; const filter = (routeParams.filter as string) || undefined;
if (!routerHistory) {
routerHistory = routeProps.history;
}
return ( return (
<DashboardListingPage <DashboardListingPage
initialFilter={filter} initialFilter={filter}

View file

@ -82,8 +82,9 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr
const title = dashboard.select((state) => state.explicitInput.title); const title = dashboard.select((state) => state.explicitInput.title);
// store data views in state & subscribe to dashboard data view changes. // store data views in state & subscribe to dashboard data view changes.
const [allDataViews, setAllDataViews] = useState<DataView[]>(dashboard.getAllDataViews()); const [allDataViews, setAllDataViews] = useState<DataView[]>([]);
useEffect(() => { useEffect(() => {
setAllDataViews(dashboard.getAllDataViews());
const subscription = dashboard.onDataViewsUpdate$.subscribe((dataViews) => const subscription = dashboard.onDataViewsUpdate$.subscribe((dataViews) =>
setAllDataViews(dataViews) setAllDataViews(dataViews)
); );

View file

@ -11,7 +11,6 @@ import 'react-grid-layout/css/styles.css';
import { pick } from 'lodash'; import { pick } from 'lodash';
import classNames from 'classnames'; import classNames from 'classnames';
import { useEffectOnce } from 'react-use/lib';
import React, { useState, useMemo, useCallback, useEffect } from 'react'; import React, { useState, useMemo, useCallback, useEffect } from 'react';
import { Layout, Responsive as ResponsiveReactGridLayout } from 'react-grid-layout'; import { Layout, Responsive as ResponsiveReactGridLayout } from 'react-grid-layout';
@ -31,19 +30,20 @@ export const DashboardGrid = ({ viewportWidth }: { viewportWidth: number }) => {
const viewMode = dashboard.select((state) => state.explicitInput.viewMode); const viewMode = dashboard.select((state) => state.explicitInput.viewMode);
const useMargins = dashboard.select((state) => state.explicitInput.useMargins); const useMargins = dashboard.select((state) => state.explicitInput.useMargins);
const expandedPanelId = dashboard.select((state) => state.componentState.expandedPanelId); const expandedPanelId = dashboard.select((state) => state.componentState.expandedPanelId);
const animatePanelTransforms = dashboard.select(
(state) => state.componentState.animatePanelTransforms
);
// turn off panel transform animations for the first 500ms so that the dashboard doesn't animate on its first render. /**
const [animatePanelTransforms, setAnimatePanelTransforms] = useState(false); * Track panel maximized state delayed by one tick and use it to prevent
useEffectOnce(() => { * panel sliding animations on maximize and minimize.
setTimeout(() => setAnimatePanelTransforms(true), 500); */
}); const [delayedIsPanelExpanded, setDelayedIsPanelMaximized] = useState(false);
useEffect(() => { useEffect(() => {
if (expandedPanelId) { if (expandedPanelId) {
setAnimatePanelTransforms(false); setDelayedIsPanelMaximized(true);
} else { } else {
// delaying enabling CSS transforms to the next tick prevents a panel slide animation on minimize setTimeout(() => setDelayedIsPanelMaximized(false), 0);
setTimeout(() => setAnimatePanelTransforms(true), 0);
} }
}, [expandedPanelId]); }, [expandedPanelId]);
@ -107,7 +107,7 @@ export const DashboardGrid = ({ viewportWidth }: { viewportWidth: number }) => {
'dshLayout-withoutMargins': !useMargins, 'dshLayout-withoutMargins': !useMargins,
'dshLayout--viewing': viewMode === ViewMode.VIEW, 'dshLayout--viewing': viewMode === ViewMode.VIEW,
'dshLayout--editing': viewMode !== ViewMode.VIEW, 'dshLayout--editing': viewMode !== ViewMode.VIEW,
'dshLayout--noAnimation': !animatePanelTransforms || expandedPanelId, 'dshLayout--noAnimation': !animatePanelTransforms || delayedIsPanelExpanded,
'dshLayout-isMaximizedPanel': expandedPanelId !== undefined, 'dshLayout-isMaximizedPanel': expandedPanelId !== undefined,
}); });

View file

@ -102,7 +102,6 @@ export function runSaveAs(this: DashboardContainer) {
this.dispatch.setLastSavedInput(stateToSave); this.dispatch.setLastSavedInput(stateToSave);
}); });
} }
if (newCopyOnSave || !lastSavedId) this.expectIdChange();
resolve(saveResult); resolve(saveResult);
return saveResult; return saveResult;
}; };
@ -175,10 +174,7 @@ export async function runClone(this: DashboardContainer) {
saveOptions: { saveAsCopy: true }, saveOptions: { saveAsCopy: true },
currentState: { ...currentState, title: newTitle }, currentState: { ...currentState, title: newTitle },
}); });
this.dispatch.setTitle(newTitle);
resolve(saveResult); resolve(saveResult);
this.expectIdChange();
return saveResult.id ? { id: saveResult.id } : { error: saveResult.error }; return saveResult.id ? { id: saveResult.id } : { error: saveResult.error };
}; };
showCloneModal({ onClone, title: currentState.title, onClose: () => resolve(undefined) }); showCloneModal({ onClone, title: currentState.title, onClose: () => resolve(undefined) });

View file

@ -50,7 +50,7 @@ export function startSyncingDashboardControlGroup(this: DashboardContainer) {
chainingSystem: deepEqual, chainingSystem: deepEqual,
ignoreParentSettings: deepEqual, ignoreParentSettings: deepEqual,
}; };
this.subscriptions.add( this.integrationSubscriptions.add(
this.controlGroup this.controlGroup
.getInput$() .getInput$()
.pipe( .pipe(
@ -83,7 +83,7 @@ export function startSyncingDashboardControlGroup(this: DashboardContainer) {
}; };
// pass down any pieces of input needed to refetch or force refetch data for the controls // pass down any pieces of input needed to refetch or force refetch data for the controls
this.subscriptions.add( this.integrationSubscriptions.add(
(this.getInput$() as Readonly<Observable<DashboardContainerInput>>) (this.getInput$() as Readonly<Observable<DashboardContainerInput>>)
.pipe( .pipe(
distinctUntilChanged((a, b) => distinctUntilChanged((a, b) =>
@ -106,7 +106,7 @@ export function startSyncingDashboardControlGroup(this: DashboardContainer) {
); );
// when control group outputs filters, force a refresh! // when control group outputs filters, force a refresh!
this.subscriptions.add( this.integrationSubscriptions.add(
this.controlGroup this.controlGroup
.getOutput$() .getOutput$()
.pipe( .pipe(
@ -118,7 +118,7 @@ export function startSyncingDashboardControlGroup(this: DashboardContainer) {
.subscribe(() => this.forceRefresh(false)) // we should not reload the control group when the control group output changes - otherwise, performance is severely impacted .subscribe(() => this.forceRefresh(false)) // we should not reload the control group when the control group output changes - otherwise, performance is severely impacted
); );
this.subscriptions.add( this.integrationSubscriptions.add(
this.controlGroup this.controlGroup
.getOutput$() .getOutput$()
.pipe( .pipe(
@ -134,7 +134,7 @@ export function startSyncingDashboardControlGroup(this: DashboardContainer) {
); );
// the Control Group needs to know when any dashboard children are loading in order to know when to move on to the next time slice when playing. // the Control Group needs to know when any dashboard children are loading in order to know when to move on to the next time slice when playing.
this.subscriptions.add( this.integrationSubscriptions.add(
this.getAnyChildOutputChange$().subscribe(() => { this.getAnyChildOutputChange$().subscribe(() => {
if (!this.controlGroup) { if (!this.controlGroup) {
return; return;

View file

@ -30,14 +30,12 @@ import { pluginServices } from '../../../services/plugin_services';
import { DashboardCreationOptions } from '../dashboard_container_factory'; import { DashboardCreationOptions } from '../dashboard_container_factory';
import { DEFAULT_DASHBOARD_INPUT } from '../../../dashboard_constants'; import { DEFAULT_DASHBOARD_INPUT } from '../../../dashboard_constants';
const embeddableId = 'create-dat-dashboard';
test('throws error when no data views are available', async () => { test('throws error when no data views are available', async () => {
pluginServices.getServices().data.dataViews.getDefaultDataView = jest pluginServices.getServices().data.dataViews.getDefaultDataView = jest
.fn() .fn()
.mockReturnValue(undefined); .mockReturnValue(undefined);
await expect(async () => { await expect(async () => {
await createDashboard(embeddableId); await createDashboard();
}).rejects.toThrow('Dashboard requires at least one data view before it can be initialized.'); }).rejects.toThrow('Dashboard requires at least one data view before it can be initialized.');
// reset get default data view // reset get default data view
@ -49,7 +47,7 @@ test('throws error when provided validation function returns invalid', async ()
validateLoadedSavedObject: jest.fn().mockImplementation(() => false), validateLoadedSavedObject: jest.fn().mockImplementation(() => false),
}; };
await expect(async () => { await expect(async () => {
await createDashboard(embeddableId, creationOptions, 0, 'test-id'); await createDashboard(creationOptions, 0, 'test-id');
}).rejects.toThrow('Dashboard failed saved object result validation'); }).rejects.toThrow('Dashboard failed saved object result validation');
}); });
@ -62,7 +60,7 @@ test('pulls state from dashboard saved object when given a saved object id', asy
description: `wow would you look at that? Wow.`, description: `wow would you look at that? Wow.`,
}, },
}); });
const dashboard = await createDashboard(embeddableId, {}, 0, 'wow-such-id'); const dashboard = await createDashboard({}, 0, 'wow-such-id');
expect( expect(
pluginServices.getServices().dashboardSavedObject.loadDashboardStateFromSavedObject pluginServices.getServices().dashboardSavedObject.loadDashboardStateFromSavedObject
).toHaveBeenCalledWith({ id: 'wow-such-id' }); ).toHaveBeenCalledWith({ id: 'wow-such-id' });
@ -81,12 +79,7 @@ test('pulls state from session storage which overrides state from saved object',
pluginServices.getServices().dashboardSessionStorage.getState = jest pluginServices.getServices().dashboardSessionStorage.getState = jest
.fn() .fn()
.mockReturnValue({ description: 'wow this description marginally better' }); .mockReturnValue({ description: 'wow this description marginally better' });
const dashboard = await createDashboard( const dashboard = await createDashboard({ useSessionStorageIntegration: true }, 0, 'wow-such-id');
embeddableId,
{ useSessionStorageIntegration: true },
0,
'wow-such-id'
);
expect(dashboard.getState().explicitInput.description).toBe( expect(dashboard.getState().explicitInput.description).toBe(
'wow this description marginally better' 'wow this description marginally better'
); );
@ -105,10 +98,9 @@ test('pulls state from creation options initial input which overrides all other
.fn() .fn()
.mockReturnValue({ description: 'wow this description marginally better' }); .mockReturnValue({ description: 'wow this description marginally better' });
const dashboard = await createDashboard( const dashboard = await createDashboard(
embeddableId,
{ {
useSessionStorageIntegration: true, useSessionStorageIntegration: true,
initialInput: { description: 'wow this description is a masterpiece' }, getInitialInput: () => ({ description: 'wow this description is a masterpiece' }),
}, },
0, 0,
'wow-such-id' 'wow-such-id'
@ -123,12 +115,12 @@ test('applies filters and query from state to query service', async () => {
{ meta: { alias: 'test', disabled: false, negate: false, index: 'test' } }, { meta: { alias: 'test', disabled: false, negate: false, index: 'test' } },
]; ];
const query = { language: 'kql', query: 'query' }; const query = { language: 'kql', query: 'query' };
await createDashboard(embeddableId, { await createDashboard({
useUnifiedSearchIntegration: true, useUnifiedSearchIntegration: true,
unifiedSearchSettings: { unifiedSearchSettings: {
kbnUrlStateStorage: createKbnUrlStateStorage(), kbnUrlStateStorage: createKbnUrlStateStorage(),
}, },
initialInput: { filters, query }, getInitialInput: () => ({ filters, query }),
}); });
expect(pluginServices.getServices().data.query.queryString.setQuery).toHaveBeenCalledWith(query); expect(pluginServices.getServices().data.query.queryString.setQuery).toHaveBeenCalledWith(query);
expect(pluginServices.getServices().data.query.filterManager.setAppFilters).toHaveBeenCalledWith( expect(pluginServices.getServices().data.query.filterManager.setAppFilters).toHaveBeenCalledWith(
@ -139,12 +131,12 @@ test('applies filters and query from state to query service', async () => {
test('applies time range and refresh interval from initial input to query service if time restore is on', async () => { test('applies time range and refresh interval from initial input to query service if time restore is on', async () => {
const timeRange = { from: new Date().toISOString(), to: new Date().toISOString() }; const timeRange = { from: new Date().toISOString(), to: new Date().toISOString() };
const refreshInterval = { pause: false, value: 42 }; const refreshInterval = { pause: false, value: 42 };
await createDashboard(embeddableId, { await createDashboard({
useUnifiedSearchIntegration: true, useUnifiedSearchIntegration: true,
unifiedSearchSettings: { unifiedSearchSettings: {
kbnUrlStateStorage: createKbnUrlStateStorage(), kbnUrlStateStorage: createKbnUrlStateStorage(),
}, },
initialInput: { timeRange, refreshInterval, timeRestore: true }, getInitialInput: () => ({ timeRange, refreshInterval, timeRestore: true }),
}); });
expect( expect(
pluginServices.getServices().data.query.timefilter.timefilter.setTime pluginServices.getServices().data.query.timefilter.timefilter.setTime
@ -159,7 +151,7 @@ test('applied time range from query service to initial input if time restore is
pluginServices.getServices().data.query.timefilter.timefilter.getTime = jest pluginServices.getServices().data.query.timefilter.timefilter.getTime = jest
.fn() .fn()
.mockReturnValue(timeRange); .mockReturnValue(timeRange);
const dashboard = await createDashboard(embeddableId, { const dashboard = await createDashboard({
useUnifiedSearchIntegration: true, useUnifiedSearchIntegration: true,
unifiedSearchSettings: { unifiedSearchSettings: {
kbnUrlStateStorage: createKbnUrlStateStorage(), kbnUrlStateStorage: createKbnUrlStateStorage(),
@ -177,9 +169,9 @@ test('replaces panel with incoming embeddable if id matches existing panel', asy
} as ContactCardEmbeddableInput, } as ContactCardEmbeddableInput,
embeddableId: 'i_match', embeddableId: 'i_match',
}; };
const dashboard = await createDashboard(embeddableId, { const dashboard = await createDashboard({
incomingEmbeddable, getIncomingEmbeddable: () => incomingEmbeddable,
initialInput: { getInitialInput: () => ({
panels: { panels: {
i_match: getSampleDashboardPanel<ContactCardEmbeddableInput>({ i_match: getSampleDashboardPanel<ContactCardEmbeddableInput>({
explicitInput: { explicitInput: {
@ -189,7 +181,7 @@ test('replaces panel with incoming embeddable if id matches existing panel', asy
type: CONTACT_CARD_EMBEDDABLE, type: CONTACT_CARD_EMBEDDABLE,
}), }),
}, },
}, }),
}); });
expect(dashboard.getState().explicitInput.panels.i_match.explicitInput).toStrictEqual( expect(dashboard.getState().explicitInput.panels.i_match.explicitInput).toStrictEqual(
expect.objectContaining({ expect.objectContaining({
@ -216,9 +208,9 @@ test('creates new embeddable with incoming embeddable if id does not match exist
.fn() .fn()
.mockReturnValue(mockContactCardFactory); .mockReturnValue(mockContactCardFactory);
await createDashboard(embeddableId, { await createDashboard({
incomingEmbeddable, getIncomingEmbeddable: () => incomingEmbeddable,
initialInput: { getInitialInput: () => ({
panels: { panels: {
i_do_not_match: getSampleDashboardPanel<ContactCardEmbeddableInput>({ i_do_not_match: getSampleDashboardPanel<ContactCardEmbeddableInput>({
explicitInput: { explicitInput: {
@ -228,7 +220,7 @@ test('creates new embeddable with incoming embeddable if id does not match exist
type: CONTACT_CARD_EMBEDDABLE, type: CONTACT_CARD_EMBEDDABLE,
}), }),
}, },
}, }),
}); });
// flush promises // flush promises
@ -258,11 +250,11 @@ test('creates a control group from the control group factory and waits for it to
pluginServices.getServices().embeddable.getEmbeddableFactory = jest pluginServices.getServices().embeddable.getEmbeddableFactory = jest
.fn() .fn()
.mockReturnValue(mockControlGroupFactory); .mockReturnValue(mockControlGroupFactory);
await createDashboard(embeddableId, { await createDashboard({
useControlGroupIntegration: true, useControlGroupIntegration: true,
initialInput: { getInitialInput: () => ({
controlGroupInput: { controlStyle: 'twoLine' } as unknown as ControlGroupInput, controlGroupInput: { controlStyle: 'twoLine' } as unknown as ControlGroupInput,
}, }),
}); });
// flush promises // flush promises
await new Promise((r) => setTimeout(r, 1)); await new Promise((r) => setTimeout(r, 1));
@ -302,7 +294,7 @@ test('searchSessionId is updated prior to child embeddable parent subscription e
sessionCount++; sessionCount++;
return `searchSessionId${sessionCount}`; return `searchSessionId${sessionCount}`;
}; };
const dashboard = await createDashboard(embeddableId, { const dashboard = await createDashboard({
searchSessionSettings: { searchSessionSettings: {
getSearchSessionIdFromURL: () => undefined, getSearchSessionIdFromURL: () => undefined,
removeSessionIdFromUrl: () => {}, removeSessionIdFromUrl: () => {},

View file

@ -27,47 +27,21 @@ import { startSyncingDashboardDataViews } from './data_views/sync_dashboard_data
import { syncUnifiedSearchState } from './unified_search/sync_dashboard_unified_search_state'; import { syncUnifiedSearchState } from './unified_search/sync_dashboard_unified_search_state';
import { startSyncingDashboardControlGroup } from './controls/dashboard_control_group_integration'; import { startSyncingDashboardControlGroup } from './controls/dashboard_control_group_integration';
import { startDashboardSearchSessionIntegration } from './search_sessions/start_dashboard_search_session_integration'; import { startDashboardSearchSessionIntegration } from './search_sessions/start_dashboard_search_session_integration';
import { LoadDashboardFromSavedObjectReturn } from '../../../services/dashboard_saved_object/lib/load_dashboard_state_from_saved_object';
/** /**
* * Builds a new Dashboard from scratch.
* @param creationOptions
*/ */
export const createDashboard = async ( export const createDashboard = async (
embeddableId: string,
creationOptions?: DashboardCreationOptions, creationOptions?: DashboardCreationOptions,
dashboardCreationStartTime?: number, dashboardCreationStartTime?: number,
savedObjectId?: string savedObjectId?: string
): Promise<DashboardContainer> => { ): Promise<DashboardContainer> => {
// --------------------------------------------------------------------------------------
// Unpack services & Options
// --------------------------------------------------------------------------------------
const { const {
dashboardSessionStorage, data: { dataViews },
embeddable: { getEmbeddableFactory },
data: {
dataViews,
query: queryService,
search: { session },
},
dashboardSavedObject: { loadDashboardStateFromSavedObject }, dashboardSavedObject: { loadDashboardStateFromSavedObject },
} = pluginServices.getServices(); } = pluginServices.getServices();
const {
queryString,
filterManager,
timefilter: { timefilter: timefilterService },
} = queryService;
const {
searchSessionSettings,
unifiedSearchSettings,
validateLoadedSavedObject,
useControlGroupIntegration,
useUnifiedSearchIntegration,
initialInput: overrideInput,
useSessionStorageIntegration,
} = creationOptions ?? {};
// -------------------------------------------------------------------------------------- // --------------------------------------------------------------------------------------
// Create method which allows work to be done on the dashboard container when it's ready. // Create method which allows work to be done on the dashboard container when it's ready.
// -------------------------------------------------------------------------------------- // --------------------------------------------------------------------------------------
@ -83,12 +57,9 @@ export const createDashboard = async (
// -------------------------------------------------------------------------------------- // --------------------------------------------------------------------------------------
// Lazy load required systems and Dashboard saved object. // Lazy load required systems and Dashboard saved object.
// -------------------------------------------------------------------------------------- // --------------------------------------------------------------------------------------
const reduxEmbeddablePackagePromise = lazyLoadReduxToolsPackage(); const reduxEmbeddablePackagePromise = lazyLoadReduxToolsPackage();
const defaultDataViewAssignmentPromise = dataViews.getDefaultDataView(); const defaultDataViewAssignmentPromise = dataViews.getDefaultDataView();
const dashboardSavedObjectPromise = savedObjectId const dashboardSavedObjectPromise = loadDashboardStateFromSavedObject({ id: savedObjectId });
? loadDashboardStateFromSavedObject({ id: savedObjectId })
: Promise.resolve(undefined);
const [reduxEmbeddablePackage, savedObjectResult, defaultDataView] = await Promise.all([ const [reduxEmbeddablePackage, savedObjectResult, defaultDataView] = await Promise.all([
reduxEmbeddablePackagePromise, reduxEmbeddablePackagePromise,
@ -96,17 +67,82 @@ export const createDashboard = async (
defaultDataViewAssignmentPromise, defaultDataViewAssignmentPromise,
]); ]);
// --------------------------------------------------------------------------------------
// Run validations.
// --------------------------------------------------------------------------------------
if (!defaultDataView) { if (!defaultDataView) {
throw new Error('Dashboard requires at least one data view before it can be initialized.'); throw new Error('Dashboard requires at least one data view before it can be initialized.');
} }
// --------------------------------------------------------------------------------------
// Initialize Dashboard integrations
// --------------------------------------------------------------------------------------
const { input, searchSessionId } = await initializeDashboard({
loadDashboardReturn: savedObjectResult,
untilDashboardReady,
creationOptions,
});
// --------------------------------------------------------------------------------------
// Build and return the dashboard container.
// --------------------------------------------------------------------------------------
const dashboardContainer = new DashboardContainer(
input,
reduxEmbeddablePackage,
searchSessionId,
savedObjectResult?.dashboardInput,
dashboardCreationStartTime,
undefined,
creationOptions,
savedObjectId
);
dashboardContainerReady$.next(dashboardContainer);
return dashboardContainer;
};
/**
* Initializes a Dashboard and starts all of its integrations
*/
export const initializeDashboard = async ({
loadDashboardReturn,
untilDashboardReady,
creationOptions,
controlGroup,
}: {
loadDashboardReturn: LoadDashboardFromSavedObjectReturn;
untilDashboardReady: () => Promise<DashboardContainer>;
creationOptions?: DashboardCreationOptions;
controlGroup?: ControlGroupContainer;
}) => {
const {
dashboardSessionStorage,
embeddable: { getEmbeddableFactory },
data: {
query: queryService,
search: { session },
},
} = pluginServices.getServices();
const {
queryString,
filterManager,
timefilter: { timefilter: timefilterService },
} = queryService;
const {
getInitialInput,
searchSessionSettings,
unifiedSearchSettings,
validateLoadedSavedObject,
useControlGroupIntegration,
useUnifiedSearchIntegration,
useSessionStorageIntegration,
} = creationOptions ?? {};
const overrideInput = getInitialInput?.();
// --------------------------------------------------------------------------------------
// Run validation.
// --------------------------------------------------------------------------------------
if ( if (
savedObjectResult && loadDashboardReturn &&
validateLoadedSavedObject && validateLoadedSavedObject &&
!validateLoadedSavedObject(savedObjectResult) !validateLoadedSavedObject(loadDashboardReturn)
) { ) {
throw new Error('Dashboard failed saved object result validation'); throw new Error('Dashboard failed saved object result validation');
} }
@ -116,7 +152,7 @@ export const createDashboard = async (
// -------------------------------------------------------------------------------------- // --------------------------------------------------------------------------------------
const sessionStorageInput = ((): Partial<DashboardContainerInput> | undefined => { const sessionStorageInput = ((): Partial<DashboardContainerInput> | undefined => {
if (!useSessionStorageIntegration) return; if (!useSessionStorageIntegration) return;
return dashboardSessionStorage.getState(savedObjectId); return dashboardSessionStorage.getState(loadDashboardReturn.dashboardId);
})(); })();
// -------------------------------------------------------------------------------------- // --------------------------------------------------------------------------------------
@ -124,10 +160,9 @@ export const createDashboard = async (
// -------------------------------------------------------------------------------------- // --------------------------------------------------------------------------------------
const initialInput: DashboardContainerInput = cloneDeep({ const initialInput: DashboardContainerInput = cloneDeep({
...DEFAULT_DASHBOARD_INPUT, ...DEFAULT_DASHBOARD_INPUT,
...(savedObjectResult?.dashboardInput ?? {}), ...(loadDashboardReturn?.dashboardInput ?? {}),
...sessionStorageInput, ...sessionStorageInput,
...overrideInput, ...overrideInput,
id: embeddableId,
}); });
initialInput.executionContext = { initialInput.executionContext = {
@ -166,8 +201,7 @@ export const createDashboard = async (
} }
untilDashboardReady().then((dashboardContainer) => { untilDashboardReady().then((dashboardContainer) => {
const stopSyncingUnifiedSearchState = const stopSyncingUnifiedSearchState = syncUnifiedSearchState.bind(dashboardContainer)();
syncUnifiedSearchState.bind(dashboardContainer)(kbnUrlStateStorage);
dashboardContainer.stopSyncingWithUnifiedSearch = () => { dashboardContainer.stopSyncingWithUnifiedSearch = () => {
stopSyncingUnifiedSearchState(); stopSyncingUnifiedSearchState();
stopSyncingQueryServiceStateWithUrl(); stopSyncingQueryServiceStateWithUrl();
@ -178,16 +212,20 @@ export const createDashboard = async (
// -------------------------------------------------------------------------------------- // --------------------------------------------------------------------------------------
// Place the incoming embeddable if there is one // Place the incoming embeddable if there is one
// -------------------------------------------------------------------------------------- // --------------------------------------------------------------------------------------
const incomingEmbeddable = creationOptions?.incomingEmbeddable; const incomingEmbeddable = creationOptions?.getIncomingEmbeddable?.();
if (incomingEmbeddable) { if (incomingEmbeddable) {
initialInput.viewMode = ViewMode.EDIT; // view mode must always be edit to recieve an embeddable. const scrolltoIncomingEmbeddable = (container: DashboardContainer, id: string) => {
container.setScrollToPanelId(id);
container.setHighlightPanelId(id);
};
const panelExists = initialInput.viewMode = ViewMode.EDIT; // view mode must always be edit to recieve an embeddable.
if (
incomingEmbeddable.embeddableId && incomingEmbeddable.embeddableId &&
Boolean(initialInput.panels[incomingEmbeddable.embeddableId]); Boolean(initialInput.panels[incomingEmbeddable.embeddableId])
if (panelExists) { ) {
// this embeddable already exists, we will update the explicit input. // this embeddable already exists, we will update the explicit input.
const panelToUpdate = initialInput.panels[incomingEmbeddable.embeddableId as string]; const panelToUpdate = initialInput.panels[incomingEmbeddable.embeddableId];
const sameType = panelToUpdate.type === incomingEmbeddable.type; const sameType = panelToUpdate.type === incomingEmbeddable.type;
panelToUpdate.type = incomingEmbeddable.type; panelToUpdate.type = incomingEmbeddable.type;
@ -196,22 +234,24 @@ export const createDashboard = async (
...(sameType ? panelToUpdate.explicitInput : {}), ...(sameType ? panelToUpdate.explicitInput : {}),
...incomingEmbeddable.input, ...incomingEmbeddable.input,
id: incomingEmbeddable.embeddableId as string, id: incomingEmbeddable.embeddableId,
// maintain hide panel titles setting. // maintain hide panel titles setting.
hidePanelTitles: panelToUpdate.explicitInput.hidePanelTitles, hidePanelTitles: panelToUpdate.explicitInput.hidePanelTitles,
}; };
untilDashboardReady().then((container) =>
scrolltoIncomingEmbeddable(container, incomingEmbeddable.embeddableId as string)
);
} else { } else {
// otherwise this incoming embeddable is brand new and can be added via the default method after the dashboard container is created. // otherwise this incoming embeddable is brand new and can be added via the default method after the dashboard container is created.
untilDashboardReady().then(async (container) => { untilDashboardReady().then(async (container) => {
container.addNewEmbeddable(incomingEmbeddable.type, incomingEmbeddable.input); const embeddable = await container.addNewEmbeddable(
incomingEmbeddable.type,
incomingEmbeddable.input
);
scrolltoIncomingEmbeddable(container, embeddable.id);
}); });
} }
untilDashboardReady().then(async (container) => {
container.setScrollToPanelId(incomingEmbeddable.embeddableId);
container.setHighlightPanelId(incomingEmbeddable.embeddableId);
});
} }
// -------------------------------------------------------------------------------------- // --------------------------------------------------------------------------------------
@ -251,7 +291,7 @@ export const createDashboard = async (
ControlGroupContainer ControlGroupContainer
>(CONTROL_GROUP_TYPE); >(CONTROL_GROUP_TYPE);
const { filters, query, timeRange, viewMode, controlGroupInput, id } = initialInput; const { filters, query, timeRange, viewMode, controlGroupInput, id } = initialInput;
const controlGroup = await controlsGroupFactory?.create({ const fullControlGroupInput = {
id: `control_group_${id ?? 'new_dashboard'}`, id: `control_group_${id ?? 'new_dashboard'}`,
...getDefaultControlGroupInput(), ...getDefaultControlGroupInput(),
...pickBy(controlGroupInput, identity), // undefined keys in initialInput should not overwrite defaults ...pickBy(controlGroupInput, identity), // undefined keys in initialInput should not overwrite defaults
@ -259,9 +299,15 @@ export const createDashboard = async (
viewMode, viewMode,
filters, filters,
query, query,
}); };
if (!controlGroup || isErrorEmbeddable(controlGroup)) { if (controlGroup) {
throw new Error('Error in control group startup'); controlGroup.updateInputAndReinitialize(fullControlGroupInput);
} else {
const newControlGroup = await controlsGroupFactory?.create(fullControlGroupInput);
if (!newControlGroup || isErrorEmbeddable(newControlGroup)) {
throw new Error('Error in control group startup');
}
controlGroup = newControlGroup;
} }
untilDashboardReady().then((dashboardContainer) => { untilDashboardReady().then((dashboardContainer) => {
@ -275,22 +321,17 @@ export const createDashboard = async (
// Start the data views integration. // Start the data views integration.
// -------------------------------------------------------------------------------------- // --------------------------------------------------------------------------------------
untilDashboardReady().then((dashboardContainer) => { untilDashboardReady().then((dashboardContainer) => {
dashboardContainer.subscriptions.add(startSyncingDashboardDataViews.bind(dashboardContainer)()); dashboardContainer.integrationSubscriptions.add(
startSyncingDashboardDataViews.bind(dashboardContainer)()
);
}); });
// -------------------------------------------------------------------------------------- // --------------------------------------------------------------------------------------
// Build and return the dashboard container. // Start animating panel transforms 500 ms after dashboard is created.
// -------------------------------------------------------------------------------------- // --------------------------------------------------------------------------------------
const dashboardContainer = new DashboardContainer( untilDashboardReady().then((dashboard) =>
initialInput, setTimeout(() => dashboard.dispatch.setAnimatePanelTransforms(true), 500)
reduxEmbeddablePackage,
initialSearchSessionId,
savedObjectResult?.dashboardInput,
dashboardCreationStartTime,
undefined,
creationOptions,
savedObjectId
); );
dashboardContainerReady$.next(dashboardContainer);
return dashboardContainer; return { input: initialInput, searchSessionId: initialSearchSessionId };
}; };

View file

@ -86,5 +86,5 @@ export function startDashboardSearchSessionIntegration(
} }
}); });
this.subscriptions.add(searchSessionIdChangeSubscription); this.integrationSubscriptions.add(searchSessionIdChangeSubscription);
} }

View file

@ -10,7 +10,6 @@ import { Subject } from 'rxjs';
import { distinctUntilChanged, finalize, switchMap, tap } from 'rxjs/operators'; import { distinctUntilChanged, finalize, switchMap, tap } from 'rxjs/operators';
import type { Filter, Query } from '@kbn/es-query'; import type { Filter, Query } from '@kbn/es-query';
import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import { cleanFiltersForSerialize } from '@kbn/presentation-util-plugin/public'; import { cleanFiltersForSerialize } from '@kbn/presentation-util-plugin/public';
import { connectToQueryState, waitUntilNextSessionCompletes$ } from '@kbn/data-plugin/public'; import { connectToQueryState, waitUntilNextSessionCompletes$ } from '@kbn/data-plugin/public';
@ -21,10 +20,7 @@ import { pluginServices } from '../../../../services/plugin_services';
* Sets up syncing and subscriptions between the filter state from the Data plugin * Sets up syncing and subscriptions between the filter state from the Data plugin
* and the dashboard Redux store. * and the dashboard Redux store.
*/ */
export function syncUnifiedSearchState( export function syncUnifiedSearchState(this: DashboardContainer) {
this: DashboardContainer,
kbnUrlStateStorage: IKbnUrlStateStorage
) {
const { const {
data: { query: queryService, search }, data: { query: queryService, search },
} = pluginServices.getServices(); } = pluginServices.getServices();

View file

@ -6,9 +6,10 @@
* Side Public License, v 1. * Side Public License, v 1.
*/ */
import React, { createContext, useContext } from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { batch } from 'react-redux';
import { Subject, Subscription } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
import React, { createContext, useContext } from 'react';
import { ReduxToolsPackage, ReduxEmbeddableTools } from '@kbn/presentation-util-plugin/public'; import { ReduxToolsPackage, ReduxEmbeddableTools } from '@kbn/presentation-util-plugin/public';
import { import {
@ -20,10 +21,10 @@ import {
type EmbeddableFactory, type EmbeddableFactory,
} from '@kbn/embeddable-plugin/public'; } from '@kbn/embeddable-plugin/public';
import { I18nProvider } from '@kbn/i18n-react'; import { I18nProvider } from '@kbn/i18n-react';
import { RefreshInterval } from '@kbn/data-plugin/public';
import type { Filter, TimeRange, Query } from '@kbn/es-query'; import type { Filter, TimeRange, Query } from '@kbn/es-query';
import type { DataView } from '@kbn/data-views-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/public';
import { reportPerformanceMetricEvent } from '@kbn/ebt-tools'; import { reportPerformanceMetricEvent } from '@kbn/ebt-tools';
import type { RefreshInterval } from '@kbn/data-plugin/public';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import type { ControlGroupContainer } from '@kbn/controls-plugin/public'; import type { ControlGroupContainer } from '@kbn/controls-plugin/public';
import type { KibanaExecutionContext, OverlayRef } from '@kbn/core/public'; import type { KibanaExecutionContext, OverlayRef } from '@kbn/core/public';
@ -44,6 +45,7 @@ import {
import { DASHBOARD_CONTAINER_TYPE } from '../..'; import { DASHBOARD_CONTAINER_TYPE } from '../..';
import { createPanelState } from '../component/panel'; import { createPanelState } from '../component/panel';
import { pluginServices } from '../../services/plugin_services'; import { pluginServices } from '../../services/plugin_services';
import { initializeDashboard } from './create/create_dashboard';
import { DashboardCreationOptions } from './dashboard_container_factory'; import { DashboardCreationOptions } from './dashboard_container_factory';
import { DashboardAnalyticsService } from '../../services/analytics/types'; import { DashboardAnalyticsService } from '../../services/analytics/types';
import { DashboardViewport } from '../component/viewport/dashboard_viewport'; import { DashboardViewport } from '../component/viewport/dashboard_viewport';
@ -93,7 +95,8 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
public dispatch: DashboardReduxEmbeddableTools['dispatch']; public dispatch: DashboardReduxEmbeddableTools['dispatch'];
public onStateChange: DashboardReduxEmbeddableTools['onStateChange']; public onStateChange: DashboardReduxEmbeddableTools['onStateChange'];
public subscriptions: Subscription = new Subscription(); public integrationSubscriptions: Subscription = new Subscription();
public diffingSubscription: Subscription = new Subscription();
public controlGroup?: ControlGroupContainer; public controlGroup?: ControlGroupContainer;
public searchSessionId?: string; public searchSessionId?: string;
@ -169,6 +172,7 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
...DEFAULT_DASHBOARD_INPUT, ...DEFAULT_DASHBOARD_INPUT,
id: initialInput.id, id: initialInput.id,
}, },
animatePanelTransforms: false, // set panel transforms to false initially to avoid panels animating on initial render.
hasUnsavedChanges: false, // if there is initial unsaved changes, the initial diff will catch them. hasUnsavedChanges: false, // if there is initial unsaved changes, the initial diff will catch them.
lastSavedId: savedObjectId, lastSavedId: savedObjectId,
}, },
@ -280,7 +284,8 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
super.destroy(); super.destroy();
this.cleanupStateTools(); this.cleanupStateTools();
this.controlGroup?.destroy(); this.controlGroup?.destroy();
this.subscriptions.unsubscribe(); this.diffingSubscription.unsubscribe();
this.integrationSubscriptions.unsubscribe();
this.stopSyncingWithUnifiedSearch?.(); this.stopSyncingWithUnifiedSearch?.();
if (this.domNode) ReactDOM.unmountComponentAtNode(this.domNode); if (this.domNode) ReactDOM.unmountComponentAtNode(this.domNode);
} }
@ -289,26 +294,6 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
// Dashboard API // Dashboard API
// ------------------------------------------------------------------------------------------------------ // ------------------------------------------------------------------------------------------------------
/**
* Sometimes when the ID changes, it's due to a clone operation, or a save as operation. In these cases,
* most of the state hasn't actually changed, so there isn't any reason to destroy this container and
* load up a fresh one. When an id change is in progress, the renderer can check this method, and if it returns
* true, the renderer can safely skip destroying and rebuilding the container.
*/
public isExpectingIdChange() {
return this.expectingIdChange;
}
private expectingIdChange = false;
public expectIdChange() {
/**
* this.expectingIdChange = true; TODO - re-enable this for saving speed-ups. It causes some functional test failures because the _g param is not carried over.
* See https://github.com/elastic/kibana/issues/147491 for more information.
**/
setTimeout(() => {
this.expectingIdChange = false;
}, 1); // turn this off after the next update.
}
public runClone = runClone; public runClone = runClone;
public runSaveAs = runSaveAs; public runSaveAs = runSaveAs;
public runQuickSave = runQuickSave; public runQuickSave = runQuickSave;
@ -361,6 +346,49 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
} }
} }
public navigateToDashboard = async (
newSavedObjectId?: string,
newCreationOptions?: Partial<DashboardCreationOptions>
) => {
this.integrationSubscriptions.unsubscribe();
this.integrationSubscriptions = new Subscription();
this.stopSyncingWithUnifiedSearch?.();
const {
dashboardSavedObject: { loadDashboardStateFromSavedObject },
} = pluginServices.getServices();
if (newCreationOptions) {
this.creationOptions = { ...this.creationOptions, ...newCreationOptions };
}
const loadDashboardReturn = await loadDashboardStateFromSavedObject({ id: newSavedObjectId });
const dashboardContainerReady$ = new Subject<DashboardContainer>();
const untilDashboardReady = () =>
new Promise<DashboardContainer>((resolve) => {
const subscription = dashboardContainerReady$.subscribe((container) => {
subscription.unsubscribe();
resolve(container);
});
});
const { input: newInput, searchSessionId } = await initializeDashboard({
creationOptions: this.creationOptions,
controlGroup: this.controlGroup,
untilDashboardReady,
loadDashboardReturn,
});
this.searchSessionId = searchSessionId;
this.updateInput(newInput);
batch(() => {
this.dispatch.setLastSavedInput(loadDashboardReturn?.dashboardInput);
this.dispatch.setAnimatePanelTransforms(false); // prevents panels from animating on navigate.
this.dispatch.setLastSavedId(newSavedObjectId);
});
dashboardContainerReady$.next(this);
};
/** /**
* Gets all the dataviews that are actively being used in the dashboard * Gets all the dataviews that are actively being used in the dashboard
* @returns An array of dataviews * @returns An array of dataviews

View file

@ -34,9 +34,9 @@ export type DashboardContainerFactory = EmbeddableFactory<
>; >;
export interface DashboardCreationOptions { export interface DashboardCreationOptions {
initialInput?: Partial<DashboardContainerInput>; getInitialInput?: () => Partial<DashboardContainerInput>;
incomingEmbeddable?: EmbeddablePackageState; getIncomingEmbeddable?: () => EmbeddablePackageState | undefined;
useSearchSessionsIntegration?: boolean; useSearchSessionsIntegration?: boolean;
searchSessionSettings?: { searchSessionSettings?: {
@ -98,7 +98,7 @@ export class DashboardContainerFactoryDefinition
const { createDashboard } = await import('./create/create_dashboard'); const { createDashboard } = await import('./create/create_dashboard');
try { try {
return Promise.resolve( return Promise.resolve(
createDashboard(initialInput.id, creationOptions, dashboardCreationStartTime, savedObjectId) createDashboard(creationOptions, dashboardCreationStartTime, savedObjectId)
); );
} catch (e) { } catch (e) {
return new ErrorEmbeddable(e.text, { id: e.id }); return new ErrorEmbeddable(e.text, { id: e.id });

View file

@ -26,7 +26,7 @@ describe('dashboard renderer', () => {
mockDashboardContainer = { mockDashboardContainer = {
destroy: jest.fn(), destroy: jest.fn(),
render: jest.fn(), render: jest.fn(),
isExpectingIdChange: jest.fn().mockReturnValue(false), navigateToDashboard: jest.fn(),
} as unknown as DashboardContainer; } as unknown as DashboardContainer;
mockDashboardFactory = { mockDashboardFactory = {
create: jest.fn().mockReturnValue(mockDashboardContainer), create: jest.fn().mockReturnValue(mockDashboardContainer),
@ -77,7 +77,7 @@ describe('dashboard renderer', () => {
expect(mockDashboardContainer.destroy).toHaveBeenCalledTimes(1); expect(mockDashboardContainer.destroy).toHaveBeenCalledTimes(1);
}); });
test('destroys dashboard container on unexpected ID change', async () => { test('calls navigate and does not destroy dashboard container on ID change', async () => {
let wrapper: ReactWrapper; let wrapper: ReactWrapper;
await act(async () => { await act(async () => {
wrapper = await mountWithIntl(<DashboardRenderer savedObjectId="saved_object_kibanana" />); wrapper = await mountWithIntl(<DashboardRenderer savedObjectId="saved_object_kibanana" />);
@ -85,18 +85,9 @@ describe('dashboard renderer', () => {
await act(async () => { await act(async () => {
await wrapper.setProps({ savedObjectId: 'saved_object_kibanakiwi' }); await wrapper.setProps({ savedObjectId: 'saved_object_kibanakiwi' });
}); });
expect(mockDashboardContainer.destroy).toHaveBeenCalledTimes(1);
});
test('does not destroy dashboard container on expected ID change', async () => {
let wrapper: ReactWrapper;
await act(async () => {
wrapper = await mountWithIntl(<DashboardRenderer savedObjectId="saved_object_kibanana" />);
});
mockDashboardContainer.isExpectingIdChange = jest.fn().mockReturnValue(true);
await act(async () => {
await wrapper.setProps({ savedObjectId: 'saved_object_kibanakiwi' });
});
expect(mockDashboardContainer.destroy).not.toHaveBeenCalled(); expect(mockDashboardContainer.destroy).not.toHaveBeenCalled();
expect(mockDashboardContainer.navigateToDashboard).toHaveBeenCalledWith(
'saved_object_kibanakiwi'
);
}); });
}); });

View file

@ -47,7 +47,6 @@ export const DashboardRenderer = forwardRef<AwaitingDashboardAPI, DashboardRende
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [screenshotMode, setScreenshotMode] = useState(false); const [screenshotMode, setScreenshotMode] = useState(false);
const [dashboardContainer, setDashboardContainer] = useState<DashboardContainer>(); const [dashboardContainer, setDashboardContainer] = useState<DashboardContainer>();
const [dashboardIdToBuild, setDashboardIdToBuild] = useState<string | undefined>(savedObjectId);
useImperativeHandle( useImperativeHandle(
ref, ref,
@ -67,9 +66,10 @@ export const DashboardRenderer = forwardRef<AwaitingDashboardAPI, DashboardRende
}, []); }, []);
useEffect(() => { useEffect(() => {
// check if dashboard container is expecting id change... if not, update dashboardIdToBuild to force it to rebuild the container.
if (!dashboardContainer) return; if (!dashboardContainer) return;
if (!dashboardContainer.isExpectingIdChange()) setDashboardIdToBuild(savedObjectId);
// When a dashboard already exists, don't rebuild it, just set a new id.
dashboardContainer.navigateToDashboard(savedObjectId);
// Disabling exhaustive deps because this useEffect should only be triggered when the savedObjectId changes. // Disabling exhaustive deps because this useEffect should only be triggered when the savedObjectId changes.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -115,9 +115,9 @@ export const DashboardRenderer = forwardRef<AwaitingDashboardAPI, DashboardRende
canceled = true; canceled = true;
destroyContainer?.(); destroyContainer?.();
}; };
// Disabling exhaustive deps because embeddable should only be created when the dashboardIdToBuild changes. // Disabling exhaustive deps because embeddable should only be created on first render.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboardIdToBuild]); }, []);
const viewportClasses = classNames( const viewportClasses = classNames(
'dashboardViewport', 'dashboardViewport',

View file

@ -56,6 +56,10 @@ export const dashboardContainerReducers = {
} }
}, },
setLastSavedId: (state: DashboardReduxState, action: PayloadAction<string | undefined>) => {
state.componentState.lastSavedId = action.payload;
},
setStateFromSettingsFlyout: ( setStateFromSettingsFlyout: (
state: DashboardReduxState, state: DashboardReduxState,
action: PayloadAction<DashboardStateFromSettingsFlyout> action: PayloadAction<DashboardStateFromSettingsFlyout>
@ -218,4 +222,11 @@ export const dashboardContainerReducers = {
setHighlightPanelId: (state: DashboardReduxState, action: PayloadAction<string | undefined>) => { setHighlightPanelId: (state: DashboardReduxState, action: PayloadAction<string | undefined>) => {
state.componentState.highlightPanelId = action.payload; state.componentState.highlightPanelId = action.payload;
}, },
setAnimatePanelTransforms: (
state: DashboardReduxState,
action: PayloadAction<boolean | undefined>
) => {
state.componentState.animatePanelTransforms = action.payload;
},
}; };

View file

@ -84,7 +84,7 @@ export function startDiffingDashboardState(
creationOptions?: DashboardCreationOptions creationOptions?: DashboardCreationOptions
) { ) {
const checkForUnsavedChangesSubject$ = new Subject<null>(); const checkForUnsavedChangesSubject$ = new Subject<null>();
this.subscriptions.add( this.diffingSubscription.add(
checkForUnsavedChangesSubject$ checkForUnsavedChangesSubject$
.pipe( .pipe(
startWith(null), startWith(null),

View file

@ -26,6 +26,7 @@ export type DashboardStateFromSettingsFlyout = DashboardStateFromSaveModal & Das
export interface DashboardPublicState { export interface DashboardPublicState {
lastSavedInput: DashboardContainerInput; lastSavedInput: DashboardContainerInput;
animatePanelTransforms?: boolean;
isEmbeddedExternally?: boolean; isEmbeddedExternally?: boolean;
hasUnsavedChanges?: boolean; hasUnsavedChanges?: boolean;
hasOverlays?: boolean; hasOverlays?: boolean;

View file

@ -94,7 +94,7 @@ export class DashboardAddPanelService extends FtrService {
continue; continue;
} }
await button.click(); await button.click();
await this.common.closeToast(); await this.common.closeToastIfExists();
embeddableList.push(name); embeddableList.push(name);
} }
}); });

View file

@ -44,7 +44,7 @@ const DashboardRendererComponent = ({
const getCreationOptions = useCallback( const getCreationOptions = useCallback(
() => () =>
Promise.resolve({ Promise.resolve({
initialInput: { timeRange, viewMode: ViewMode.VIEW, query, filters }, getInitialInput: () => ({ timeRange, viewMode: ViewMode.VIEW, query, filters }),
}), }),
[filters, query, timeRange] [filters, query, timeRange]
); );