[8.8] [Dashboard] Fast Navigation Between Dashboards (#157437) (#159827)

# Backport

This will backport the following commits from `main` to `8.8`:
- [[Dashboard] Fast Navigation Between Dashboards
(#157437)](https://github.com/elastic/kibana/pull/157437)

<!--- Backport version: 8.9.7 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Devon
Thomson","email":"devon.thomson@elastic.co"},"sourceCommit":{"committedDate":"2023-05-25T18:40:48Z","message":"[Dashboard]
Fast Navigation Between Dashboards (#157437)\n\n## Summary\r\nMakes all
navigation from one Dashboard to another feel
snappier.","sha":"5342563a22a54223180a727a542a7b6c99caea7f","branchLabelMapping":{"^v8.9.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:enhancement","Feature:Dashboard","Team:Presentation","loe:days","impact:medium","backport:skip","v8.8.0","v8.9.0"],"number":157437,"url":"https://github.com/elastic/kibana/pull/157437","mergeCommit":{"message":"[Dashboard]
Fast Navigation Between Dashboards (#157437)\n\n## Summary\r\nMakes all
navigation from one Dashboard to another feel
snappier.","sha":"5342563a22a54223180a727a542a7b6c99caea7f"}},"sourceBranch":"main","suggestedTargetBranches":["8.8"],"targetPullRequestStates":[{"branch":"8.8","label":"v8.8.0","labelRegex":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v8.9.0","labelRegex":"^v8.9.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/157437","number":157437,"mergeCommit":{"message":"[Dashboard]
Fast Navigation Between Dashboards (#157437)\n\n## Summary\r\nMakes all
navigation from one Dashboard to another feel
snappier.","sha":"5342563a22a54223180a727a542a7b6c99caea7f"}}]}]
BACKPORT-->

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Patryk Kopyciński <contact@patrykkopycinski.com>
This commit is contained in:
Devon Thomson 2023-06-16 06:22:23 -04:00 committed by GitHub
parent 1bdfebab70
commit 9e48a57755
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 { getDefaultControlGroupInput } from '@kbn/controls-plugin/common';
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 }) => {
const [dashboard, setDashboard] = useState<AwaitingDashboardAPI>();
@ -48,7 +52,7 @@ export const DashboardWithControlsExample = ({ dataView }: { dataView: DataView
<EuiSpacer size="m" />
<EuiPanel hasBorder={true}>
<DashboardRenderer
getCreationOptions={async () => {
getCreationOptions={async (): Promise<DashboardCreationOptions> => {
const builder = controlGroupInputBuilder;
const controlGroupInput = getDefaultControlGroupInput();
await builder.addDataControlFromField(controlGroupInput, {
@ -68,11 +72,11 @@ export const DashboardWithControlsExample = ({ dataView }: { dataView: DataView
return {
useControlGroupIntegration: true,
initialInput: {
getInitialInput: () => ({
timeRange: { from: 'now-30d', to: 'now' },
viewMode: ViewMode.VIEW,
controlGroupInput,
},
}),
};
}}
ref={setDashboard}

View file

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

View file

@ -50,12 +50,15 @@ export const StaticByReferenceExample = ({
const field = dataView.getFieldByName('machine.os.keyword');
let filter: Filter;
let creationOptions: DashboardCreationOptions = {
initialInput: { viewMode: ViewMode.VIEW },
getInitialInput: () => ({ viewMode: ViewMode.VIEW }),
};
if (field) {
filter = buildPhraseFilter(field, 'win xp', dataView);
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
}}

View file

@ -29,11 +29,11 @@ export const StaticByValueExample = () => {
<DashboardRenderer
getCreationOptions={async () => {
return {
initialInput: {
getInitialInput: () => ({
timeRange: { from: 'now-30d', to: 'now' },
viewMode: ViewMode.VIEW,
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) => {
this.lastUsedDataViewId = lastUsedDataViewId;
};

View file

@ -83,11 +83,6 @@ export function DashboardApp({
customBranding,
} = pluginServices.getServices();
const showPlainSpinner = useObservable(customBranding.hasCustomBranding$, false);
const incomingEmbeddable = getStateTransfer().getIncomingEmbeddablePackage(
DASHBOARD_APP_ID,
true
);
const { scopedHistory: getScopedHistory } = useDashboardMountContext();
useExecutionContext(executionContext, {
@ -125,13 +120,28 @@ export function DashboardApp({
/**
* Create options to pass into the dashboard renderer
*/
const stateFromLocator = loadDashboardHistoryLocationState(getScopedHistory);
const getCreationOptions = useCallback((): Promise<DashboardCreationOptions> => {
const initialUrlState = loadAndRemoveDashboardState(kbnUrlStateStorage);
const searchSessionIdFromURL = getSearchSessionIdFromURL(history);
const getInitialInput = () => {
const stateFromLocator = loadDashboardHistoryLocationState(getScopedHistory);
const initialUrlState = loadAndRemoveDashboardState(kbnUrlStateStorage);
return Promise.resolve({
incomingEmbeddable,
// Override all state with URL + Locator input
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
useControlGroupIntegration: true,
@ -148,28 +158,16 @@ export function DashboardApp({
getSearchSessionIdFromURL: () => getSearchSessionIdFromURL(history),
removeSessionIdFromUrl: () => removeSearchSessionIdFromURL(kbnUrlStateStorage),
},
// 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 }
: {}),
},
getInitialInput,
validateLoadedSavedObject: validateOutcome,
});
}, [
history,
validateOutcome,
stateFromLocator,
getScopedHistory,
isScreenshotMode,
getStateTransfer,
kbnUrlStateStorage,
incomingEmbeddable,
getScreenshotContext,
]);
@ -183,7 +181,7 @@ export function DashboardApp({
dashboardAPI,
});
return () => stopWatchingAppStateInUrl();
}, [dashboardAPI, kbnUrlStateStorage]);
}, [dashboardAPI, kbnUrlStateStorage, savedDashboardId]);
return (
<div className="dshAppWrapper">

View file

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

View file

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

View file

@ -11,7 +11,6 @@ import 'react-grid-layout/css/styles.css';
import { pick } from 'lodash';
import classNames from 'classnames';
import { useEffectOnce } from 'react-use/lib';
import React, { useState, useMemo, useCallback, useEffect } from 'react';
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 useMargins = dashboard.select((state) => state.explicitInput.useMargins);
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);
useEffectOnce(() => {
setTimeout(() => setAnimatePanelTransforms(true), 500);
});
/**
* Track panel maximized state delayed by one tick and use it to prevent
* panel sliding animations on maximize and minimize.
*/
const [delayedIsPanelExpanded, setDelayedIsPanelMaximized] = useState(false);
useEffect(() => {
if (expandedPanelId) {
setAnimatePanelTransforms(false);
setDelayedIsPanelMaximized(true);
} else {
// delaying enabling CSS transforms to the next tick prevents a panel slide animation on minimize
setTimeout(() => setAnimatePanelTransforms(true), 0);
setTimeout(() => setDelayedIsPanelMaximized(false), 0);
}
}, [expandedPanelId]);
@ -107,7 +107,7 @@ export const DashboardGrid = ({ viewportWidth }: { viewportWidth: number }) => {
'dshLayout-withoutMargins': !useMargins,
'dshLayout--viewing': viewMode === ViewMode.VIEW,
'dshLayout--editing': viewMode !== ViewMode.VIEW,
'dshLayout--noAnimation': !animatePanelTransforms || expandedPanelId,
'dshLayout--noAnimation': !animatePanelTransforms || delayedIsPanelExpanded,
'dshLayout-isMaximizedPanel': expandedPanelId !== undefined,
});

View file

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

View file

@ -50,7 +50,7 @@ export function startSyncingDashboardControlGroup(this: DashboardContainer) {
chainingSystem: deepEqual,
ignoreParentSettings: deepEqual,
};
this.subscriptions.add(
this.integrationSubscriptions.add(
this.controlGroup
.getInput$()
.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
this.subscriptions.add(
this.integrationSubscriptions.add(
(this.getInput$() as Readonly<Observable<DashboardContainerInput>>)
.pipe(
distinctUntilChanged((a, b) =>
@ -106,7 +106,7 @@ export function startSyncingDashboardControlGroup(this: DashboardContainer) {
);
// when control group outputs filters, force a refresh!
this.subscriptions.add(
this.integrationSubscriptions.add(
this.controlGroup
.getOutput$()
.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
);
this.subscriptions.add(
this.integrationSubscriptions.add(
this.controlGroup
.getOutput$()
.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.
this.subscriptions.add(
this.integrationSubscriptions.add(
this.getAnyChildOutputChange$().subscribe(() => {
if (!this.controlGroup) {
return;

View file

@ -30,14 +30,12 @@ import { pluginServices } from '../../../services/plugin_services';
import { DashboardCreationOptions } from '../dashboard_container_factory';
import { DEFAULT_DASHBOARD_INPUT } from '../../../dashboard_constants';
const embeddableId = 'create-dat-dashboard';
test('throws error when no data views are available', async () => {
pluginServices.getServices().data.dataViews.getDefaultDataView = jest
.fn()
.mockReturnValue(undefined);
await expect(async () => {
await createDashboard(embeddableId);
await createDashboard();
}).rejects.toThrow('Dashboard requires at least one data view before it can be initialized.');
// reset get default data view
@ -49,7 +47,7 @@ test('throws error when provided validation function returns invalid', async ()
validateLoadedSavedObject: jest.fn().mockImplementation(() => false),
};
await expect(async () => {
await createDashboard(embeddableId, creationOptions, 0, 'test-id');
await createDashboard(creationOptions, 0, 'test-id');
}).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.`,
},
});
const dashboard = await createDashboard(embeddableId, {}, 0, 'wow-such-id');
const dashboard = await createDashboard({}, 0, 'wow-such-id');
expect(
pluginServices.getServices().dashboardSavedObject.loadDashboardStateFromSavedObject
).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
.fn()
.mockReturnValue({ description: 'wow this description marginally better' });
const dashboard = await createDashboard(
embeddableId,
{ useSessionStorageIntegration: true },
0,
'wow-such-id'
);
const dashboard = await createDashboard({ useSessionStorageIntegration: true }, 0, 'wow-such-id');
expect(dashboard.getState().explicitInput.description).toBe(
'wow this description marginally better'
);
@ -105,10 +98,9 @@ test('pulls state from creation options initial input which overrides all other
.fn()
.mockReturnValue({ description: 'wow this description marginally better' });
const dashboard = await createDashboard(
embeddableId,
{
useSessionStorageIntegration: true,
initialInput: { description: 'wow this description is a masterpiece' },
getInitialInput: () => ({ description: 'wow this description is a masterpiece' }),
},
0,
'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' } },
];
const query = { language: 'kql', query: 'query' };
await createDashboard(embeddableId, {
await createDashboard({
useUnifiedSearchIntegration: true,
unifiedSearchSettings: {
kbnUrlStateStorage: createKbnUrlStateStorage(),
},
initialInput: { filters, query },
getInitialInput: () => ({ filters, query }),
});
expect(pluginServices.getServices().data.query.queryString.setQuery).toHaveBeenCalledWith(query);
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 () => {
const timeRange = { from: new Date().toISOString(), to: new Date().toISOString() };
const refreshInterval = { pause: false, value: 42 };
await createDashboard(embeddableId, {
await createDashboard({
useUnifiedSearchIntegration: true,
unifiedSearchSettings: {
kbnUrlStateStorage: createKbnUrlStateStorage(),
},
initialInput: { timeRange, refreshInterval, timeRestore: true },
getInitialInput: () => ({ timeRange, refreshInterval, timeRestore: true }),
});
expect(
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
.fn()
.mockReturnValue(timeRange);
const dashboard = await createDashboard(embeddableId, {
const dashboard = await createDashboard({
useUnifiedSearchIntegration: true,
unifiedSearchSettings: {
kbnUrlStateStorage: createKbnUrlStateStorage(),
@ -177,9 +169,9 @@ test('replaces panel with incoming embeddable if id matches existing panel', asy
} as ContactCardEmbeddableInput,
embeddableId: 'i_match',
};
const dashboard = await createDashboard(embeddableId, {
incomingEmbeddable,
initialInput: {
const dashboard = await createDashboard({
getIncomingEmbeddable: () => incomingEmbeddable,
getInitialInput: () => ({
panels: {
i_match: getSampleDashboardPanel<ContactCardEmbeddableInput>({
explicitInput: {
@ -189,7 +181,7 @@ test('replaces panel with incoming embeddable if id matches existing panel', asy
type: CONTACT_CARD_EMBEDDABLE,
}),
},
},
}),
});
expect(dashboard.getState().explicitInput.panels.i_match.explicitInput).toStrictEqual(
expect.objectContaining({
@ -216,9 +208,9 @@ test('creates new embeddable with incoming embeddable if id does not match exist
.fn()
.mockReturnValue(mockContactCardFactory);
await createDashboard(embeddableId, {
incomingEmbeddable,
initialInput: {
await createDashboard({
getIncomingEmbeddable: () => incomingEmbeddable,
getInitialInput: () => ({
panels: {
i_do_not_match: getSampleDashboardPanel<ContactCardEmbeddableInput>({
explicitInput: {
@ -228,7 +220,7 @@ test('creates new embeddable with incoming embeddable if id does not match exist
type: CONTACT_CARD_EMBEDDABLE,
}),
},
},
}),
});
// 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
.fn()
.mockReturnValue(mockControlGroupFactory);
await createDashboard(embeddableId, {
await createDashboard({
useControlGroupIntegration: true,
initialInput: {
getInitialInput: () => ({
controlGroupInput: { controlStyle: 'twoLine' } as unknown as ControlGroupInput,
},
}),
});
// flush promises
await new Promise((r) => setTimeout(r, 1));
@ -302,7 +294,7 @@ test('searchSessionId is updated prior to child embeddable parent subscription e
sessionCount++;
return `searchSessionId${sessionCount}`;
};
const dashboard = await createDashboard(embeddableId, {
const dashboard = await createDashboard({
searchSessionSettings: {
getSearchSessionIdFromURL: () => undefined,
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 { startSyncingDashboardControlGroup } from './controls/dashboard_control_group_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';
/**
*
* @param creationOptions
* Builds a new Dashboard from scratch.
*/
export const createDashboard = async (
embeddableId: string,
creationOptions?: DashboardCreationOptions,
dashboardCreationStartTime?: number,
savedObjectId?: string
): Promise<DashboardContainer> => {
// --------------------------------------------------------------------------------------
// Unpack services & Options
// --------------------------------------------------------------------------------------
const {
dashboardSessionStorage,
embeddable: { getEmbeddableFactory },
data: {
dataViews,
query: queryService,
search: { session },
},
data: { dataViews },
dashboardSavedObject: { loadDashboardStateFromSavedObject },
} = 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.
// --------------------------------------------------------------------------------------
@ -83,12 +57,9 @@ export const createDashboard = async (
// --------------------------------------------------------------------------------------
// Lazy load required systems and Dashboard saved object.
// --------------------------------------------------------------------------------------
const reduxEmbeddablePackagePromise = lazyLoadReduxToolsPackage();
const defaultDataViewAssignmentPromise = dataViews.getDefaultDataView();
const dashboardSavedObjectPromise = savedObjectId
? loadDashboardStateFromSavedObject({ id: savedObjectId })
: Promise.resolve(undefined);
const dashboardSavedObjectPromise = loadDashboardStateFromSavedObject({ id: savedObjectId });
const [reduxEmbeddablePackage, savedObjectResult, defaultDataView] = await Promise.all([
reduxEmbeddablePackagePromise,
@ -96,17 +67,82 @@ export const createDashboard = async (
defaultDataViewAssignmentPromise,
]);
// --------------------------------------------------------------------------------------
// Run validations.
// --------------------------------------------------------------------------------------
if (!defaultDataView) {
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 (
savedObjectResult &&
loadDashboardReturn &&
validateLoadedSavedObject &&
!validateLoadedSavedObject(savedObjectResult)
!validateLoadedSavedObject(loadDashboardReturn)
) {
throw new Error('Dashboard failed saved object result validation');
}
@ -116,7 +152,7 @@ export const createDashboard = async (
// --------------------------------------------------------------------------------------
const sessionStorageInput = ((): Partial<DashboardContainerInput> | undefined => {
if (!useSessionStorageIntegration) return;
return dashboardSessionStorage.getState(savedObjectId);
return dashboardSessionStorage.getState(loadDashboardReturn.dashboardId);
})();
// --------------------------------------------------------------------------------------
@ -124,10 +160,9 @@ export const createDashboard = async (
// --------------------------------------------------------------------------------------
const initialInput: DashboardContainerInput = cloneDeep({
...DEFAULT_DASHBOARD_INPUT,
...(savedObjectResult?.dashboardInput ?? {}),
...(loadDashboardReturn?.dashboardInput ?? {}),
...sessionStorageInput,
...overrideInput,
id: embeddableId,
});
initialInput.executionContext = {
@ -166,8 +201,7 @@ export const createDashboard = async (
}
untilDashboardReady().then((dashboardContainer) => {
const stopSyncingUnifiedSearchState =
syncUnifiedSearchState.bind(dashboardContainer)(kbnUrlStateStorage);
const stopSyncingUnifiedSearchState = syncUnifiedSearchState.bind(dashboardContainer)();
dashboardContainer.stopSyncingWithUnifiedSearch = () => {
stopSyncingUnifiedSearchState();
stopSyncingQueryServiceStateWithUrl();
@ -178,16 +212,20 @@ export const createDashboard = async (
// --------------------------------------------------------------------------------------
// Place the incoming embeddable if there is one
// --------------------------------------------------------------------------------------
const incomingEmbeddable = creationOptions?.incomingEmbeddable;
const incomingEmbeddable = creationOptions?.getIncomingEmbeddable?.();
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 &&
Boolean(initialInput.panels[incomingEmbeddable.embeddableId]);
if (panelExists) {
Boolean(initialInput.panels[incomingEmbeddable.embeddableId])
) {
// 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;
panelToUpdate.type = incomingEmbeddable.type;
@ -196,22 +234,24 @@ export const createDashboard = async (
...(sameType ? panelToUpdate.explicitInput : {}),
...incomingEmbeddable.input,
id: incomingEmbeddable.embeddableId as string,
id: incomingEmbeddable.embeddableId,
// maintain hide panel titles setting.
hidePanelTitles: panelToUpdate.explicitInput.hidePanelTitles,
};
untilDashboardReady().then((container) =>
scrolltoIncomingEmbeddable(container, incomingEmbeddable.embeddableId as string)
);
} else {
// 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) => {
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
>(CONTROL_GROUP_TYPE);
const { filters, query, timeRange, viewMode, controlGroupInput, id } = initialInput;
const controlGroup = await controlsGroupFactory?.create({
const fullControlGroupInput = {
id: `control_group_${id ?? 'new_dashboard'}`,
...getDefaultControlGroupInput(),
...pickBy(controlGroupInput, identity), // undefined keys in initialInput should not overwrite defaults
@ -259,9 +299,15 @@ export const createDashboard = async (
viewMode,
filters,
query,
});
if (!controlGroup || isErrorEmbeddable(controlGroup)) {
throw new Error('Error in control group startup');
};
if (controlGroup) {
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) => {
@ -275,22 +321,17 @@ export const createDashboard = async (
// Start the data views integration.
// --------------------------------------------------------------------------------------
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(
initialInput,
reduxEmbeddablePackage,
initialSearchSessionId,
savedObjectResult?.dashboardInput,
dashboardCreationStartTime,
undefined,
creationOptions,
savedObjectId
untilDashboardReady().then((dashboard) =>
setTimeout(() => dashboard.dispatch.setAnimatePanelTransforms(true), 500)
);
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 type { Filter, Query } from '@kbn/es-query';
import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import { cleanFiltersForSerialize } from '@kbn/presentation-util-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
* and the dashboard Redux store.
*/
export function syncUnifiedSearchState(
this: DashboardContainer,
kbnUrlStateStorage: IKbnUrlStateStorage
) {
export function syncUnifiedSearchState(this: DashboardContainer) {
const {
data: { query: queryService, search },
} = pluginServices.getServices();

View file

@ -6,9 +6,10 @@
* Side Public License, v 1.
*/
import React, { createContext, useContext } from 'react';
import ReactDOM from 'react-dom';
import { batch } from 'react-redux';
import { Subject, Subscription } from 'rxjs';
import React, { createContext, useContext } from 'react';
import { ReduxToolsPackage, ReduxEmbeddableTools } from '@kbn/presentation-util-plugin/public';
import {
@ -20,10 +21,10 @@ import {
type EmbeddableFactory,
} from '@kbn/embeddable-plugin/public';
import { I18nProvider } from '@kbn/i18n-react';
import { RefreshInterval } from '@kbn/data-plugin/public';
import type { Filter, TimeRange, Query } from '@kbn/es-query';
import type { DataView } from '@kbn/data-views-plugin/public';
import { reportPerformanceMetricEvent } from '@kbn/ebt-tools';
import type { RefreshInterval } from '@kbn/data-plugin/public';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import type { ControlGroupContainer } from '@kbn/controls-plugin/public';
import type { KibanaExecutionContext, OverlayRef } from '@kbn/core/public';
@ -44,6 +45,7 @@ import {
import { DASHBOARD_CONTAINER_TYPE } from '../..';
import { createPanelState } from '../component/panel';
import { pluginServices } from '../../services/plugin_services';
import { initializeDashboard } from './create/create_dashboard';
import { DashboardCreationOptions } from './dashboard_container_factory';
import { DashboardAnalyticsService } from '../../services/analytics/types';
import { DashboardViewport } from '../component/viewport/dashboard_viewport';
@ -93,7 +95,8 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
public dispatch: DashboardReduxEmbeddableTools['dispatch'];
public onStateChange: DashboardReduxEmbeddableTools['onStateChange'];
public subscriptions: Subscription = new Subscription();
public integrationSubscriptions: Subscription = new Subscription();
public diffingSubscription: Subscription = new Subscription();
public controlGroup?: ControlGroupContainer;
public searchSessionId?: string;
@ -169,6 +172,7 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
...DEFAULT_DASHBOARD_INPUT,
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.
lastSavedId: savedObjectId,
},
@ -280,7 +284,8 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
super.destroy();
this.cleanupStateTools();
this.controlGroup?.destroy();
this.subscriptions.unsubscribe();
this.diffingSubscription.unsubscribe();
this.integrationSubscriptions.unsubscribe();
this.stopSyncingWithUnifiedSearch?.();
if (this.domNode) ReactDOM.unmountComponentAtNode(this.domNode);
}
@ -289,26 +294,6 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
// 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 runSaveAs = runSaveAs;
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
* @returns An array of dataviews

View file

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

View file

@ -26,7 +26,7 @@ describe('dashboard renderer', () => {
mockDashboardContainer = {
destroy: jest.fn(),
render: jest.fn(),
isExpectingIdChange: jest.fn().mockReturnValue(false),
navigateToDashboard: jest.fn(),
} as unknown as DashboardContainer;
mockDashboardFactory = {
create: jest.fn().mockReturnValue(mockDashboardContainer),
@ -77,7 +77,7 @@ describe('dashboard renderer', () => {
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;
await act(async () => {
wrapper = await mountWithIntl(<DashboardRenderer savedObjectId="saved_object_kibanana" />);
@ -85,18 +85,9 @@ describe('dashboard renderer', () => {
await act(async () => {
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.navigateToDashboard).toHaveBeenCalledWith(
'saved_object_kibanakiwi'
);
});
});

View file

@ -47,7 +47,6 @@ export const DashboardRenderer = forwardRef<AwaitingDashboardAPI, DashboardRende
const [loading, setLoading] = useState(true);
const [screenshotMode, setScreenshotMode] = useState(false);
const [dashboardContainer, setDashboardContainer] = useState<DashboardContainer>();
const [dashboardIdToBuild, setDashboardIdToBuild] = useState<string | undefined>(savedObjectId);
useImperativeHandle(
ref,
@ -67,9 +66,10 @@ export const DashboardRenderer = forwardRef<AwaitingDashboardAPI, DashboardRende
}, []);
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.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.
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -115,9 +115,9 @@ export const DashboardRenderer = forwardRef<AwaitingDashboardAPI, DashboardRende
canceled = true;
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
}, [dashboardIdToBuild]);
}, []);
const viewportClasses = classNames(
'dashboardViewport',

View file

@ -56,6 +56,10 @@ export const dashboardContainerReducers = {
}
},
setLastSavedId: (state: DashboardReduxState, action: PayloadAction<string | undefined>) => {
state.componentState.lastSavedId = action.payload;
},
setStateFromSettingsFlyout: (
state: DashboardReduxState,
action: PayloadAction<DashboardStateFromSettingsFlyout>
@ -218,4 +222,11 @@ export const dashboardContainerReducers = {
setHighlightPanelId: (state: DashboardReduxState, action: PayloadAction<string | undefined>) => {
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
) {
const checkForUnsavedChangesSubject$ = new Subject<null>();
this.subscriptions.add(
this.diffingSubscription.add(
checkForUnsavedChangesSubject$
.pipe(
startWith(null),

View file

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

View file

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

View file

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