[Dashboard] Rebuild State Management (#97941)

* Rebuilt dashboard state management system with RTK.
This commit is contained in:
Devon Thomson 2021-06-07 15:17:24 -04:00 committed by GitHub
parent 1cd88f4a44
commit b3ed014c1a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
80 changed files with 3196 additions and 3566 deletions

View file

@ -748,6 +748,7 @@
"jest-cli": "^26.6.3",
"jest-diff": "^26.6.2",
"jest-environment-jsdom": "^26.6.2",
"jest-environment-jsdom-thirteen": "^1.0.1",
"jest-raw-loader": "^1.0.1",
"jest-silent-reporter": "^0.5.0",
"jest-snapshot": "^26.6.2",

View file

@ -6,14 +6,11 @@
* Side Public License, v 1.
*/
import { dashboardExpandPanelAction } from '../../dashboard_strings';
import { DashboardContainerInput } from '../..';
import { IEmbeddable } from '../../services/embeddable';
import { dashboardExpandPanelAction } from '../../dashboard_strings';
import { Action, IncompatibleActionError } from '../../services/ui_actions';
import {
DASHBOARD_CONTAINER_TYPE,
DashboardContainer,
DashboardContainerInput,
} from '../embeddable';
import { DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '../embeddable';
export const ACTION_EXPAND_PANEL = 'togglePanel';

View file

@ -7,35 +7,20 @@
*/
import { History } from 'history';
import { merge, Subject, Subscription } from 'rxjs';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo } from 'react';
import { debounceTime, finalize, switchMap, tap } from 'rxjs/operators';
import { useDashboardSelector } from './state';
import { useDashboardAppState } from './hooks';
import { useKibana } from '../../../kibana_react/public';
import { DashboardConstants } from '../dashboard_constants';
import { DashboardTopNav } from './top_nav/dashboard_top_nav';
import { DashboardAppServices, DashboardEmbedSettings, DashboardRedirect } from './types';
import {
getChangesFromAppStateForContainerState,
getDashboardContainerInput,
getFiltersSubscription,
getInputSubscription,
getOutputSubscription,
getSearchSessionIdFromURL,
} from './dashboard_app_functions';
import {
useDashboardBreadcrumbs,
useDashboardContainer,
useDashboardStateManager,
useSavedDashboard,
} from './hooks';
import { IndexPattern, waitUntilNextSessionCompletes$ } from '../services/data';
getDashboardBreadcrumb,
getDashboardTitle,
leaveConfirmStrings,
} from '../dashboard_strings';
import { EmbeddableRenderer } from '../services/embeddable';
import { DashboardContainerInput } from '.';
import { leaveConfirmStrings } from '../dashboard_strings';
import { createQueryParamObservable, replaceUrlHashQuery } from '../../../kibana_utils/public';
import { DashboardTopNav, isCompleteDashboardAppState } from './top_nav/dashboard_top_nav';
import { DashboardAppServices, DashboardEmbedSettings, DashboardRedirect } from '../types';
import { createKbnUrlStateStorage, withNotifyOnErrors } from '../services/kibana_utils';
export interface DashboardAppProps {
history: History;
savedDashboardId?: string;
@ -50,236 +35,37 @@ export function DashboardApp({
history,
}: DashboardAppProps) {
const {
data,
core,
chrome,
embeddable,
onAppLeave,
uiSettings,
embeddable,
dashboardCapabilities,
indexPatterns: indexPatternService,
} = useKibana<DashboardAppServices>().services;
const triggerRefresh$ = useMemo(() => new Subject<{ force?: boolean }>(), []);
const [indexPatterns, setIndexPatterns] = useState<IndexPattern[]>([]);
const savedDashboard = useSavedDashboard(savedDashboardId, history);
const getIncomingEmbeddable = useCallback(
(removeAfterFetch?: boolean) => {
return embeddable
.getStateTransfer()
.getIncomingEmbeddablePackage(DashboardConstants.DASHBOARDS_ID, removeAfterFetch);
},
[embeddable]
const kbnUrlStateStorage = useMemo(
() =>
createKbnUrlStateStorage({
history,
useHash: uiSettings.get('state:storeInSessionStorage'),
...withNotifyOnErrors(core.notifications.toasts),
}),
[core.notifications.toasts, history, uiSettings]
);
const { dashboardStateManager, viewMode, setViewMode } = useDashboardStateManager(
savedDashboard,
history,
getIncomingEmbeddable
);
const [unsavedChanges, setUnsavedChanges] = useState(false);
const dashboardContainer = useDashboardContainer({
timeFilter: data.query.timefilter.timefilter,
dashboardStateManager,
getIncomingEmbeddable,
setUnsavedChanges,
const dashboardState = useDashboardSelector((state) => state.dashboardStateReducer);
const dashboardAppState = useDashboardAppState({
history,
redirectTo,
savedDashboardId,
kbnUrlStateStorage,
isEmbeddedExternally: Boolean(embedSettings),
});
const searchSessionIdQuery$ = useMemo(
() => createQueryParamObservable(history, DashboardConstants.SEARCH_SESSION_ID),
[history]
);
const refreshDashboardContainer = useCallback(
(force?: boolean) => {
if (!dashboardContainer || !dashboardStateManager) {
return;
}
const changes = getChangesFromAppStateForContainerState({
dashboardContainer,
appStateDashboardInput: getDashboardContainerInput({
isEmbeddedExternally: Boolean(embedSettings),
dashboardStateManager,
lastReloadRequestTime: force ? Date.now() : undefined,
dashboardCapabilities,
query: data.query,
}),
});
if (changes) {
// state keys change in which likely won't need a data fetch
const noRefetchKeys: Array<keyof DashboardContainerInput> = [
'viewMode',
'title',
'description',
'expandedPanelId',
'useMargins',
'isEmbeddedExternally',
'isFullScreenMode',
];
const shouldRefetch = Object.keys(changes).some(
(changeKey) => !noRefetchKeys.includes(changeKey as keyof DashboardContainerInput)
);
const newSearchSessionId: string | undefined = (() => {
// do not update session id if this is irrelevant state change to prevent excessive searches
if (!shouldRefetch) return;
let searchSessionIdFromURL = getSearchSessionIdFromURL(history);
if (searchSessionIdFromURL) {
if (
data.search.session.isRestore() &&
data.search.session.isCurrentSession(searchSessionIdFromURL)
) {
// navigating away from a restored session
dashboardStateManager.kbnUrlStateStorage.kbnUrlControls.updateAsync((nextUrl) => {
if (nextUrl.includes(DashboardConstants.SEARCH_SESSION_ID)) {
return replaceUrlHashQuery(nextUrl, (query) => {
delete query[DashboardConstants.SEARCH_SESSION_ID];
return query;
});
}
return nextUrl;
});
searchSessionIdFromURL = undefined;
} else {
data.search.session.restore(searchSessionIdFromURL);
}
}
return searchSessionIdFromURL ?? data.search.session.start();
})();
if (changes.viewMode) {
setViewMode(changes.viewMode);
}
dashboardContainer.updateInput({
...changes,
...(newSearchSessionId && { searchSessionId: newSearchSessionId }),
});
}
},
[
history,
data.query,
setViewMode,
embedSettings,
dashboardContainer,
data.search.session,
dashboardCapabilities,
dashboardStateManager,
]
);
// Manage dashboard container subscriptions
// Build app leave handler whenever hasUnsavedChanges changes
useEffect(() => {
if (!dashboardStateManager || !dashboardContainer) {
return;
}
const timeFilter = data.query.timefilter.timefilter;
const subscriptions = new Subscription();
subscriptions.add(
getInputSubscription({
dashboardContainer,
dashboardStateManager,
filterManager: data.query.filterManager,
})
);
subscriptions.add(
getOutputSubscription({
dashboardContainer,
indexPatterns: indexPatternService,
onUpdateIndexPatterns: (newIndexPatterns) => setIndexPatterns(newIndexPatterns),
})
);
subscriptions.add(
getFiltersSubscription({
query: data.query,
dashboardStateManager,
})
);
subscriptions.add(
merge(
...[timeFilter.getRefreshIntervalUpdate$(), timeFilter.getTimeUpdate$()]
).subscribe(() => triggerRefresh$.next())
);
subscriptions.add(
searchSessionIdQuery$.subscribe(() => {
triggerRefresh$.next({ force: true });
})
);
subscriptions.add(
data.query.timefilter.timefilter
.getAutoRefreshFetch$()
.pipe(
tap(() => {
triggerRefresh$.next({ force: true });
}),
switchMap((done) =>
// best way on a dashboard to estimate that panels are updated is to rely on search session service state
waitUntilNextSessionCompletes$(data.search.session).pipe(finalize(done))
)
)
.subscribe()
);
dashboardStateManager.registerChangeListener(() => {
setUnsavedChanges(dashboardStateManager.getIsDirty(data.query.timefilter.timefilter));
// we aren't checking dirty state because there are changes the container needs to know about
// that won't make the dashboard "dirty" - like a view mode change.
triggerRefresh$.next();
});
// debounce `refreshDashboardContainer()`
// use `forceRefresh=true` in case at least one debounced trigger asked for it
let forceRefresh: boolean = false;
subscriptions.add(
triggerRefresh$
.pipe(
tap((trigger) => {
forceRefresh = forceRefresh || (trigger?.force ?? false);
}),
debounceTime(50)
)
.subscribe(() => {
refreshDashboardContainer(forceRefresh);
forceRefresh = false;
})
);
return () => {
subscriptions.unsubscribe();
};
}, [
core.http,
uiSettings,
data.query,
dashboardContainer,
data.search.session,
indexPatternService,
dashboardStateManager,
searchSessionIdQuery$,
triggerRefresh$,
refreshDashboardContainer,
]);
// Sync breadcrumbs when Dashboard State Manager changes
useDashboardBreadcrumbs(dashboardStateManager, redirectTo);
// Build onAppLeave when Dashboard State Manager changes
useEffect(() => {
if (!dashboardStateManager || !dashboardContainer) {
return;
}
onAppLeave((actions) => {
if (
dashboardStateManager?.getIsDirty() &&
dashboardAppState.hasUnsavedChanges &&
!embeddable.getStateTransfer().isTransferInProgress
) {
return actions.confirm(
@ -293,37 +79,36 @@ export function DashboardApp({
// reset on app leave handler so leaving from the listing page doesn't trigger a confirmation
onAppLeave((actions) => actions.default());
};
}, [dashboardStateManager, dashboardContainer, onAppLeave, embeddable]);
}, [onAppLeave, embeddable, dashboardAppState.hasUnsavedChanges]);
// Set breadcrumbs when dashboard's title or view mode changes
useEffect(() => {
if (!dashboardState.title && savedDashboardId) return;
chrome.setBreadcrumbs([
{
text: getDashboardBreadcrumb(),
'data-test-subj': 'dashboardListingBreadcrumb',
onClick: () => {
redirectTo({ destination: 'listing' });
},
},
{
text: getDashboardTitle(dashboardState.title, dashboardState.viewMode, !savedDashboardId),
},
]);
}, [chrome, dashboardState.title, dashboardState.viewMode, redirectTo, savedDashboardId]);
return (
<>
{savedDashboard && dashboardStateManager && dashboardContainer && viewMode && (
{isCompleteDashboardAppState(dashboardAppState) && (
<>
<DashboardTopNav
{...{
redirectTo,
embedSettings,
indexPatterns,
savedDashboard,
unsavedChanges,
dashboardContainer,
dashboardStateManager,
}}
viewMode={viewMode}
lastDashboardId={savedDashboardId}
clearUnsavedChanges={() => setUnsavedChanges(false)}
timefilter={data.query.timefilter.timefilter}
onQuerySubmit={(_payload, isUpdate) => {
if (isUpdate === false) {
// The user can still request a reload in the query bar, even if the
// query is the same, and in that case, we have to explicitly ask for
// a reload, since no state changes will cause it.
triggerRefresh$.next({ force: true });
}
}}
redirectTo={redirectTo}
embedSettings={embedSettings}
dashboardAppState={dashboardAppState}
/>
<div className="dashboardViewport">
<EmbeddableRenderer embeddable={dashboardContainer} />
<EmbeddableRenderer embeddable={dashboardAppState.dashboardContainer} />
</div>
</>
)}

View file

@ -1,277 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { History } from 'history';
import _, { uniqBy } from 'lodash';
import deepEqual from 'fast-deep-equal';
import { merge, Observable, pipe } from 'rxjs';
import {
debounceTime,
distinctUntilChanged,
filter,
map,
mapTo,
startWith,
switchMap,
} from 'rxjs/operators';
import { DashboardAppCapabilities } from './types';
import { DashboardConstants } from '../dashboard_constants';
import { DashboardStateManager } from './dashboard_state_manager';
import { convertSavedDashboardPanelToPanelState } from '../../common/embeddable/embeddable_saved_object_converters';
import {
DashboardPanelState,
DashboardContainer,
DashboardContainerInput,
SavedDashboardPanel,
} from '.';
import { getQueryParams } from '../services/kibana_utils';
import { EmbeddablePackageState, isErrorEmbeddable } from '../services/embeddable';
import {
esFilters,
FilterManager,
IndexPattern,
IndexPatternsContract,
QueryStart,
} from '../services/data';
export const getChangesFromAppStateForContainerState = ({
dashboardContainer,
appStateDashboardInput,
}: {
dashboardContainer: DashboardContainer;
appStateDashboardInput: DashboardContainerInput;
}) => {
if (!dashboardContainer || isErrorEmbeddable(dashboardContainer)) {
return appStateDashboardInput;
}
const containerInput = dashboardContainer.getInput();
const differences: Partial<DashboardContainerInput> = {};
// Filters shouldn't be compared using regular isEqual
if (
!esFilters.compareFilters(
containerInput.filters,
appStateDashboardInput.filters,
esFilters.COMPARE_ALL_OPTIONS
)
) {
differences.filters = appStateDashboardInput.filters;
}
Object.keys(
_.omit(containerInput, [
'filters',
'searchSessionId',
'lastReloadRequestTime',
'switchViewMode',
])
).forEach((key) => {
const containerValue = (containerInput as { [key: string]: unknown })[key];
const appStateValue = ((appStateDashboardInput as unknown) as { [key: string]: unknown })[key];
if (!_.isEqual(containerValue, appStateValue)) {
(differences as { [key: string]: unknown })[key] = appStateValue;
}
});
// last reload request time can be undefined without causing a refresh
if (
appStateDashboardInput.lastReloadRequestTime &&
containerInput.lastReloadRequestTime !== appStateDashboardInput.lastReloadRequestTime
) {
differences.lastReloadRequestTime = appStateDashboardInput.lastReloadRequestTime;
}
// cloneDeep hack is needed, as there are multiple places, where container's input mutated,
// but values from appStateValue are deeply frozen, as they can't be mutated directly
return Object.values(differences).length === 0 ? undefined : _.cloneDeep(differences);
};
export const getDashboardContainerInput = ({
query,
searchSessionId,
incomingEmbeddable,
isEmbeddedExternally,
lastReloadRequestTime,
dashboardStateManager,
dashboardCapabilities,
}: {
dashboardCapabilities: DashboardAppCapabilities;
dashboardStateManager: DashboardStateManager;
incomingEmbeddable?: EmbeddablePackageState;
lastReloadRequestTime?: number;
isEmbeddedExternally: boolean;
searchSessionId?: string;
query: QueryStart;
}): DashboardContainerInput => {
const embeddablesMap: {
[key: string]: DashboardPanelState;
} = {};
dashboardStateManager.getPanels().forEach((panel: SavedDashboardPanel) => {
embeddablesMap[panel.panelIndex] = convertSavedDashboardPanelToPanelState(panel);
});
// If the incoming embeddable state's id already exists in the embeddables map, replace the input, retaining the existing gridData for that panel.
if (incomingEmbeddable?.embeddableId && embeddablesMap[incomingEmbeddable.embeddableId]) {
const originalPanelState = embeddablesMap[incomingEmbeddable.embeddableId];
embeddablesMap[incomingEmbeddable.embeddableId] = {
gridData: originalPanelState.gridData,
type: incomingEmbeddable.type,
explicitInput: {
...originalPanelState.explicitInput,
...incomingEmbeddable.input,
id: incomingEmbeddable.embeddableId,
},
};
}
return {
refreshConfig: query.timefilter.timefilter.getRefreshInterval(),
hidePanelTitles: dashboardStateManager.getHidePanelTitles(),
isFullScreenMode: dashboardStateManager.getFullScreenMode(),
expandedPanelId: dashboardStateManager.getExpandedPanelId(),
description: dashboardStateManager.getDescription(),
id: dashboardStateManager.savedDashboard.id || '',
useMargins: dashboardStateManager.getUseMargins(),
syncColors: dashboardStateManager.getSyncColors(),
viewMode: dashboardStateManager.getViewMode(),
filters: query.filterManager.getFilters(),
query: dashboardStateManager.getQuery(),
title: dashboardStateManager.getTitle(),
panels: embeddablesMap,
lastReloadRequestTime,
dashboardCapabilities,
isEmbeddedExternally,
searchSessionId,
timeRange: {
..._.cloneDeep(query.timefilter.timefilter.getTime()),
},
};
};
export const getInputSubscription = ({
dashboardContainer,
dashboardStateManager,
filterManager,
}: {
dashboardContainer: DashboardContainer;
dashboardStateManager: DashboardStateManager;
filterManager: FilterManager;
}) =>
dashboardContainer.getInput$().subscribe(() => {
// This has to be first because handleDashboardContainerChanges causes
// appState.save which will cause refreshDashboardContainer to be called.
if (
!esFilters.compareFilters(
dashboardContainer.getInput().filters,
filterManager.getFilters(),
esFilters.COMPARE_ALL_OPTIONS
)
) {
// Add filters modifies the object passed to it, hence the clone deep.
filterManager.addFilters(_.cloneDeep(dashboardContainer.getInput().filters));
dashboardStateManager.applyFilters(
dashboardStateManager.getQuery(),
dashboardContainer.getInput().filters
);
}
dashboardStateManager.handleDashboardContainerChanges(dashboardContainer);
});
export const getOutputSubscription = ({
dashboardContainer,
indexPatterns,
onUpdateIndexPatterns,
}: {
dashboardContainer: DashboardContainer;
indexPatterns: IndexPatternsContract;
onUpdateIndexPatterns: (newIndexPatterns: IndexPattern[]) => void;
}) => {
const updateIndexPatternsOperator = pipe(
filter((container: DashboardContainer) => !!container && !isErrorEmbeddable(container)),
map((container: DashboardContainer): IndexPattern[] => {
let panelIndexPatterns: IndexPattern[] = [];
Object.values(container.getChildIds()).forEach((id) => {
const embeddableInstance = container.getChild(id);
if (isErrorEmbeddable(embeddableInstance)) return;
const embeddableIndexPatterns = (embeddableInstance.getOutput() as any).indexPatterns;
if (!embeddableIndexPatterns) return;
panelIndexPatterns.push(...embeddableIndexPatterns);
});
panelIndexPatterns = uniqBy(panelIndexPatterns, 'id');
return panelIndexPatterns;
}),
distinctUntilChanged((a, b) =>
deepEqual(
a.map((ip) => ip && ip.id),
b.map((ip) => ip && ip.id)
)
),
// using switchMap for previous task cancellation
switchMap((panelIndexPatterns: IndexPattern[]) => {
return new Observable((observer) => {
if (panelIndexPatterns && panelIndexPatterns.length > 0) {
if (observer.closed) return;
onUpdateIndexPatterns(panelIndexPatterns);
observer.complete();
} else {
indexPatterns.getDefault().then((defaultIndexPattern) => {
if (observer.closed) return;
onUpdateIndexPatterns([defaultIndexPattern as IndexPattern]);
observer.complete();
});
}
});
})
);
return merge(
// output of dashboard container itself
dashboardContainer.getOutput$(),
// plus output of dashboard container children,
// children may change, so make sure we subscribe/unsubscribe with switchMap
dashboardContainer.getOutput$().pipe(
map(() => dashboardContainer!.getChildIds()),
distinctUntilChanged(deepEqual),
switchMap((newChildIds: string[]) =>
merge(...newChildIds.map((childId) => dashboardContainer!.getChild(childId).getOutput$()))
)
)
)
.pipe(
mapTo(dashboardContainer),
startWith(dashboardContainer), // to trigger initial index pattern update
updateIndexPatternsOperator
)
.subscribe();
};
export const getFiltersSubscription = ({
query,
dashboardStateManager,
}: {
query: QueryStart;
dashboardStateManager: DashboardStateManager;
}) => {
return merge(query.filterManager.getUpdates$(), query.queryString.getUpdates$())
.pipe(debounceTime(100))
.subscribe(() => {
dashboardStateManager.applyFilters(
query.queryString.getQuery(),
query.filterManager.getFilters()
);
});
};
export const getSearchSessionIdFromURL = (history: History): string | undefined =>
getQueryParams(history.location)[DashboardConstants.SEARCH_SESSION_ID] as string | undefined;

View file

@ -8,36 +8,37 @@
import './index.scss';
import React from 'react';
import { History } from 'history';
import { Provider } from 'react-redux';
import { first } from 'rxjs/operators';
import { I18nProvider } from '@kbn/i18n/react';
import { parse, ParsedQuery } from 'query-string';
import { render, unmountComponentAtNode } from 'react-dom';
import { Switch, Route, RouteComponentProps, HashRouter, Redirect } from 'react-router-dom';
import { first } from 'rxjs/operators';
import { DashboardListing } from './listing';
import { dashboardStateStore } from './state';
import { DashboardApp } from './dashboard_app';
import { addHelpMenuToAppChrome, DashboardPanelStorage } from './lib';
import { DashboardNoMatch } from './listing/dashboard_no_match';
import { KibanaContextProvider } from '../services/kibana_react';
import { addHelpMenuToAppChrome, DashboardSessionStorage } from './lib';
import { createDashboardListingFilterUrl } from '../dashboard_constants';
import { getDashboardPageTitle, dashboardReadonlyBadge } from '../dashboard_strings';
import { createDashboardEditUrl, DashboardConstants } from '../dashboard_constants';
import { DashboardAppServices, DashboardEmbedSettings, RedirectToProps } from './types';
import { getDashboardPageTitle, dashboardReadonlyBadge } from '../dashboard_strings';
import { createKbnUrlStateStorage, withNotifyOnErrors } from '../services/kibana_utils';
import { DashboardAppServices, DashboardEmbedSettings, RedirectToProps } from '../types';
import {
DashboardFeatureFlagConfig,
DashboardSetupDependencies,
DashboardStart,
DashboardStartDependencies,
} from '../plugin';
import { createKbnUrlStateStorage, withNotifyOnErrors } from '../services/kibana_utils';
import { KibanaContextProvider } from '../services/kibana_react';
import {
AppMountParameters,
CoreSetup,
PluginInitializerContext,
ScopedHistory,
} from '../services/core';
import { DashboardNoMatch } from './listing/dashboard_no_match';
export const dashboardUrlParams = {
showTopMenu: 'show-top-menu',
@ -89,12 +90,14 @@ export async function mountApp({
const activeSpaceId =
spacesApi && (await spacesApi.getActiveSpace$().pipe(first()).toPromise())?.id;
let globalEmbedSettings: DashboardEmbedSettings | undefined;
let routerHistory: History;
const dashboardServices: DashboardAppServices = {
navigation,
onAppLeave,
savedObjects,
urlForwarding,
visualizations,
usageCollection,
core: coreStart,
data: dataStart,
@ -109,10 +112,6 @@ export async function mountApp({
indexPatterns: dataStart.indexPatterns,
savedQueryService: dataStart.query.savedQueries,
savedObjectsClient: coreStart.savedObjects.client,
dashboardPanelStorage: new DashboardPanelStorage(
core.notifications.toasts,
activeSpaceId || 'default'
),
savedDashboards: dashboardStart.getSavedDashboardLoader(),
savedObjectsTagging: savedObjectsTaggingOss?.getTaggingApi(),
allowByValueEmbeddables: initializerContext.config.get<DashboardFeatureFlagConfig>()
@ -127,7 +126,10 @@ export async function mountApp({
visualizeCapabilities: { save: Boolean(coreStart.application.capabilities.visualize?.save) },
storeSearchSession: Boolean(coreStart.application.capabilities.dashboard.storeSearchSession),
},
visualizations,
dashboardSessionStorage: new DashboardSessionStorage(
core.notifications.toasts,
activeSpaceId || 'default'
),
};
const getUrlStateStorage = (history: RouteComponentProps['history']) =>
@ -137,10 +139,9 @@ export async function mountApp({
...withNotifyOnErrors(core.notifications.toasts),
});
const redirect = (routeProps: RouteComponentProps, redirectTo: RedirectToProps) => {
const historyFunction = redirectTo.useReplace
? routeProps.history.replace
: routeProps.history.push;
const redirect = (redirectTo: RedirectToProps) => {
if (!routerHistory) return;
const historyFunction = redirectTo.useReplace ? routerHistory.replace : routerHistory.push;
let destination;
if (redirectTo.destination === 'dashboard') {
destination = redirectTo.id
@ -168,12 +169,15 @@ export async function mountApp({
if (routeParams.embed && !globalEmbedSettings) {
globalEmbedSettings = getDashboardEmbedSettings(routeParams);
}
if (!routerHistory) {
routerHistory = routeProps.history;
}
return (
<DashboardApp
history={routeProps.history}
embedSettings={globalEmbedSettings}
savedDashboardId={routeProps.match.params.id}
redirectTo={(props: RedirectToProps) => redirect(routeProps, props)}
redirectTo={redirect}
/>
);
};
@ -183,13 +187,15 @@ export async function mountApp({
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 (
<DashboardListing
initialFilter={filter}
title={title}
kbnUrlStateStorage={getUrlStateStorage(routeProps.history)}
redirectTo={(props: RedirectToProps) => redirect(routeProps, props)}
redirectTo={redirect}
/>
);
};
@ -215,26 +221,32 @@ export async function mountApp({
const app = (
<I18nProvider>
<KibanaContextProvider services={dashboardServices}>
<presentationUtil.ContextProvider>
<HashRouter>
<Switch>
<Route
path={[
DashboardConstants.CREATE_NEW_DASHBOARD_URL,
`${DashboardConstants.VIEW_DASHBOARD_URL}/:id`,
]}
render={renderDashboard}
/>
<Route exact path={DashboardConstants.LANDING_PAGE_PATH} render={renderListingPage} />
<Route exact path="/">
<Redirect to={DashboardConstants.LANDING_PAGE_PATH} />
</Route>
<Route render={renderNoMatch} />
</Switch>
</HashRouter>
</presentationUtil.ContextProvider>
</KibanaContextProvider>
<Provider store={dashboardStateStore}>
<KibanaContextProvider services={dashboardServices}>
<presentationUtil.ContextProvider>
<HashRouter>
<Switch>
<Route
path={[
DashboardConstants.CREATE_NEW_DASHBOARD_URL,
`${DashboardConstants.VIEW_DASHBOARD_URL}/:id`,
]}
render={renderDashboard}
/>
<Route
exact
path={DashboardConstants.LANDING_PAGE_PATH}
render={renderListingPage}
/>
<Route exact path="/">
<Redirect to={DashboardConstants.LANDING_PAGE_PATH} />
</Route>
<Route render={renderNoMatch} />
</Switch>
</HashRouter>
</presentationUtil.ContextProvider>
</KibanaContextProvider>
</Provider>
</I18nProvider>
);

View file

@ -1,272 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { createBrowserHistory } from 'history';
import { getSavedDashboardMock } from './test_helpers';
import { DashboardContainer, DashboardContainerInput, DashboardPanelState } from '.';
import { DashboardStateManager } from './dashboard_state_manager';
import { DashboardContainerServices } from './embeddable/dashboard_container';
import { EmbeddableInput, ViewMode } from '../services/embeddable';
import { createKbnUrlStateStorage } from '../services/kibana_utils';
import { InputTimeRange, TimefilterContract, TimeRange } from '../services/data';
import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks';
import { coreMock } from '../../../../core/public/mocks';
describe('DashboardState', function () {
let dashboardState: DashboardStateManager;
const savedDashboard = getSavedDashboardMock();
let mockTime: TimeRange = { to: 'now', from: 'now-15m' };
const mockTimefilter = {
getTime: () => {
return mockTime;
},
setTime: (time: InputTimeRange) => {
mockTime = time as TimeRange;
},
} as TimefilterContract;
// TS is *very* picky with type guards / predicates. can't just use jest.fn()
function mockHasTaggingCapabilities(obj: any): obj is any {
return false;
}
function initDashboardState() {
dashboardState = new DashboardStateManager({
savedDashboard,
hideWriteControls: false,
allowByValueEmbeddables: false,
hasPendingEmbeddable: () => false,
kibanaVersion: '7.0.0',
kbnUrlStateStorage: createKbnUrlStateStorage(),
history: createBrowserHistory(),
toasts: coreMock.createStart().notifications.toasts,
hasTaggingCapabilities: mockHasTaggingCapabilities,
});
}
function initDashboardContainer(initialInput?: Partial<DashboardContainerInput>) {
const { doStart } = embeddablePluginMock.createInstance();
const defaultInput: DashboardContainerInput = {
id: '123',
viewMode: ViewMode.EDIT,
filters: [] as DashboardContainerInput['filters'],
query: {} as DashboardContainerInput['query'],
timeRange: {} as DashboardContainerInput['timeRange'],
useMargins: true,
syncColors: false,
title: 'ultra awesome test dashboard',
isFullScreenMode: false,
panels: {} as DashboardContainerInput['panels'],
};
const input = { ...defaultInput, ...(initialInput ?? {}) };
return new DashboardContainer(input, { embeddable: doStart() } as DashboardContainerServices);
}
describe('syncTimefilterWithDashboard', function () {
test('syncs quick time', function () {
savedDashboard.timeRestore = true;
savedDashboard.timeFrom = 'now/w';
savedDashboard.timeTo = 'now/w';
mockTime.from = '2015-09-19 06:31:44.000';
mockTime.to = '2015-09-29 06:31:44.000';
initDashboardState();
dashboardState.syncTimefilterWithDashboardTime(mockTimefilter);
expect(mockTime.to).toBe('now/w');
expect(mockTime.from).toBe('now/w');
});
test('syncs relative time', function () {
savedDashboard.timeRestore = true;
savedDashboard.timeFrom = 'now-13d';
savedDashboard.timeTo = 'now';
mockTime.from = '2015-09-19 06:31:44.000';
mockTime.to = '2015-09-29 06:31:44.000';
initDashboardState();
dashboardState.syncTimefilterWithDashboardTime(mockTimefilter);
expect(mockTime.to).toBe('now');
expect(mockTime.from).toBe('now-13d');
});
test('syncs absolute time', function () {
savedDashboard.timeRestore = true;
savedDashboard.timeFrom = '2015-09-19 06:31:44.000';
savedDashboard.timeTo = '2015-09-29 06:31:44.000';
mockTime.from = 'now/w';
mockTime.to = 'now/w';
initDashboardState();
dashboardState.syncTimefilterWithDashboardTime(mockTimefilter);
expect(mockTime.to).toBe(savedDashboard.timeTo);
expect(mockTime.from).toBe(savedDashboard.timeFrom);
});
});
describe('Dashboard Container Changes', () => {
beforeEach(() => {
initDashboardState();
});
test('expanedPanelId in container input casues state update', () => {
dashboardState.setExpandedPanelId = jest.fn();
const dashboardContainer = initDashboardContainer({
expandedPanelId: 'theCoolestPanelOnThisDashboard',
panels: {
theCoolestPanelOnThisDashboard: {
explicitInput: { id: 'theCoolestPanelOnThisDashboard' },
} as DashboardPanelState<EmbeddableInput>,
},
});
dashboardState.handleDashboardContainerChanges(dashboardContainer);
expect(dashboardState.setExpandedPanelId).toHaveBeenCalledWith(
'theCoolestPanelOnThisDashboard'
);
});
test('expanedPanelId is not updated when it is the same', () => {
dashboardState.setExpandedPanelId = jest
.fn()
.mockImplementation(dashboardState.setExpandedPanelId);
const dashboardContainer = initDashboardContainer({
expandedPanelId: 'theCoolestPanelOnThisDashboard',
panels: {
theCoolestPanelOnThisDashboard: {
explicitInput: { id: 'theCoolestPanelOnThisDashboard' },
} as DashboardPanelState<EmbeddableInput>,
},
});
dashboardState.handleDashboardContainerChanges(dashboardContainer);
dashboardState.handleDashboardContainerChanges(dashboardContainer);
expect(dashboardState.setExpandedPanelId).toHaveBeenCalledTimes(1);
});
test('expandedPanelId is set to undefined if panel does not exist in input', () => {
dashboardState.setExpandedPanelId = jest
.fn()
.mockImplementation(dashboardState.setExpandedPanelId);
const dashboardContainer = initDashboardContainer({
expandedPanelId: 'theCoolestPanelOnThisDashboard',
panels: {
theCoolestPanelOnThisDashboard: {
explicitInput: { id: 'theCoolestPanelOnThisDashboard' },
} as DashboardPanelState<EmbeddableInput>,
},
});
dashboardState.handleDashboardContainerChanges(dashboardContainer);
expect(dashboardState.setExpandedPanelId).toHaveBeenCalledWith(
'theCoolestPanelOnThisDashboard'
);
dashboardContainer.updateInput({ expandedPanelId: 'theLeastCoolPanelOnThisDashboard' });
dashboardState.handleDashboardContainerChanges(dashboardContainer);
expect(dashboardState.setExpandedPanelId).toHaveBeenCalledWith(undefined);
});
});
describe('isDirty', function () {
beforeAll(() => {
initDashboardState();
});
test('getIsDirty is true if isDirty is true and editing', () => {
dashboardState.switchViewMode(ViewMode.EDIT);
dashboardState.isDirty = true;
expect(dashboardState.getIsDirty()).toBeTruthy();
});
test('getIsDirty is false if isDirty is true and editing', () => {
dashboardState.switchViewMode(ViewMode.VIEW);
dashboardState.isDirty = true;
expect(dashboardState.getIsDirty()).toBeFalsy();
});
});
describe('initial view mode', () => {
test('initial view mode set to view when hideWriteControls is true', () => {
const initialViewModeDashboardState = new DashboardStateManager({
savedDashboard,
hideWriteControls: true,
allowByValueEmbeddables: false,
hasPendingEmbeddable: () => false,
kibanaVersion: '7.0.0',
kbnUrlStateStorage: createKbnUrlStateStorage(),
history: createBrowserHistory(),
toasts: coreMock.createStart().notifications.toasts,
hasTaggingCapabilities: mockHasTaggingCapabilities,
});
expect(initialViewModeDashboardState.getViewMode()).toBe(ViewMode.VIEW);
});
test('initial view mode set to edit if edit mode specified in URL', () => {
const kbnUrlStateStorage = createKbnUrlStateStorage();
kbnUrlStateStorage.set('_a', { viewMode: ViewMode.EDIT });
const initialViewModeDashboardState = new DashboardStateManager({
savedDashboard,
kbnUrlStateStorage,
kibanaVersion: '7.0.0',
hideWriteControls: false,
allowByValueEmbeddables: false,
history: createBrowserHistory(),
hasPendingEmbeddable: () => false,
toasts: coreMock.createStart().notifications.toasts,
hasTaggingCapabilities: mockHasTaggingCapabilities,
});
expect(initialViewModeDashboardState.getViewMode()).toBe(ViewMode.EDIT);
});
test('initial view mode set to edit if the dashboard is new', () => {
const newDashboard = getSavedDashboardMock();
newDashboard.id = undefined;
const initialViewModeDashboardState = new DashboardStateManager({
savedDashboard: newDashboard,
kibanaVersion: '7.0.0',
hideWriteControls: false,
allowByValueEmbeddables: false,
history: createBrowserHistory(),
hasPendingEmbeddable: () => false,
kbnUrlStateStorage: createKbnUrlStateStorage(),
toasts: coreMock.createStart().notifications.toasts,
hasTaggingCapabilities: mockHasTaggingCapabilities,
});
expect(initialViewModeDashboardState.getViewMode()).toBe(ViewMode.EDIT);
});
test('initial view mode set to edit if there is a pending embeddable', () => {
const newDashboard = getSavedDashboardMock();
newDashboard.id = undefined;
const initialViewModeDashboardState = new DashboardStateManager({
savedDashboard: newDashboard,
kibanaVersion: '7.0.0',
hideWriteControls: false,
allowByValueEmbeddables: false,
history: createBrowserHistory(),
hasPendingEmbeddable: () => true,
kbnUrlStateStorage: createKbnUrlStateStorage(),
toasts: coreMock.createStart().notifications.toasts,
hasTaggingCapabilities: mockHasTaggingCapabilities,
});
expect(initialViewModeDashboardState.getViewMode()).toBe(ViewMode.EDIT);
});
});
});

View file

@ -1,771 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import _ from 'lodash';
import { Moment } from 'moment';
import { i18n } from '@kbn/i18n';
import { History } from 'history';
import { Observable, Subscription } from 'rxjs';
import { FilterUtils } from './lib/filter_utils';
import { DashboardContainer } from './embeddable';
import { DashboardSavedObject } from '../saved_dashboards';
import { migrateLegacyQuery } from './lib/migrate_legacy_query';
import {
getAppStateDefaults,
migrateAppState,
getDashboardIdFromUrl,
DashboardPanelStorage,
} from './lib';
import { convertPanelStateToSavedDashboardPanel } from '../../common/embeddable/embeddable_saved_object_converters';
import {
DashboardAppState,
DashboardAppStateDefaults,
DashboardAppStateInUrl,
DashboardAppStateTransitions,
SavedDashboardPanel,
} from '../types';
import { ViewMode } from '../services/embeddable';
import { UsageCollectionSetup } from '../services/usage_collection';
import { Filter, Query, TimefilterContract as Timefilter } from '../services/data';
import type { SavedObjectTagDecoratorTypeGuard } from '../services/saved_objects_tagging_oss';
import {
createStateContainer,
IKbnUrlStateStorage,
ISyncStateRef,
ReduxLikeStateContainer,
syncState,
} from '../services/kibana_utils';
import { STATE_STORAGE_KEY } from '../url_generator';
import { NotificationsStart } from '../services/core';
import { getMigratedToastText } from '../dashboard_strings';
/**
* Dashboard state manager handles connecting angular and redux state between the angular and react portions of the
* app. There are two "sources of truth" that need to stay in sync - AppState (aka the `_a` portion of the url) and
* the Store. They aren't complete duplicates of each other as AppState has state that the Store doesn't, and vice
* versa. They should be as decoupled as possible so updating the store won't affect bwc of urls.
*/
export class DashboardStateManager {
public savedDashboard: DashboardSavedObject;
public lastSavedDashboardFilters: {
timeTo?: string | Moment;
timeFrom?: string | Moment;
filterBars: Filter[];
query: Query;
};
private stateDefaults: DashboardAppStateDefaults;
private toasts: NotificationsStart['toasts'];
private hideWriteControls: boolean;
private kibanaVersion: string;
public isDirty: boolean;
private changeListeners: Array<(status: { dirty: boolean }) => void>;
private hasShownMigrationToast = false;
public get appState(): DashboardAppState {
return this.stateContainer.get();
}
public get appState$(): Observable<DashboardAppState> {
return this.stateContainer.state$;
}
private readonly stateContainer: ReduxLikeStateContainer<
DashboardAppState,
DashboardAppStateTransitions
>;
private readonly stateContainerChangeSub: Subscription;
private readonly dashboardPanelStorage?: DashboardPanelStorage;
public readonly kbnUrlStateStorage: IKbnUrlStateStorage;
private readonly stateSyncRef: ISyncStateRef;
private readonly allowByValueEmbeddables: boolean;
private readonly usageCollection: UsageCollectionSetup | undefined;
public readonly hasTaggingCapabilities: SavedObjectTagDecoratorTypeGuard;
private hasPendingEmbeddable: () => boolean;
/**
*
* @param savedDashboard
* @param hideWriteControls true if write controls should be hidden.
* @param kibanaVersion current kibanaVersion
* @param
*/
constructor({
toasts,
history,
kibanaVersion,
savedDashboard,
usageCollection,
hideWriteControls,
kbnUrlStateStorage,
hasPendingEmbeddable,
dashboardPanelStorage,
hasTaggingCapabilities,
allowByValueEmbeddables,
}: {
history: History;
kibanaVersion: string;
hideWriteControls: boolean;
hasPendingEmbeddable: () => boolean;
allowByValueEmbeddables: boolean;
savedDashboard: DashboardSavedObject;
toasts: NotificationsStart['toasts'];
usageCollection?: UsageCollectionSetup;
kbnUrlStateStorage: IKbnUrlStateStorage;
dashboardPanelStorage?: DashboardPanelStorage;
hasTaggingCapabilities: SavedObjectTagDecoratorTypeGuard;
}) {
this.toasts = toasts;
this.kibanaVersion = kibanaVersion;
this.savedDashboard = savedDashboard;
this.hideWriteControls = hideWriteControls;
this.usageCollection = usageCollection;
this.hasTaggingCapabilities = hasTaggingCapabilities;
this.allowByValueEmbeddables = allowByValueEmbeddables;
this.hasPendingEmbeddable = hasPendingEmbeddable;
this.dashboardPanelStorage = dashboardPanelStorage;
this.kbnUrlStateStorage = kbnUrlStateStorage;
// get state defaults from saved dashboard, make sure it is migrated
const viewMode = this.getInitialViewMode();
this.stateDefaults = migrateAppState(
getAppStateDefaults(viewMode, this.savedDashboard, this.hasTaggingCapabilities),
kibanaVersion,
usageCollection
);
// setup initial state by merging defaults with state from url & panels storage
// also run migration, as state in url could be of older version
const initialUrlState = this.kbnUrlStateStorage.get<DashboardAppState>(STATE_STORAGE_KEY);
const initialState = migrateAppState(
{
...this.stateDefaults,
...this.getUnsavedPanelState(),
...initialUrlState,
},
kibanaVersion,
usageCollection
);
this.isDirty = false;
if (initialUrlState?.panels && !_.isEqual(initialUrlState.panels, this.stateDefaults.panels)) {
this.isDirty = true;
this.setUnsavedPanels(initialState.panels);
}
// setup state container using initial state both from defaults and from url
this.stateContainer = createStateContainer<DashboardAppState, DashboardAppStateTransitions>(
initialState,
{
set: (state) => (prop, value) => ({ ...state, [prop]: value }),
setOption: (state) => (option, value) => ({
...state,
options: {
...state.options,
[option]: value,
},
}),
}
);
// We can't compare the filters stored on this.appState to this.savedDashboard because in order to apply
// the filters to the visualizations, we need to save it on the dashboard. We keep track of the original
// filter state in order to let the user know if their filters changed and provide this specific information
// in the 'lose changes' warning message.
this.lastSavedDashboardFilters = this.getFilterState();
this.changeListeners = [];
this.stateContainerChangeSub = this.stateContainer.state$.subscribe(() => {
this.isDirty = this.checkIsDirty();
this.changeListeners.forEach((listener) => listener({ dirty: this.isDirty }));
});
// setup state syncing utils. state container will be synced with url into `STATE_STORAGE_KEY` query param
this.stateSyncRef = syncState<DashboardAppStateInUrl>({
storageKey: STATE_STORAGE_KEY,
stateContainer: {
...this.stateContainer,
get: () => this.toUrlState(this.stateContainer.get()),
set: (stateFromUrl: DashboardAppStateInUrl | null) => {
// sync state required state container to be able to handle null
// overriding set() so it could handle null coming from url
if (stateFromUrl) {
// Skip this update if current dashboardId in the url is different from what we have in the current instance of state manager
// As dashboard is driven by angular at the moment, the destroy cycle happens async,
// If the dashboardId has changed it means this instance
// is going to be destroyed soon and we shouldn't sync state anymore,
// as it could potentially trigger further url updates
const currentDashboardIdInUrl = getDashboardIdFromUrl(history.location.pathname);
if (currentDashboardIdInUrl !== this.savedDashboard.id) return;
// set View mode before the rest of the state so unsaved panels can be added correctly.
if (this.appState.viewMode !== stateFromUrl.viewMode) {
this.switchViewMode(stateFromUrl.viewMode);
}
this.stateContainer.set({
...this.stateDefaults,
...this.getUnsavedPanelState(),
...stateFromUrl,
});
} else {
// Do nothing in case when state from url is empty,
// this fixes: https://github.com/elastic/kibana/issues/57789
// There are not much cases when state in url could become empty:
// 1. User manually removed `_a` from the url
// 2. Browser is navigating away from the page and most likely there is no `_a` in the url.
// In this case we don't want to do any state updates
// and just allow $scope.$on('destroy') fire later and clean up everything
}
},
},
stateStorage: this.kbnUrlStateStorage,
});
}
public startStateSyncing() {
this.saveState({ replace: true });
this.stateSyncRef.start();
}
public registerChangeListener(callback: (status: { dirty: boolean }) => void) {
this.changeListeners.push(callback);
}
public handleDashboardContainerChanges(dashboardContainer: DashboardContainer) {
let dirty = false;
let dirtyBecauseOfInitialStateMigration = false;
const savedDashboardPanelMap: { [key: string]: SavedDashboardPanel } = {};
const input = dashboardContainer.getInput();
this.getPanels().forEach((savedDashboardPanel) => {
if (input.panels[savedDashboardPanel.panelIndex] !== undefined) {
savedDashboardPanelMap[savedDashboardPanel.panelIndex] = savedDashboardPanel;
} else {
// A panel was deleted.
dirty = true;
}
});
const convertedPanelStateMap: { [key: string]: SavedDashboardPanel } = {};
let expandedPanelValid = false;
Object.values(input.panels).forEach((panelState) => {
if (savedDashboardPanelMap[panelState.explicitInput.id] === undefined) {
dirty = true;
}
if (panelState.explicitInput.id === input.expandedPanelId) {
expandedPanelValid = true;
}
convertedPanelStateMap[panelState.explicitInput.id] = convertPanelStateToSavedDashboardPanel(
panelState,
this.kibanaVersion
);
if (
!_.isEqual(
convertedPanelStateMap[panelState.explicitInput.id],
savedDashboardPanelMap[panelState.explicitInput.id]
)
) {
// A panel was changed
dirty = true;
const oldVersion = savedDashboardPanelMap[panelState.explicitInput.id]?.version;
const newVersion = convertedPanelStateMap[panelState.explicitInput.id]?.version;
if (oldVersion && newVersion && oldVersion !== newVersion) {
dirtyBecauseOfInitialStateMigration = true;
}
}
});
if (dirty) {
this.stateContainer.transitions.set('panels', Object.values(convertedPanelStateMap));
if (dirtyBecauseOfInitialStateMigration) {
if (this.getIsEditMode() && !this.hasShownMigrationToast) {
this.toasts.addSuccess(getMigratedToastText());
this.hasShownMigrationToast = true;
}
this.saveState({ replace: true });
}
// If a panel has been changed, and the state is now equal to the state in the saved object, remove the unsaved panels
if (!this.isDirty && this.getIsEditMode()) {
this.clearUnsavedPanels();
} else {
this.setUnsavedPanels(this.getPanels());
}
}
if (input.isFullScreenMode !== this.getFullScreenMode()) {
this.setFullScreenMode(input.isFullScreenMode);
}
if (expandedPanelValid && input.expandedPanelId !== this.getExpandedPanelId()) {
this.setExpandedPanelId(input.expandedPanelId);
} else if (!expandedPanelValid && this.getExpandedPanelId()) {
this.setExpandedPanelId(undefined);
}
if (!_.isEqual(input.query, this.getQuery())) {
this.setQuery(input.query);
}
this.changeListeners.forEach((listener) => listener({ dirty }));
}
public getFullScreenMode() {
return this.appState.fullScreenMode;
}
public setFullScreenMode(fullScreenMode: boolean) {
this.stateContainer.transitions.set('fullScreenMode', fullScreenMode);
}
public getExpandedPanelId() {
return this.appState.expandedPanelId;
}
public setExpandedPanelId(expandedPanelId?: string) {
this.stateContainer.transitions.set('expandedPanelId', expandedPanelId);
}
public setFilters(filters: Filter[]) {
this.stateContainer.transitions.set('filters', filters);
}
/**
* Resets the state back to the last saved version of the dashboard.
*/
public resetState() {
// In order to show the correct warning, we have to store the unsaved
// title on the dashboard object. We should fix this at some point, but this is how all the other object
// save panels work at the moment.
this.savedDashboard.title = this.savedDashboard.lastSavedTitle;
// appState.reset uses the internal defaults to reset the state, but some of the default settings (e.g. the panels
// array) point to the same object that is stored on appState and is getting modified.
// The right way to fix this might be to ensure the defaults object stored on state is a deep
// clone, but given how much code uses the state object, I determined that to be too risky of a change for
// now. TODO: revisit this!
const currentViewMode = this.stateContainer.get().viewMode;
this.stateDefaults = migrateAppState(
getAppStateDefaults(currentViewMode, this.savedDashboard, this.hasTaggingCapabilities),
this.kibanaVersion,
this.usageCollection
);
// The original query won't be restored by the above because the query on this.savedDashboard is applied
// in place in order for it to affect the visualizations.
this.stateDefaults.query = this.lastSavedDashboardFilters.query;
// Need to make a copy to ensure they are not overwritten.
this.stateDefaults.filters = [...this.getLastSavedFilterBars()];
this.isDirty = false;
this.stateContainer.set(this.stateDefaults);
}
/**
* Returns an object which contains the current filter state of this.savedDashboard.
*/
public getFilterState() {
return {
timeTo: this.savedDashboard.timeTo,
timeFrom: this.savedDashboard.timeFrom,
filterBars: this.savedDashboard.getFilters(),
query: this.savedDashboard.getQuery(),
};
}
public getTitle() {
return this.appState.title;
}
public isSaved() {
return !!this.savedDashboard.id;
}
public isNew() {
return !this.isSaved();
}
public getDescription() {
return this.appState.description;
}
public getTags() {
return this.appState.tags;
}
public setDescription(description: string) {
this.stateContainer.transitions.set('description', description);
}
public setTitle(title: string) {
this.savedDashboard.title = title;
this.stateContainer.transitions.set('title', title);
}
public setTags(tags: string[]) {
this.stateContainer.transitions.set('tags', tags);
}
public getAppState() {
return this.stateContainer.get();
}
public getQuery(): Query {
return migrateLegacyQuery(this.stateContainer.get().query);
}
public getSavedQueryId() {
return this.stateContainer.get().savedQuery;
}
public setSavedQueryId(id?: string) {
this.stateContainer.transitions.set('savedQuery', id);
}
public getUseMargins() {
// Existing dashboards that don't define this should default to false.
return this.appState.options.useMargins === undefined
? false
: this.appState.options.useMargins;
}
public setUseMargins(useMargins: boolean) {
this.stateContainer.transitions.setOption('useMargins', useMargins);
}
public getSyncColors() {
// Existing dashboards that don't define this should default to true.
return this.appState.options.syncColors === undefined ? true : this.appState.options.syncColors;
}
public setSyncColors(syncColors: boolean) {
this.stateContainer.transitions.setOption('syncColors', syncColors);
}
public getHidePanelTitles() {
return this.appState.options.hidePanelTitles;
}
public setHidePanelTitles(hidePanelTitles: boolean) {
this.stateContainer.transitions.setOption('hidePanelTitles', hidePanelTitles);
}
public getTimeRestore() {
return this.appState.timeRestore;
}
public setTimeRestore(timeRestore: boolean) {
this.stateContainer.transitions.set('timeRestore', timeRestore);
}
public getIsTimeSavedWithDashboard() {
return this.savedDashboard.timeRestore;
}
public getLastSavedFilterBars(): Filter[] {
return this.lastSavedDashboardFilters.filterBars;
}
public getLastSavedQuery() {
return this.lastSavedDashboardFilters.query;
}
/**
* @returns True if the query changed since the last time the dashboard was saved, or if it's a
* new dashboard, if the query differs from the default.
*/
public getQueryChanged() {
const currentQuery = this.appState.query;
const lastSavedQuery = this.getLastSavedQuery();
const query = migrateLegacyQuery(currentQuery);
const isLegacyStringQuery =
_.isString(lastSavedQuery) && _.isPlainObject(currentQuery) && _.has(currentQuery, 'query');
if (isLegacyStringQuery) {
return lastSavedQuery !== query.query;
}
return !_.isEqual(currentQuery, lastSavedQuery);
}
/**
* @returns True if the filter bar state has changed since the last time the dashboard was saved,
* or if it's a new dashboard, if the query differs from the default.
*/
public getFilterBarChanged() {
return !_.isEqual(
FilterUtils.cleanFiltersForComparison(this.appState.filters),
FilterUtils.cleanFiltersForComparison(this.getLastSavedFilterBars())
);
}
/**
* @param timeFilter
* @returns True if the time state has changed since the time saved with the dashboard.
*/
public getTimeChanged(timeFilter: Timefilter) {
return (
!FilterUtils.areTimesEqual(
this.lastSavedDashboardFilters.timeFrom,
timeFilter.getTime().from
) ||
!FilterUtils.areTimesEqual(this.lastSavedDashboardFilters.timeTo, timeFilter.getTime().to)
);
}
public getViewMode() {
if (this.hideWriteControls) {
return ViewMode.VIEW;
}
if (this.stateContainer) {
return this.appState.viewMode;
}
// get viewMode should work properly even before the state container is created
return this.getInitialViewMode();
}
public getIsViewMode() {
return this.getViewMode() === ViewMode.VIEW;
}
public getIsEditMode() {
return this.getViewMode() === ViewMode.EDIT;
}
/**
*
* @returns True if the dashboard has changed since the last save (or, is new).
*/
public getIsDirty(timeFilter?: Timefilter) {
// Filter bar comparison is done manually (see cleanFiltersForComparison for the reason) and time picker
// changes are not tracked by the state monitor.
const hasTimeFilterChanged = timeFilter ? this.getFiltersChanged(timeFilter) : false;
return (
this.hasUnsavedPanelState() ||
(this.getIsEditMode() && (this.isDirty || hasTimeFilterChanged))
);
}
public getPanels(): SavedDashboardPanel[] {
return this.appState.panels;
}
public updatePanel(panelIndex: string, panelAttributes: any) {
const foundPanel = this.getPanels().find(
(panel: SavedDashboardPanel) => panel.panelIndex === panelIndex
);
Object.assign(foundPanel, panelAttributes);
return foundPanel;
}
/**
* @param timeFilter
* @returns An array of user friendly strings indicating the filter types that have changed.
*/
public getChangedFilterTypes(timeFilter: Timefilter) {
const changedFilters = [];
if (this.getFilterBarChanged()) {
changedFilters.push('filter');
}
if (this.getQueryChanged()) {
changedFilters.push('query');
}
if (this.savedDashboard.timeRestore && this.getTimeChanged(timeFilter)) {
changedFilters.push('time range');
}
return changedFilters;
}
/**
* @returns True if filters (query, filter bar filters, and time picker if time is stored
* with the dashboard) have changed since the last saved state (or if the dashboard hasn't been saved,
* the default state).
*/
public getFiltersChanged(timeFilter: Timefilter) {
return this.getChangedFilterTypes(timeFilter).length > 0;
}
/**
* Updates timeFilter to match the time saved with the dashboard.
* @param timeFilter
* @param timeFilter.setTime
* @param timeFilter.setRefreshInterval
*/
public syncTimefilterWithDashboardTime(timeFilter: Timefilter) {
if (!this.getIsTimeSavedWithDashboard()) {
throw new Error(
i18n.translate('dashboard.stateManager.timeNotSavedWithDashboardErrorMessage', {
defaultMessage: 'The time is not saved with this dashboard so should not be synced.',
})
);
}
if (this.savedDashboard.timeFrom && this.savedDashboard.timeTo) {
timeFilter.setTime({
from: this.savedDashboard.timeFrom,
to: this.savedDashboard.timeTo,
});
}
}
/**
* Updates timeFilter to match the refreshInterval saved with the dashboard.
* @param timeFilter
*/
public syncTimefilterWithDashboardRefreshInterval(timeFilter: Timefilter) {
if (!this.getIsTimeSavedWithDashboard()) {
throw new Error(
i18n.translate('dashboard.stateManager.timeNotSavedWithDashboardErrorMessage', {
defaultMessage: 'The time is not saved with this dashboard so should not be synced.',
})
);
}
if (this.savedDashboard.refreshInterval) {
timeFilter.setRefreshInterval(this.savedDashboard.refreshInterval);
}
}
/**
* Synchronously writes current state to url
* returned boolean indicates whether the update happened and if history was updated
*/
private saveState({ replace }: { replace: boolean }): boolean {
// schedules setting current state to url
this.kbnUrlStateStorage.set<DashboardAppStateInUrl>(
STATE_STORAGE_KEY,
this.toUrlState(this.stateContainer.get())
);
// immediately forces scheduled updates and changes location
return !!this.kbnUrlStateStorage.kbnUrlControls.flush(replace);
}
public setQuery(query: Query) {
this.stateContainer.transitions.set('query', query);
}
/**
* Applies the current filter state to the dashboard.
* @param filter An array of filter bar filters.
*/
public applyFilters(query: Query, filters: Filter[]) {
this.savedDashboard.searchSource.setField('query', query);
this.savedDashboard.searchSource.setField('filter', filters);
this.stateContainer.transitions.set('query', query);
}
public switchViewMode(newMode: ViewMode) {
this.stateContainer.transitions.set('viewMode', newMode);
this.restorePanels();
}
/**
* Destroys and cleans up this object when it's no longer used.
*/
public destroy() {
this.stateContainerChangeSub.unsubscribe();
this.savedDashboard.destroy();
if (this.stateSyncRef) {
this.stateSyncRef.stop();
}
}
public restorePanels() {
const unsavedState = this.getUnsavedPanelState();
if (!unsavedState || unsavedState.panels?.length === 0) {
return;
}
this.stateContainer.set(
migrateAppState(
{
...this.stateDefaults,
...unsavedState,
...this.kbnUrlStateStorage.get<DashboardAppState>(STATE_STORAGE_KEY),
viewMode: this.getViewMode(),
},
this.kibanaVersion,
this.usageCollection
)
);
}
public clearUnsavedPanels() {
if (!this.allowByValueEmbeddables || !this.dashboardPanelStorage) {
return;
}
this.dashboardPanelStorage.clearPanels(this.savedDashboard?.id);
}
public hasUnsavedPanelState(): boolean {
const panels = this.dashboardPanelStorage?.getPanels(this.savedDashboard?.id);
return panels !== undefined && panels.length > 0;
}
private getUnsavedPanelState(): { panels?: SavedDashboardPanel[] } {
if (!this.allowByValueEmbeddables || this.getIsViewMode() || !this.dashboardPanelStorage) {
return {};
}
const panels = this.dashboardPanelStorage.getPanels(this.savedDashboard?.id);
return panels ? { panels } : {};
}
private setUnsavedPanels(newPanels: SavedDashboardPanel[]) {
if (
!this.allowByValueEmbeddables ||
this.getIsViewMode() ||
!this.getIsDirty() ||
!this.dashboardPanelStorage
) {
return;
}
this.dashboardPanelStorage.setPanels(this.savedDashboard?.id, newPanels);
}
private toUrlState(state: DashboardAppState): DashboardAppStateInUrl {
if (this.getIsEditMode() && !this.allowByValueEmbeddables) {
return state;
}
const { panels, ...stateWithoutPanels } = state;
return stateWithoutPanels;
}
private getInitialViewMode() {
if (this.hideWriteControls) {
return ViewMode.VIEW;
}
const viewModeFromUrl = this.kbnUrlStateStorage.get<DashboardAppState>(STATE_STORAGE_KEY)
?.viewMode;
if (viewModeFromUrl) {
return viewModeFromUrl;
}
return !this.savedDashboard.id || this.hasPendingEmbeddable() ? ViewMode.EDIT : ViewMode.VIEW;
}
private checkIsDirty() {
// Filters need to be compared manually because they sometimes have a $$hashkey stored on the object.
// Query needs to be compared manually because saved legacy queries get migrated in app state automatically
const propsToIgnore: Array<keyof DashboardAppState> = ['viewMode', 'filters', 'query'];
const initial = _.omit(this.stateDefaults, propsToIgnore);
const current = _.omit(this.stateContainer.get(), propsToIgnore);
return !_.isEqual(initial, current);
}
}

View file

@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import _ from 'lodash';
import React from 'react';
import ReactDOM from 'react-dom';
import { I18nProvider } from '@kbn/i18n/react';
@ -20,7 +21,6 @@ import {
Container,
PanelState,
IEmbeddable,
ContainerInput,
EmbeddableInput,
EmbeddableStart,
EmbeddableOutput,
@ -36,30 +36,13 @@ import {
KibanaReactContextValue,
} from '../../services/kibana_react';
import { PLACEHOLDER_EMBEDDABLE } from './placeholder';
import { PanelPlacementMethod, IPanelPlacementArgs } from './panel/dashboard_panel_placement';
import { DashboardAppCapabilities } from '../types';
import { DashboardAppCapabilities, DashboardContainerInput } from '../../types';
import { PresentationUtilPluginStart } from '../../services/presentation_util';
import { PanelPlacementMethod, IPanelPlacementArgs } from './panel/dashboard_panel_placement';
export interface DashboardContainerInput extends ContainerInput {
dashboardCapabilities?: DashboardAppCapabilities;
refreshConfig?: RefreshInterval;
isEmbeddedExternally?: boolean;
isFullScreenMode: boolean;
expandedPanelId?: string;
timeRange: TimeRange;
description?: string;
useMargins: boolean;
syncColors?: boolean;
viewMode: ViewMode;
filters: Filter[];
title: string;
query: Query;
panels: {
[panelId: string]: DashboardPanelState<EmbeddableInput & { [k: string]: unknown }>;
};
}
export interface DashboardContainerServices {
ExitFullScreenButton: React.ComponentType<any>;
presentationUtil: PresentationUtilPluginStart;
SavedObjectFinder: React.ComponentType<any>;
notifications: CoreStart['notifications'];
application: CoreStart['application'];
@ -69,7 +52,6 @@ export interface DashboardContainerServices {
embeddable: EmbeddableStart;
uiActions: UiActionsStart;
http: CoreStart['http'];
presentationUtil: PresentationUtilPluginStart;
}
interface IndexSignature {
@ -104,7 +86,6 @@ const defaultCapabilities: DashboardAppCapabilities = {
export class DashboardContainer extends Container<InheritedChildInput, DashboardContainerInput> {
public readonly type = DASHBOARD_CONTAINER_TYPE;
public switchViewMode?: (newViewMode: ViewMode) => void;
public getPanelCount = () => {
return Object.keys(this.getInput().panels).length;
@ -134,7 +115,8 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
partial: Partial<TEmbeddableInput> = {}
): DashboardPanelState<TEmbeddableInput> {
const panelState = super.createNewPanelState(factory, partial);
return createPanelState(panelState, this.input.panels);
const { newPanel } = createPanelState(panelState, this.input.panels);
return newPanel;
}
public showPlaceholderUntil<TPlacementMethodArgs extends IPanelPlacementArgs>(
@ -155,7 +137,8 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
],
},
} as PanelState<EmbeddableInput>;
const placeholderPanelState = createPanelState(
const { otherPanels, newPanel: placeholderPanelState } = createPanelState(
originalPanelState,
this.input.panels,
placementMethod,
@ -164,7 +147,7 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
this.updateInput({
panels: {
...this.input.panels,
...otherPanels,
[placeholderPanelState.explicitInput.id]: placeholderPanelState,
},
});
@ -248,7 +231,7 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
<I18nProvider>
<KibanaContextProvider services={this.services}>
<this.services.presentationUtil.ContextProvider>
<DashboardViewport container={this} switchViewMode={this.switchViewMode} />
<DashboardViewport container={this} />
</this.services.presentationUtil.ContextProvider>
</KibanaContextProvider>
</I18nProvider>,

View file

@ -7,7 +7,7 @@
*/
import * as React from 'react';
import { DashboardContainerInput } from './dashboard_container';
import { DashboardContainerInput } from '../..';
import { DashboardContainerFactory } from './dashboard_container_factory';
import { EmbeddableRenderer } from '../../services/embeddable';

View file

@ -8,6 +8,10 @@
import { i18n } from '@kbn/i18n';
import { EmbeddablePersistableStateService } from 'src/plugins/embeddable/common';
import { DashboardContainerInput } from '../..';
import { DASHBOARD_CONTAINER_TYPE } from './dashboard_constants';
import { DashboardContainer, DashboardContainerServices } from './dashboard_container';
import {
Container,
ErrorEmbeddable,
@ -15,12 +19,6 @@ import {
EmbeddableFactory,
EmbeddableFactoryDefinition,
} from '../../services/embeddable';
import {
DashboardContainer,
DashboardContainerInput,
DashboardContainerServices,
} from './dashboard_container';
import { DASHBOARD_CONTAINER_TYPE } from './dashboard_constants';
import {
createExtract,
createInject,

View file

@ -1025,30 +1025,7 @@ exports[`DashboardEmptyScreen renders correctly with view mode 1`] = `
className="euiTextColor euiTextColor--subdued"
>
<p>
Click
<span>
 
</span>
<EuiLink
aria-label="Edit dashboard"
data-test-subj=""
onClick={[MockFunction]}
>
<button
aria-label="Edit dashboard"
className="euiLink euiLink--primary"
data-test-subj=""
disabled={false}
onClick={[MockFunction]}
type="button"
>
Edit
</button>
</EuiLink>
<span>
 
</span>
in the menu bar above to start adding panels.
Click edit in the menu bar above to start adding panels.
</p>
</div>
</EuiTextColor>

View file

@ -10,7 +10,6 @@ import React from 'react';
import { I18nProvider } from '@kbn/i18n/react';
import {
EuiIcon,
EuiLink,
EuiSpacer,
EuiPageContent,
EuiPageBody,
@ -24,7 +23,6 @@ import { emptyScreenStrings } from '../../../dashboard_strings';
export interface DashboardEmptyScreenProps {
isEditMode?: boolean;
onLinkClick: () => void;
uiSettings: IUiSettingsClient;
http: HttpStart;
isReadonlyMode?: boolean;
@ -32,7 +30,6 @@ export interface DashboardEmptyScreenProps {
export function DashboardEmptyScreen({
isEditMode,
onLinkClick,
uiSettings,
http,
isReadonlyMode,
@ -41,33 +38,7 @@ export function DashboardEmptyScreen({
const emptyStateGraphicURL = IS_DARK_THEME
? '/plugins/home/assets/welcome_graphic_dark_2x.png'
: '/plugins/home/assets/welcome_graphic_light_2x.png';
const paragraph = (
description1: string | null,
description2: string,
linkText: string,
ariaLabel: string,
dataTestSubj?: string
) => {
return (
<EuiText size="m" color="subdued">
<p>
{description1}
{description1 && <span>&nbsp;</span>}
<EuiLink onClick={onLinkClick} aria-label={ariaLabel} data-test-subj={dataTestSubj || ''}>
{linkText}
</EuiLink>
<span>&nbsp;</span>
{description2}
</p>
</EuiText>
);
};
const enterEditModeParagraph = paragraph(
emptyScreenStrings.getHowToStartWorkingOnNewDashboardDescription1(),
emptyScreenStrings.getHowToStartWorkingOnNewDashboardDescription2(),
emptyScreenStrings.getHowToStartWorkingOnNewDashboardEditLinkText(),
emptyScreenStrings.getHowToStartWorkingOnNewDashboardEditLinkAriaLabel()
);
const page = (mainText: string, showAdditionalParagraph?: boolean, additionalText?: string) => {
return (
<EuiPage
@ -94,7 +65,11 @@ export function DashboardEmptyScreen({
{showAdditionalParagraph ? (
<React.Fragment>
<EuiSpacer size="m" />
<div className="dshStartScreen__panelDesc">{enterEditModeParagraph}</div>
<div className="dshStartScreen__panelDesc">
<EuiText size="m" color="subdued">
<p>{emptyScreenStrings.getHowToStartWorkingOnNewDashboardDescription()}</p>
</EuiText>
</div>
</React.Fragment>
) : null}
</EuiPageContent>

View file

@ -10,7 +10,7 @@ export {
DashboardContainerFactoryDefinition,
DashboardContainerFactory,
} from './dashboard_container_factory';
export { DashboardContainer, DashboardContainerInput } from './dashboard_container';
export { DashboardContainer } from './dashboard_container';
export { createPanelState } from './panel';
export * from './types';

View file

@ -18,7 +18,7 @@ interface TestInput extends EmbeddableInput {
const panels: { [key: string]: DashboardPanelState } = {};
test('createPanelState adds a new panel state in 0,0 position', () => {
const panelState = createPanelState<TestInput>(
const { newPanel: panelState } = createPanelState<TestInput>(
{
type: CONTACT_CARD_EMBEDDABLE,
explicitInput: { test: 'hi', id: '123' },
@ -37,7 +37,7 @@ test('createPanelState adds a new panel state in 0,0 position', () => {
});
test('createPanelState adds a second new panel state', () => {
const panelState = createPanelState<TestInput>(
const { newPanel: panelState } = createPanelState<TestInput>(
{ type: CONTACT_CARD_EMBEDDABLE, explicitInput: { test: 'bye', id: '456' } },
panels
);
@ -51,7 +51,7 @@ test('createPanelState adds a second new panel state', () => {
});
test('createPanelState adds a third new panel state', () => {
const panelState = createPanelState<TestInput>(
const { newPanel: panelState } = createPanelState<TestInput>(
{
type: CONTACT_CARD_EMBEDDABLE,
explicitInput: { test: 'bye', id: '789' },
@ -68,7 +68,7 @@ test('createPanelState adds a third new panel state', () => {
test('createPanelState adds a new panel state in the top most position', () => {
delete panels['456'];
const panelState = createPanelState<TestInput>(
const { newPanel: panelState } = createPanelState<TestInput>(
{
type: CONTACT_CARD_EMBEDDABLE,
explicitInput: { test: 'bye', id: '987' },

View file

@ -26,7 +26,10 @@ export function createPanelState<
currentPanels: { [key: string]: DashboardPanelState },
placementMethod?: PanelPlacementMethod<TPlacementMethodArgs>,
placementArgs?: TPlacementMethodArgs
): DashboardPanelState<TEmbeddableInput> {
): {
newPanel: DashboardPanelState<TEmbeddableInput>;
otherPanels: { [key: string]: DashboardPanelState };
} {
const defaultPlacementArgs = {
width: DEFAULT_PANEL_WIDTH,
height: DEFAULT_PANEL_HEIGHT,
@ -39,15 +42,18 @@ export function createPanelState<
}
: defaultPlacementArgs;
const gridDataLocation = placementMethod
const { newPanelPlacement, otherPanels } = placementMethod
? placementMethod(finalPlacementArgs as TPlacementMethodArgs)
: findTopLeftMostOpenSpace(defaultPlacementArgs);
return {
gridData: {
...gridDataLocation,
i: panelState.explicitInput.id,
newPanel: {
gridData: {
...newPanelPlacement,
i: panelState.explicitInput.id,
},
...panelState,
},
...panelState,
otherPanels,
};
}

View file

@ -13,7 +13,12 @@ import { DashboardPanelState, DASHBOARD_GRID_COLUMN_COUNT } from '..';
export type PanelPlacementMethod<PlacementArgs extends IPanelPlacementArgs> = (
args: PlacementArgs
) => Omit<GridData, 'i'>;
) => PanelPlacementMethodReturn;
interface PanelPlacementMethodReturn {
newPanelPlacement: Omit<GridData, 'i'>;
otherPanels: { [key: string]: DashboardPanelState };
}
export interface IPanelPlacementArgs {
width: number;
@ -30,7 +35,7 @@ export function findTopLeftMostOpenSpace({
width,
height,
currentPanels,
}: IPanelPlacementArgs): Omit<GridData, 'i'> {
}: IPanelPlacementArgs): PanelPlacementMethodReturn {
let maxY = -1;
const currentPanelsArray = Object.values(currentPanels);
@ -40,7 +45,7 @@ export function findTopLeftMostOpenSpace({
// Handle case of empty grid.
if (maxY < 0) {
return { x: 0, y: 0, w: width, h: height };
return { newPanelPlacement: { x: 0, y: 0, w: width, h: height }, otherPanels: currentPanels };
}
const grid = new Array(maxY);
@ -80,7 +85,10 @@ export function findTopLeftMostOpenSpace({
if (spaceIsEmpty && fitsPanelWidth && fitsPanelHeight) {
// Found space
return { x, y, w: width, h: height };
return {
newPanelPlacement: { x, y, w: width, h: height },
otherPanels: currentPanels,
};
} else if (grid[h][w] === 1) {
// x, y spot doesn't work, break.
break;
@ -90,7 +98,7 @@ export function findTopLeftMostOpenSpace({
}
}
}
return { x: 0, y: maxY, w: width, h: height };
return { newPanelPlacement: { x: 0, y: maxY, w: width, h: height }, otherPanels: currentPanels };
}
interface IplacementDirection {
@ -123,15 +131,15 @@ export function placePanelBeside({
height,
currentPanels,
placeBesideId,
}: IPanelPlacementBesideArgs): Omit<GridData, 'i'> {
}: IPanelPlacementBesideArgs): PanelPlacementMethodReturn {
const panelToPlaceBeside = currentPanels[placeBesideId];
if (!panelToPlaceBeside) {
throw new PanelNotFoundError();
}
const beside = panelToPlaceBeside.gridData;
const otherPanels: GridData[] = [];
const otherPanelGridData: GridData[] = [];
_.forOwn(currentPanels, (panel: DashboardPanelState, key: string | undefined) => {
otherPanels.push(panel.gridData);
otherPanelGridData.push(panel.gridData);
});
const possiblePlacementDirections: IplacementDirection[] = [
@ -147,7 +155,7 @@ export function placePanelBeside({
direction.grid.x + direction.grid.w <= DASHBOARD_GRID_COLUMN_COUNT &&
direction.grid.y >= 0
) {
const intersection = otherPanels.some((currentPanelGrid: GridData) => {
const intersection = otherPanelGridData.some((currentPanelGrid: GridData) => {
return (
direction.grid.x + direction.grid.w > currentPanelGrid.x &&
direction.grid.x < currentPanelGrid.x + currentPanelGrid.w &&
@ -156,7 +164,7 @@ export function placePanelBeside({
);
});
if (!intersection) {
return direction.grid;
return { newPanelPlacement: direction.grid, otherPanels: currentPanels };
}
} else {
direction.fits = false;
@ -168,7 +176,8 @@ export function placePanelBeside({
* 2. place the cloned panel to the bottom
* 3. reposition the panels after the cloned panel in the grid
*/
const grid = otherPanels.sort(comparePanels);
const otherPanels = { ...currentPanels };
const grid = otherPanelGridData.sort(comparePanels);
let position = 0;
for (position; position < grid.length; position++) {
@ -182,13 +191,13 @@ export function placePanelBeside({
const diff =
bottomPlacement.grid.y +
bottomPlacement.grid.h -
currentPanels[originalPositionInTheGrid].gridData.y;
otherPanels[originalPositionInTheGrid].gridData.y;
for (let j = position + 1; j < grid.length; j++) {
originalPositionInTheGrid = grid[j].i;
const movedPanel = _.cloneDeep(currentPanels[originalPositionInTheGrid]);
const movedPanel = _.cloneDeep(otherPanels[originalPositionInTheGrid]);
movedPanel.gridData.y = movedPanel.gridData.y + diff;
currentPanels[originalPositionInTheGrid] = movedPanel;
otherPanels[originalPositionInTheGrid] = movedPanel;
}
return bottomPlacement.grid;
return { newPanelPlacement: bottomPlacement.grid, otherPanels };
}

View file

@ -15,7 +15,6 @@ import { context } from '../../../services/kibana_react';
import { DashboardEmptyScreen } from '../empty_screen/dashboard_empty_screen';
export interface DashboardViewportProps {
switchViewMode?: (newViewMode: ViewMode) => void;
container: DashboardContainer;
}
@ -120,7 +119,6 @@ export class DashboardViewport extends React.Component<DashboardViewportProps, S
isReadonlyMode={
this.props.container.getInput().dashboardCapabilities?.hideWriteControls
}
onLinkClick={() => this.props.switchViewMode?.(ViewMode.EDIT)}
isEditMode={isEditMode}
uiSettings={this.context.services.uiSettings}
http={this.context.services.http}

View file

@ -6,7 +6,4 @@
* Side Public License, v 1.
*/
export { useSavedDashboard } from './use_saved_dashboard';
export { useDashboardContainer } from './use_dashboard_container';
export { useDashboardBreadcrumbs } from './use_dashboard_breadcrumbs';
export { useDashboardStateManager } from './use_dashboard_state_manager';
export { useDashboardAppState } from './use_dashboard_app_state';

View file

@ -0,0 +1,337 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { of } from 'rxjs';
import { Provider } from 'react-redux';
import { createBrowserHistory } from 'history';
import { renderHook, act, RenderHookResult } from '@testing-library/react-hooks';
import { DashboardContainer } from '..';
import { DashboardSessionStorage } from '../lib';
import { coreMock } from '../../../../../core/public/mocks';
import { DashboardConstants } from '../../dashboard_constants';
import { dataPluginMock } from '../../../../data/public/mocks';
import { SavedObjectLoader } from '../../services/saved_objects';
import { DashboardAppServices, DashboardAppState } from '../../types';
import { KibanaContextProvider } from '../../../../kibana_react/public';
import { EmbeddableFactory, ViewMode } from '../../services/embeddable';
import { dashboardStateStore, setDescription, setViewMode } from '../state';
import { DashboardContainerServices } from '../embeddable/dashboard_container';
import { createKbnUrlStateStorage, defer } from '../../../../kibana_utils/public';
import { Filter, IIndexPattern, IndexPatternsContract } from '../../services/data';
import { useDashboardAppState, UseDashboardStateProps } from './use_dashboard_app_state';
import {
getSampleDashboardInput,
getSavedDashboardMock,
makeDefaultServices,
} from '../test_helpers';
interface SetupEmbeddableFactoryReturn {
finalizeEmbeddableCreation: () => void;
dashboardContainer: DashboardContainer;
dashboardDestroySpy: jest.SpyInstance<unknown>;
}
interface RenderDashboardStateHookReturn {
embeddableFactoryResult: SetupEmbeddableFactoryReturn;
renderHookResult: RenderHookResult<Partial<UseDashboardStateProps>, DashboardAppState>;
services: DashboardAppServices;
props: UseDashboardStateProps;
}
const originalDashboardEmbeddableId = 'originalDashboardEmbeddableId';
const createDashboardAppStateProps = (): UseDashboardStateProps => ({
kbnUrlStateStorage: createKbnUrlStateStorage(),
savedDashboardId: 'testDashboardId',
history: createBrowserHistory(),
isEmbeddedExternally: false,
redirectTo: jest.fn(),
});
const createDashboardAppStateServices = () => {
const defaults = makeDefaultServices();
const indexPatterns = {} as IndexPatternsContract;
const defaultIndexPattern = { id: 'foo', fields: [{ name: 'bar' }] } as IIndexPattern;
indexPatterns.ensureDefaultIndexPattern = jest
.fn()
.mockImplementation(() => Promise.resolve(true));
indexPatterns.getDefault = jest
.fn()
.mockImplementation(() => Promise.resolve(defaultIndexPattern));
const data = dataPluginMock.createStartContract();
data.query.filterManager.getUpdates$ = jest.fn().mockImplementation(() => of(void 0));
data.query.queryString.getUpdates$ = jest.fn().mockImplementation(() => of({}));
data.query.timefilter.timefilter.getTimeUpdate$ = jest.fn().mockImplementation(() => of(void 0));
data.query.timefilter.timefilter.getRefreshIntervalUpdate$ = jest
.fn()
.mockImplementation(() => of(void 0));
return { ...defaults, indexPatterns, data };
};
const setupEmbeddableFactory = (
services: DashboardAppServices,
id: string
): SetupEmbeddableFactoryReturn => {
const coreStart = coreMock.createStart();
const containerOptions = ({
notifications: services.core.notifications,
savedObjectMetaData: {} as unknown,
ExitFullScreenButton: () => null,
embeddable: services.embeddable,
uiSettings: services.uiSettings,
SavedObjectFinder: () => null,
overlays: coreStart.overlays,
application: {} as unknown,
inspector: {} as unknown,
uiActions: {} as unknown,
http: coreStart.http,
} as unknown) as DashboardContainerServices;
const dashboardContainer = new DashboardContainer(
{ ...getSampleDashboardInput(), id },
containerOptions
);
const deferEmbeddableCreate = defer();
services.embeddable.getEmbeddableFactory = jest.fn().mockImplementation(
() =>
(({
create: () => deferEmbeddableCreate.promise,
} as unknown) as EmbeddableFactory)
);
const dashboardDestroySpy = jest.spyOn(dashboardContainer, 'destroy');
return {
dashboardContainer,
dashboardDestroySpy,
finalizeEmbeddableCreation: () => {
act(() => {
deferEmbeddableCreate.resolve(dashboardContainer);
});
},
};
};
const renderDashboardAppStateHook = ({
partialProps,
partialServices,
}: {
partialProps?: Partial<UseDashboardStateProps>;
partialServices?: Partial<DashboardAppServices>;
}): RenderDashboardStateHookReturn => {
const props = { ...createDashboardAppStateProps(), ...(partialProps ?? {}) };
const services = { ...createDashboardAppStateServices(), ...(partialServices ?? {}) };
const embeddableFactoryResult = setupEmbeddableFactory(services, originalDashboardEmbeddableId);
const renderHookResult = renderHook(
(replaceProps: Partial<UseDashboardStateProps>) =>
useDashboardAppState({ ...props, ...replaceProps }),
{
wrapper: ({ children }) => (
<Provider store={dashboardStateStore}>
<KibanaContextProvider services={services}>{children}</KibanaContextProvider>
</Provider>
),
}
);
return { embeddableFactoryResult, renderHookResult, services, props };
};
describe('Dashboard container lifecycle', () => {
test('Dashboard container is destroyed on unmount', async () => {
const { renderHookResult, embeddableFactoryResult } = renderDashboardAppStateHook({});
embeddableFactoryResult.finalizeEmbeddableCreation();
await renderHookResult.waitForNextUpdate();
expect(embeddableFactoryResult.dashboardContainer).toBe(
renderHookResult.result.current.dashboardContainer
);
expect(embeddableFactoryResult.dashboardDestroySpy).not.toBeCalled();
renderHookResult.unmount();
expect(embeddableFactoryResult.dashboardDestroySpy).toBeCalled();
});
test('Old dashboard container is destroyed when new dashboardId is given', async () => {
const { renderHookResult, embeddableFactoryResult, services } = renderDashboardAppStateHook({});
const getResult = () => renderHookResult.result.current;
// on initial render dashboard container is undefined
expect(getResult().dashboardContainer).toBeUndefined();
embeddableFactoryResult.finalizeEmbeddableCreation();
await renderHookResult.waitForNextUpdate();
expect(embeddableFactoryResult.dashboardContainer).toBe(getResult().dashboardContainer);
expect(embeddableFactoryResult.dashboardDestroySpy).not.toBeCalled();
const newDashboardId = 'wow_a_new_dashboard_id';
const embeddableFactoryNew = setupEmbeddableFactory(services, newDashboardId);
renderHookResult.rerender({ savedDashboardId: newDashboardId });
embeddableFactoryNew.finalizeEmbeddableCreation();
await renderHookResult.waitForNextUpdate();
expect(embeddableFactoryNew.dashboardContainer).toEqual(getResult().dashboardContainer);
expect(embeddableFactoryNew.dashboardDestroySpy).not.toBeCalled();
expect(embeddableFactoryResult.dashboardDestroySpy).toBeCalled();
});
test('Dashboard container is destroyed if dashboard id is changed before container is resolved', async () => {
const { renderHookResult, embeddableFactoryResult, services } = renderDashboardAppStateHook({});
const getResult = () => renderHookResult.result.current;
// on initial render dashboard container is undefined
expect(getResult().dashboardContainer).toBeUndefined();
await act(() => Promise.resolve()); // wait for the original savedDashboard to be loaded...
const newDashboardId = 'wow_a_new_dashboard_id';
const embeddableFactoryNew = setupEmbeddableFactory(services, newDashboardId);
renderHookResult.rerender({ savedDashboardId: newDashboardId });
await act(() => Promise.resolve()); // wait for the new savedDashboard to be loaded...
embeddableFactoryNew.finalizeEmbeddableCreation();
await renderHookResult.waitForNextUpdate();
expect(embeddableFactoryNew.dashboardContainer).toBe(getResult().dashboardContainer);
expect(embeddableFactoryNew.dashboardDestroySpy).not.toBeCalled();
embeddableFactoryResult.finalizeEmbeddableCreation();
await act(() => Promise.resolve()); // Can't use waitFor from hooks, because there is no hook update
expect(embeddableFactoryNew.dashboardContainer).toBe(getResult().dashboardContainer);
expect(embeddableFactoryNew.dashboardDestroySpy).not.toBeCalled();
expect(embeddableFactoryResult.dashboardDestroySpy).toBeCalled();
});
});
describe('Dashboard initial state', () => {
it('Extracts state from Dashboard Saved Object', async () => {
const { renderHookResult, embeddableFactoryResult } = renderDashboardAppStateHook({});
const getResult = () => renderHookResult.result.current;
// saved dashboard isn't applied until after the dashboard embeddable has been created.
expect(getResult().savedDashboard).toBeUndefined();
embeddableFactoryResult.finalizeEmbeddableCreation();
await renderHookResult.waitForNextUpdate();
expect(getResult().savedDashboard).toBeDefined();
expect(getResult().savedDashboard?.title).toEqual(
getResult().getLatestDashboardState?.().title
);
});
it('Sets initial time range and filters from saved dashboard', async () => {
const savedDashboards = {} as SavedObjectLoader;
savedDashboards.get = jest.fn().mockImplementation((id?: string) =>
Promise.resolve(
getSavedDashboardMock({
getFilters: () => [({ meta: { test: 'filterMeTimbers' } } as unknown) as Filter],
timeRestore: true,
timeFrom: 'now-13d',
timeTo: 'now',
id,
})
)
);
const partialServices: Partial<DashboardAppServices> = { savedDashboards };
const { renderHookResult, embeddableFactoryResult, services } = renderDashboardAppStateHook({
partialServices,
});
const getResult = () => renderHookResult.result.current;
embeddableFactoryResult.finalizeEmbeddableCreation();
await renderHookResult.waitForNextUpdate();
expect(getResult().getLatestDashboardState?.().timeRestore).toEqual(true);
expect(services.data.query.timefilter.timefilter.setTime).toHaveBeenCalledWith({
from: 'now-13d',
to: 'now',
});
expect(services.data.query.filterManager.setAppFilters).toHaveBeenCalledWith([
({ meta: { test: 'filterMeTimbers' } } as unknown) as Filter,
]);
});
it('Combines session state and URL state into initial state', async () => {
const dashboardSessionStorage = ({
getState: jest
.fn()
.mockReturnValue({ viewMode: ViewMode.EDIT, description: 'this should be overwritten' }),
} as unknown) as DashboardSessionStorage;
const kbnUrlStateStorage = createKbnUrlStateStorage();
kbnUrlStateStorage.set('_a', { description: 'with this' });
const { renderHookResult, embeddableFactoryResult } = renderDashboardAppStateHook({
partialProps: { kbnUrlStateStorage },
partialServices: { dashboardSessionStorage },
});
const getResult = () => renderHookResult.result.current;
embeddableFactoryResult.finalizeEmbeddableCreation();
await renderHookResult.waitForNextUpdate();
expect(getResult().getLatestDashboardState?.().description).toEqual('with this');
expect(getResult().getLatestDashboardState?.().viewMode).toEqual(ViewMode.EDIT);
});
});
describe('Dashboard state sync', () => {
let defaultDashboardAppStateHookResult: RenderDashboardStateHookReturn;
const getResult = () => defaultDashboardAppStateHookResult.renderHookResult.result.current;
beforeEach(async () => {
DashboardConstants.CHANGE_APPLY_DEBOUNCE = 0;
DashboardConstants.CHANGE_CHECK_DEBOUNCE = 0;
defaultDashboardAppStateHookResult = renderDashboardAppStateHook({});
defaultDashboardAppStateHookResult.embeddableFactoryResult.finalizeEmbeddableCreation();
await defaultDashboardAppStateHookResult.renderHookResult.waitForNextUpdate();
});
it('Updates Dashboard container input when state changes', async () => {
const { embeddableFactoryResult } = defaultDashboardAppStateHookResult;
embeddableFactoryResult.dashboardContainer.updateInput = jest.fn();
act(() => {
dashboardStateStore.dispatch(setDescription('Well hello there new description'));
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 3)); // So that $triggerDashboardRefresh.next is called
});
expect(embeddableFactoryResult.dashboardContainer.updateInput).toHaveBeenCalledWith(
expect.objectContaining({ description: 'Well hello there new description' })
);
});
it('Updates state when dashboard container input changes', async () => {
const { embeddableFactoryResult } = defaultDashboardAppStateHookResult;
expect(getResult().getLatestDashboardState?.().fullScreenMode).toBe(false);
act(() => {
embeddableFactoryResult.dashboardContainer.updateInput({
isFullScreenMode: true,
});
});
await act(() => Promise.resolve());
expect(getResult().getLatestDashboardState?.().fullScreenMode).toBe(true);
});
it('pushes unsaved changes to the session storage', async () => {
const { services } = defaultDashboardAppStateHookResult;
expect(getResult().getLatestDashboardState?.().fullScreenMode).toBe(false);
act(() => {
dashboardStateStore.dispatch(setViewMode(ViewMode.EDIT)); // session storage is only populated in edit mode
dashboardStateStore.dispatch(setDescription('Wow an even cooler description.'));
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 3));
});
expect(services.dashboardSessionStorage.setState).toHaveBeenCalledWith(
'testDashboardId',
expect.objectContaining({
description: 'Wow an even cooler description.',
viewMode: ViewMode.EDIT,
})
);
});
});

View file

@ -0,0 +1,351 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import _ from 'lodash';
import { History } from 'history';
import { debounceTime } from 'rxjs/operators';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { BehaviorSubject, combineLatest, Subject } from 'rxjs';
import { DashboardConstants } from '../..';
import { ViewMode } from '../../services/embeddable';
import { useKibana } from '../../services/kibana_react';
import { getNewDashboardTitle } from '../../dashboard_strings';
import { IKbnUrlStateStorage } from '../../services/kibana_utils';
import { setDashboardState, useDashboardDispatch, useDashboardSelector } from '../state';
import {
DashboardBuildContext,
DashboardAppServices,
DashboardAppState,
DashboardRedirect,
DashboardState,
} from '../../types';
import {
tryDestroyDashboardContainer,
syncDashboardContainerInput,
savedObjectToDashboardState,
syncDashboardIndexPatterns,
syncDashboardFilterState,
loadSavedDashboardState,
buildDashboardContainer,
loadDashboardUrlState,
diffDashboardState,
areTimeRangesEqual,
} from '../lib';
export interface UseDashboardStateProps {
history: History;
savedDashboardId?: string;
redirectTo: DashboardRedirect;
isEmbeddedExternally: boolean;
kbnUrlStateStorage: IKbnUrlStateStorage;
}
export const useDashboardAppState = ({
history,
redirectTo,
savedDashboardId,
kbnUrlStateStorage,
isEmbeddedExternally,
}: UseDashboardStateProps) => {
const dispatchDashboardStateChange = useDashboardDispatch();
const dashboardState = useDashboardSelector((state) => state.dashboardStateReducer);
/**
* Dashboard app state is the return value for this hook and contains interaction points that the rest of the app can use
* to read or manipulate dashboard state.
*/
const [dashboardAppState, setDashboardAppState] = useState<DashboardAppState>(() => ({
$onDashboardStateChange: new BehaviorSubject({} as DashboardState),
$triggerDashboardRefresh: new Subject<{ force?: boolean }>(),
}));
/**
* Last saved state is diffed against the current dashboard state any time either changes. This is used to set the
* unsaved changes portion of the dashboardAppState.
*/
const [lastSavedState, setLastSavedState] = useState<DashboardState>();
const $onLastSavedStateChange = useMemo(() => new Subject<DashboardState>(), []);
/**
* Unpack services
*/
const services = useKibana<DashboardAppServices>().services;
const {
data,
core,
chrome,
embeddable,
indexPatterns,
usageCollection,
savedDashboards,
initializerContext,
savedObjectsTagging,
dashboardCapabilities,
dashboardSessionStorage,
} = services;
const { docTitle } = chrome;
const { notifications } = core;
const { query, search } = data;
const { getStateTransfer } = embeddable;
const { version: kibanaVersion } = initializerContext.env.packageInfo;
/**
* This useEffect triggers when the dashboard ID changes, and is in charge of loading the saved dashboard,
* fetching the initial state, building the Dashboard Container embeddable, and setting up all state syncing.
*/
useEffect(() => {
// fetch incoming embeddable from state transfer service.
const incomingEmbeddable = getStateTransfer().getIncomingEmbeddablePackage(
DashboardConstants.DASHBOARDS_ID,
true
);
let canceled = false;
let onDestroy: () => void;
/**
* The dashboard build context is a collection of all of the services and props required in subsequent steps to build the dashboard
* from the dashboardId. This build context doesn't contain any extrenuous services.
*/
const dashboardBuildContext: DashboardBuildContext = {
query,
search,
history,
embeddable,
indexPatterns,
notifications,
kibanaVersion,
savedDashboards,
kbnUrlStateStorage,
initializerContext,
isEmbeddedExternally,
dashboardCapabilities,
dispatchDashboardStateChange,
$checkForUnsavedChanges: new Subject(),
$onDashboardStateChange: dashboardAppState.$onDashboardStateChange,
$triggerDashboardRefresh: dashboardAppState.$triggerDashboardRefresh,
getLatestDashboardState: () => dashboardAppState.$onDashboardStateChange.value,
};
(async () => {
/**
* Load and unpack state from dashboard saved object.
*/
const loadSavedDashboardResult = await loadSavedDashboardState({
...dashboardBuildContext,
savedDashboardId,
});
if (canceled || !loadSavedDashboardResult) return;
const { savedDashboard, savedDashboardState } = loadSavedDashboardResult;
/**
* Combine initial state from the saved object, session storage, and URL, then dispatch it to Redux.
*/
const dashboardSessionStorageState = dashboardSessionStorage.getState(savedDashboardId) || {};
const dashboardURLState = loadDashboardUrlState(dashboardBuildContext);
const initialDashboardState = {
...savedDashboardState,
...dashboardSessionStorageState,
...dashboardURLState,
// if there is an incoming embeddable, dashboard always needs to be in edit mode to receive it.
...(incomingEmbeddable ? { viewMode: ViewMode.EDIT } : {}),
};
dispatchDashboardStateChange(setDashboardState(initialDashboardState));
/**
* Start syncing dashboard state with the Query, Filters and Timepicker from the Query Service.
*/
const { applyFilters, stopSyncingDashboardFilterState } = syncDashboardFilterState({
...dashboardBuildContext,
initialDashboardState,
savedDashboard,
});
/**
* Build the dashboard container embeddable, and apply the incoming embeddable if it exists.
*/
const dashboardContainer = await buildDashboardContainer({
...dashboardBuildContext,
initialDashboardState,
incomingEmbeddable,
savedDashboard,
data,
});
if (canceled || !dashboardContainer) {
tryDestroyDashboardContainer(dashboardContainer);
return;
}
/**
* Start syncing index patterns between the Query Service and the Dashboard Container.
*/
const indexPatternsSubscription = syncDashboardIndexPatterns({
dashboardContainer,
indexPatterns: dashboardBuildContext.indexPatterns,
onUpdateIndexPatterns: (newIndexPatterns) =>
setDashboardAppState((s) => ({ ...s, indexPatterns: newIndexPatterns })),
});
/**
* Set up the two way syncing between the Dashboard Container and the Redux Store.
*/
const stopSyncingContainerInput = syncDashboardContainerInput({
...dashboardBuildContext,
dashboardContainer,
savedDashboard,
applyFilters,
});
/**
* Any time the redux state, or the last saved state changes, compare them, set the unsaved
* changes state, and and push the unsaved changes to session storage.
*/
const { timefilter } = dashboardBuildContext.query.timefilter;
const lastSavedSubscription = combineLatest([
$onLastSavedStateChange,
dashboardAppState.$onDashboardStateChange,
dashboardBuildContext.$checkForUnsavedChanges,
])
.pipe(debounceTime(DashboardConstants.CHANGE_CHECK_DEBOUNCE))
.subscribe((states) => {
const [lastSaved, current] = states;
const unsavedChanges =
current.viewMode === ViewMode.EDIT ? diffDashboardState(lastSaved, current) : {};
if (current.viewMode === ViewMode.EDIT) {
const savedTimeChanged =
lastSaved.timeRestore &&
!areTimeRangesEqual(
{
from: savedDashboard?.timeFrom,
to: savedDashboard?.timeTo,
},
timefilter.getTime()
);
const hasUnsavedChanges = Object.keys(unsavedChanges).length > 0 || savedTimeChanged;
setDashboardAppState((s) => ({ ...s, hasUnsavedChanges }));
}
unsavedChanges.viewMode = current.viewMode; // always push view mode into session store.
dashboardSessionStorage.setState(savedDashboardId, unsavedChanges);
});
/**
* initialize the last saved state, and build a callback which can be used to update
* the last saved state on save.
*/
setLastSavedState(savedDashboardState);
dashboardBuildContext.$checkForUnsavedChanges.next();
const updateLastSavedState = () => {
setLastSavedState(
savedObjectToDashboardState({
hideWriteControls: dashboardBuildContext.dashboardCapabilities.hideWriteControls,
version: dashboardBuildContext.kibanaVersion,
savedObjectsTagging,
usageCollection,
savedDashboard,
})
);
};
/**
* Apply changes to the dashboard app state, and set the document title
*/
docTitle.change(savedDashboardState.title || getNewDashboardTitle());
setDashboardAppState((s) => ({
...s,
applyFilters,
savedDashboard,
dashboardContainer,
updateLastSavedState,
getLatestDashboardState: dashboardBuildContext.getLatestDashboardState,
}));
onDestroy = () => {
stopSyncingContainerInput();
stopSyncingDashboardFilterState();
lastSavedSubscription.unsubscribe();
indexPatternsSubscription.unsubscribe();
tryDestroyDashboardContainer(dashboardContainer);
setDashboardAppState((state) => ({
...state,
dashboardContainer: undefined,
}));
};
})();
return () => {
canceled = true;
onDestroy?.();
};
}, [
dashboardAppState.$triggerDashboardRefresh,
dashboardAppState.$onDashboardStateChange,
dispatchDashboardStateChange,
$onLastSavedStateChange,
dashboardSessionStorage,
dashboardCapabilities,
isEmbeddedExternally,
kbnUrlStateStorage,
savedObjectsTagging,
initializerContext,
savedDashboardId,
getStateTransfer,
savedDashboards,
usageCollection,
notifications,
indexPatterns,
kibanaVersion,
embeddable,
docTitle,
history,
search,
query,
data,
]);
/**
* rebuild reset to last saved state callback whenever last saved state changes
*/
const resetToLastSavedState = useCallback(() => {
if (
!lastSavedState ||
!dashboardAppState.savedDashboard ||
!dashboardAppState.getLatestDashboardState
) {
return;
}
if (dashboardAppState.getLatestDashboardState().timeRestore) {
const { timefilter } = data.query.timefilter;
const { timeFrom: from, timeTo: to, refreshInterval } = dashboardAppState.savedDashboard;
if (from && to) timefilter.setTime({ from, to });
if (refreshInterval) timefilter.setRefreshInterval(refreshInterval);
}
dispatchDashboardStateChange(setDashboardState(lastSavedState));
}, [lastSavedState, dashboardAppState, data.query.timefilter, dispatchDashboardStateChange]);
/**
* publish state to the state change observable when redux state changes
*/
useEffect(() => {
if (!dashboardState || Object.keys(dashboardState).length === 0) return;
dashboardAppState.$onDashboardStateChange.next(dashboardState);
}, [dashboardAppState.$onDashboardStateChange, dashboardState]);
/**
* push last saved state to the state change observable when last saved state changes
*/
useEffect(() => {
if (!lastSavedState) return;
$onLastSavedStateChange.next(lastSavedState);
}, [$onLastSavedStateChange, lastSavedState]);
return { ...dashboardAppState, resetToLastSavedState };
};

View file

@ -1,53 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { useEffect } from 'react';
import _ from 'lodash';
import { useKibana } from '../../services/kibana_react';
import { DashboardStateManager } from '../dashboard_state_manager';
import { getDashboardBreadcrumb, getDashboardTitle } from '../../dashboard_strings';
import { DashboardAppServices, DashboardRedirect } from '../types';
export const useDashboardBreadcrumbs = (
dashboardStateManager: DashboardStateManager | null,
redirectTo: DashboardRedirect
) => {
const { data, core, chrome } = useKibana<DashboardAppServices>().services;
// Destructure and rename services; makes the Effect hook more specific, makes later
// abstraction of service dependencies easier.
const { setBreadcrumbs } = chrome;
const { timefilter } = data.query.timefilter;
const { openConfirm } = core.overlays;
// Sync breadcrumbs when Dashboard State Manager changes
useEffect(() => {
if (!dashboardStateManager) {
return;
}
setBreadcrumbs([
{
text: getDashboardBreadcrumb(),
'data-test-subj': 'dashboardListingBreadcrumb',
onClick: () => {
redirectTo({ destination: 'listing' });
},
},
{
text: getDashboardTitle(
dashboardStateManager.getTitle(),
dashboardStateManager.getViewMode(),
dashboardStateManager.isNew()
),
},
]);
}, [dashboardStateManager, timefilter, openConfirm, redirectTo, setBreadcrumbs]);
};

View file

@ -1,199 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { useDashboardContainer } from './use_dashboard_container';
import { renderHook, act } from '@testing-library/react-hooks';
import { KibanaContextProvider } from '../../../../kibana_react/public';
import React from 'react';
import { DashboardStateManager } from '../dashboard_state_manager';
import { getSavedDashboardMock } from '../test_helpers';
import { createKbnUrlStateStorage, defer } from '../../../../kibana_utils/public';
import { createBrowserHistory } from 'history';
import { dataPluginMock } from '../../../../data/public/mocks';
import { embeddablePluginMock } from '../../../../embeddable/public/mocks';
import { DashboardAppCapabilities } from '../types';
import { EmbeddableFactory } from '../../../../embeddable/public';
import { HelloWorldEmbeddable } from '../../../../embeddable/public/tests/fixtures';
import { DashboardContainer } from '../embeddable';
import { coreMock } from 'src/core/public/mocks';
const savedDashboard = getSavedDashboardMock();
// TS is *very* picky with type guards / predicates. can't just use jest.fn()
function mockHasTaggingCapabilities(obj: any): obj is any {
return false;
}
const history = createBrowserHistory();
const createDashboardState = () =>
new DashboardStateManager({
savedDashboard,
kibanaVersion: '7.0.0',
hideWriteControls: false,
allowByValueEmbeddables: false,
history: createBrowserHistory(),
hasPendingEmbeddable: () => false,
kbnUrlStateStorage: createKbnUrlStateStorage(),
hasTaggingCapabilities: mockHasTaggingCapabilities,
toasts: coreMock.createStart().notifications.toasts,
});
const defaultCapabilities: DashboardAppCapabilities = {
show: false,
createNew: false,
saveQuery: false,
createShortUrl: false,
hideWriteControls: true,
mapsCapabilities: { save: false },
visualizeCapabilities: { save: false },
storeSearchSession: true,
};
const getIncomingEmbeddable = () => undefined;
const services = {
dashboardCapabilities: defaultCapabilities,
data: dataPluginMock.createStartContract(),
embeddable: embeddablePluginMock.createStartContract(),
scopedHistory: history,
};
const setupEmbeddableFactory = () => {
const embeddable = new HelloWorldEmbeddable({ id: 'id' });
const deferEmbeddableCreate = defer();
services.embeddable.getEmbeddableFactory.mockImplementation(
() =>
(({
create: () => deferEmbeddableCreate.promise,
} as unknown) as EmbeddableFactory)
);
const destroySpy = jest.spyOn(embeddable, 'destroy');
return {
destroySpy,
embeddable,
createEmbeddable: () => {
act(() => {
deferEmbeddableCreate.resolve(embeddable);
});
},
};
};
test('container is destroyed on unmount', async () => {
const { createEmbeddable, destroySpy, embeddable } = setupEmbeddableFactory();
const dashboardStateManager = createDashboardState();
const { result, unmount, waitForNextUpdate } = renderHook(
() =>
useDashboardContainer({
getIncomingEmbeddable,
dashboardStateManager,
history,
}),
{
wrapper: ({ children }) => (
<KibanaContextProvider services={services}>{children}</KibanaContextProvider>
),
}
);
expect(result.current).toBeNull(); // null on initial render
createEmbeddable();
await waitForNextUpdate();
expect(embeddable).toBe(result.current);
expect(destroySpy).not.toBeCalled();
unmount();
expect(destroySpy).toBeCalled();
});
test('old container is destroyed on new dashboardStateManager', async () => {
const embeddableFactoryOld = setupEmbeddableFactory();
const { result, waitForNextUpdate, rerender } = renderHook<
DashboardStateManager,
DashboardContainer | null
>(
(dashboardStateManager) =>
useDashboardContainer({
getIncomingEmbeddable,
dashboardStateManager,
history,
}),
{
wrapper: ({ children }) => (
<KibanaContextProvider services={services}>{children}</KibanaContextProvider>
),
initialProps: createDashboardState(),
}
);
expect(result.current).toBeNull(); // null on initial render
embeddableFactoryOld.createEmbeddable();
await waitForNextUpdate();
expect(embeddableFactoryOld.embeddable).toBe(result.current);
expect(embeddableFactoryOld.destroySpy).not.toBeCalled();
const embeddableFactoryNew = setupEmbeddableFactory();
rerender(createDashboardState());
embeddableFactoryNew.createEmbeddable();
await waitForNextUpdate();
expect(embeddableFactoryNew.embeddable).toBe(result.current);
expect(embeddableFactoryNew.destroySpy).not.toBeCalled();
expect(embeddableFactoryOld.destroySpy).toBeCalled();
});
test('destroyed if rerendered before resolved', async () => {
const embeddableFactoryOld = setupEmbeddableFactory();
const { result, waitForNextUpdate, rerender } = renderHook<
DashboardStateManager,
DashboardContainer | null
>(
(dashboardStateManager) =>
useDashboardContainer({
getIncomingEmbeddable,
dashboardStateManager,
history,
}),
{
wrapper: ({ children }) => (
<KibanaContextProvider services={services}>{children}</KibanaContextProvider>
),
initialProps: createDashboardState(),
}
);
expect(result.current).toBeNull(); // null on initial render
const embeddableFactoryNew = setupEmbeddableFactory();
rerender(createDashboardState());
embeddableFactoryNew.createEmbeddable();
await waitForNextUpdate();
expect(embeddableFactoryNew.embeddable).toBe(result.current);
expect(embeddableFactoryNew.destroySpy).not.toBeCalled();
embeddableFactoryOld.createEmbeddable();
await act(() => Promise.resolve()); // Can't use waitFor from hooks, because there is no hook update
expect(embeddableFactoryNew.embeddable).toBe(result.current);
expect(embeddableFactoryNew.destroySpy).not.toBeCalled();
expect(embeddableFactoryOld.destroySpy).toBeCalled();
});

View file

@ -1,169 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { useEffect, useState } from 'react';
import { History } from 'history';
import { useKibana } from '../../services/kibana_react';
import {
ContainerOutput,
EmbeddableFactoryNotFoundError,
EmbeddableInput,
EmbeddablePackageState,
ErrorEmbeddable,
isErrorEmbeddable,
ViewMode,
} from '../../services/embeddable';
import { DashboardStateManager } from '../dashboard_state_manager';
import { getDashboardContainerInput, getSearchSessionIdFromURL } from '../dashboard_app_functions';
import { DashboardContainer, DashboardContainerInput } from '../..';
import { DashboardAppServices } from '../types';
import { DASHBOARD_CONTAINER_TYPE } from '..';
import { TimefilterContract } from '../../services/data';
export const useDashboardContainer = ({
history,
timeFilter,
setUnsavedChanges,
getIncomingEmbeddable,
dashboardStateManager,
isEmbeddedExternally,
}: {
history: History;
isEmbeddedExternally?: boolean;
timeFilter?: TimefilterContract;
setUnsavedChanges?: (dirty: boolean) => void;
dashboardStateManager: DashboardStateManager | null;
getIncomingEmbeddable: (removeAfterFetch?: boolean) => EmbeddablePackageState | undefined;
}) => {
const {
dashboardCapabilities,
data,
embeddable,
scopedHistory,
} = useKibana<DashboardAppServices>().services;
// Destructure and rename services; makes the Effect hook more specific, makes later
// abstraction of service dependencies easier.
const { query } = data;
const { session: searchSession } = data.search;
const [dashboardContainer, setDashboardContainer] = useState<DashboardContainer | null>(null);
useEffect(() => {
if (!dashboardStateManager) {
return;
}
// Load dashboard container
const dashboardFactory = embeddable.getEmbeddableFactory<
DashboardContainerInput,
ContainerOutput,
DashboardContainer
>(DASHBOARD_CONTAINER_TYPE);
if (!dashboardFactory) {
throw new EmbeddableFactoryNotFoundError(
'dashboard app requires dashboard embeddable factory'
);
}
const searchSessionIdFromURL = getSearchSessionIdFromURL(history);
if (searchSessionIdFromURL) {
searchSession.restore(searchSessionIdFromURL);
}
const incomingEmbeddable = getIncomingEmbeddable(true);
let canceled = false;
let pendingContainer: DashboardContainer | ErrorEmbeddable | null | undefined;
(async function createContainer() {
const existingSession = searchSession.getSessionId();
pendingContainer = await dashboardFactory.create(
getDashboardContainerInput({
isEmbeddedExternally: Boolean(isEmbeddedExternally),
dashboardCapabilities,
dashboardStateManager,
incomingEmbeddable,
query,
searchSessionId:
searchSessionIdFromURL ??
(existingSession && incomingEmbeddable ? existingSession : searchSession.start()),
})
);
// already new container is being created
// no longer interested in the pending one
if (canceled) {
try {
pendingContainer?.destroy();
pendingContainer = null;
} catch (e) {
// destroy could throw if something has already destroyed the container
// eslint-disable-next-line no-console
console.warn(e);
}
return;
}
if (!pendingContainer || isErrorEmbeddable(pendingContainer)) {
return;
}
// inject switch view mode callback for the empty screen to use
pendingContainer.switchViewMode = (newViewMode: ViewMode) =>
dashboardStateManager.switchViewMode(newViewMode);
// If the incoming embeddable is newly created, or doesn't exist in the current panels list,
// add it with `addNewEmbeddable`
if (
incomingEmbeddable &&
(!incomingEmbeddable?.embeddableId ||
(incomingEmbeddable.embeddableId &&
!pendingContainer.getInput().panels[incomingEmbeddable.embeddableId]))
) {
pendingContainer.addNewEmbeddable<EmbeddableInput>(
incomingEmbeddable.type,
incomingEmbeddable.input
);
}
setDashboardContainer(pendingContainer);
setUnsavedChanges?.(dashboardStateManager.getIsDirty(data.query.timefilter.timefilter));
})();
return () => {
canceled = true;
try {
pendingContainer?.destroy();
} catch (e) {
// destroy could throw if something has already destroyed the container
// eslint-disable-next-line no-console
console.warn(e);
}
setDashboardContainer(null);
};
}, [
data.query.timefilter.timefilter,
dashboardCapabilities,
dashboardStateManager,
getIncomingEmbeddable,
isEmbeddedExternally,
setUnsavedChanges,
searchSession,
scopedHistory,
timeFilter,
embeddable,
history,
query,
]);
return dashboardContainer;
};

View file

@ -1,220 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { useEffect, useState } from 'react';
import { History } from 'history';
import _ from 'lodash';
import { map } from 'rxjs/operators';
import { createKbnUrlStateStorage, withNotifyOnErrors } from '../../services/kibana_utils';
import { useKibana } from '../../services/kibana_react';
import {
connectToQueryState,
esFilters,
noSearchSessionStorageCapabilityMessage,
QueryState,
syncQueryStateWithUrl,
} from '../../services/data';
import { SavedObject } from '../../services/saved_objects';
import type { TagDecoratedSavedObject } from '../../services/saved_objects_tagging_oss';
import { DashboardSavedObject } from '../../saved_dashboards';
import { migrateLegacyQuery } from '../lib/migrate_legacy_query';
import { createSessionRestorationDataProvider } from '../lib/session_restoration';
import { DashboardStateManager } from '../dashboard_state_manager';
import { getDashboardTitle } from '../../dashboard_strings';
import { DashboardAppServices } from '../types';
import { EmbeddablePackageState, ViewMode } from '../../services/embeddable';
// TS is picky with type guards, we can't just inline `() => false`
function defaultTaggingGuard(_obj: SavedObject): _obj is TagDecoratedSavedObject {
return false;
}
interface DashboardStateManagerReturn {
dashboardStateManager: DashboardStateManager | null;
viewMode: ViewMode | null;
setViewMode: (value: ViewMode) => void;
}
export const useDashboardStateManager = (
savedDashboard: DashboardSavedObject | null,
history: History,
getIncomingEmbeddable: () => EmbeddablePackageState | undefined
): DashboardStateManagerReturn => {
const {
data: dataPlugin,
core,
uiSettings,
usageCollection,
initializerContext,
savedObjectsTagging,
dashboardCapabilities,
dashboardPanelStorage,
allowByValueEmbeddables,
} = useKibana<DashboardAppServices>().services;
// Destructure and rename services; makes the Effect hook more specific, makes later
// abstraction of service dependencies easier.
const { query: queryService } = dataPlugin;
const { session: searchSession } = dataPlugin.search;
const { filterManager, queryString: queryStringManager } = queryService;
const { timefilter } = queryService.timefilter;
const { toasts } = core.notifications;
const { hideWriteControls } = dashboardCapabilities;
const { version: kibanaVersion } = initializerContext.env.packageInfo;
const [dashboardStateManager, setDashboardStateManager] = useState<DashboardStateManager | null>(
null
);
const [viewMode, setViewMode] = useState<ViewMode | null>(null);
const hasTaggingCapabilities = savedObjectsTagging?.ui.hasTagDecoration || defaultTaggingGuard;
useEffect(() => {
if (!savedDashboard) {
return;
}
const kbnUrlStateStorage = createKbnUrlStateStorage({
history,
useHash: uiSettings.get('state:storeInSessionStorage'),
...withNotifyOnErrors(toasts),
});
const stateManager = new DashboardStateManager({
hasPendingEmbeddable: () => Boolean(getIncomingEmbeddable()),
toasts: core.notifications.toasts,
hasTaggingCapabilities,
dashboardPanelStorage,
hideWriteControls,
history,
kbnUrlStateStorage,
kibanaVersion,
savedDashboard,
usageCollection,
allowByValueEmbeddables,
});
// sync initial app filters from state to filterManager
// if there is an existing similar global filter, then leave it as global
filterManager.setAppFilters(_.cloneDeep(stateManager.appState.filters));
queryStringManager.setQuery(migrateLegacyQuery(stateManager.appState.query));
// setup syncing of app filters between appState and filterManager
const stopSyncingAppFilters = connectToQueryState(
queryService,
{
set: ({ filters, query }) => {
stateManager.setFilters(filters || []);
stateManager.setQuery(query || queryStringManager.getDefaultQuery());
},
get: () => ({
filters: stateManager.appState.filters,
query: stateManager.getQuery(),
}),
state$: stateManager.appState$.pipe(
map((appState) => ({
filters: appState.filters,
query: queryStringManager.formatQuery(appState.query),
}))
),
},
{
filters: esFilters.FilterStateStore.APP_STATE,
query: true,
}
);
// Apply initial filters to Dashboard State Manager
stateManager.applyFilters(
stateManager.getQuery() || queryStringManager.getDefaultQuery(),
filterManager.getFilters()
);
// The hash check is so we only update the time filter on dashboard open, not during
// normal cross app navigation.
if (stateManager.getIsTimeSavedWithDashboard()) {
const initialGlobalStateInUrl = kbnUrlStateStorage.get<QueryState>('_g');
if (!initialGlobalStateInUrl?.time) {
stateManager.syncTimefilterWithDashboardTime(timefilter);
}
if (!initialGlobalStateInUrl?.refreshInterval) {
stateManager.syncTimefilterWithDashboardRefreshInterval(timefilter);
}
}
// starts syncing `_g` portion of url with query services
// it is important to start this syncing after `dashboardStateManager.syncTimefilterWithDashboard(timefilter);` above is run,
// otherwise it will case redundant browser history records
const { stop: stopSyncingQueryServiceStateWithUrl } = syncQueryStateWithUrl(
queryService,
kbnUrlStateStorage
);
// starts syncing `_a` portion of url
stateManager.startStateSyncing();
const dashboardTitle = getDashboardTitle(
stateManager.getTitle(),
stateManager.getViewMode(),
stateManager.isNew()
);
searchSession.enableStorage(
createSessionRestorationDataProvider({
data: dataPlugin,
getDashboardTitle: () => dashboardTitle,
getDashboardId: () => savedDashboard?.id || '',
getAppState: () => stateManager.getAppState(),
}),
{
isDisabled: () =>
dashboardCapabilities.storeSearchSession
? { disabled: false }
: {
disabled: true,
reasonText: noSearchSessionStorageCapabilityMessage,
},
}
);
setDashboardStateManager(stateManager);
setViewMode(stateManager.getViewMode());
return () => {
stateManager?.destroy();
setDashboardStateManager(null);
stopSyncingAppFilters();
stopSyncingQueryServiceStateWithUrl();
};
}, [
dataPlugin,
filterManager,
hasTaggingCapabilities,
initializerContext.config,
dashboardPanelStorage,
getIncomingEmbeddable,
hideWriteControls,
history,
kibanaVersion,
queryService,
queryStringManager,
savedDashboard,
searchSession,
timefilter,
toasts,
uiSettings,
usageCollection,
allowByValueEmbeddables,
core.notifications.toasts,
dashboardCapabilities.storeSearchSession,
]);
return { dashboardStateManager, viewMode, setViewMode };
};

View file

@ -1,66 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { useEffect, useState } from 'react';
import { History } from 'history';
import _ from 'lodash';
import { useKibana } from '../../services/kibana_react';
import { DashboardConstants } from '../..';
import { DashboardSavedObject } from '../../saved_dashboards';
import { getDashboard60Warning, getNewDashboardTitle } from '../../dashboard_strings';
import { DashboardAppServices } from '../types';
export const useSavedDashboard = (savedDashboardId: string | undefined, history: History) => {
const { data, core, chrome, savedDashboards } = useKibana<DashboardAppServices>().services;
const [savedDashboard, setSavedDashboard] = useState<DashboardSavedObject | null>(null);
// Destructure and rename services; makes the Effect hook more specific, makes later
// abstraction of service dependencies easier.
const { indexPatterns } = data;
const { recentlyAccessed: recentlyAccessedPaths, docTitle } = chrome;
const { toasts } = core.notifications;
useEffect(() => {
(async function loadSavedDashboard() {
if (savedDashboardId === 'create') {
history.replace({
...history.location, // preserve query,
pathname: DashboardConstants.CREATE_NEW_DASHBOARD_URL,
});
toasts.addWarning(getDashboard60Warning());
return;
}
await indexPatterns.ensureDefaultIndexPattern();
try {
const dashboard = (await savedDashboards.get(savedDashboardId)) as DashboardSavedObject;
docTitle.change(dashboard.title || getNewDashboardTitle());
setSavedDashboard(dashboard);
} catch (error) {
// E.g. a corrupt or deleted dashboard
toasts.addDanger(error.message);
history.push(DashboardConstants.LANDING_PAGE_PATH);
}
})();
return () => setSavedDashboard(null);
}, [
toasts,
docTitle,
history,
indexPatterns,
recentlyAccessedPaths,
savedDashboardId,
savedDashboards,
]);
return savedDashboard;
};

View file

@ -0,0 +1,160 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import _ from 'lodash';
import { DashboardSavedObject } from '../../saved_dashboards';
import { DashboardContainer, DASHBOARD_CONTAINER_TYPE } from '../embeddable';
import {
DashboardBuildContext,
DashboardState,
DashboardContainerInput,
DashboardAppServices,
} from '../../types';
import {
enableDashboardSearchSessions,
getSearchSessionIdFromURL,
stateToDashboardContainerInput,
} from '.';
import {
ContainerOutput,
EmbeddableFactoryNotFoundError,
EmbeddableInput,
EmbeddablePackageState,
ErrorEmbeddable,
isErrorEmbeddable,
} from '../../services/embeddable';
type BuildDashboardContainerProps = DashboardBuildContext & {
data: DashboardAppServices['data']; // the whole data service is required here because it is required by getUrlGeneratorState
savedDashboard: DashboardSavedObject;
initialDashboardState: DashboardState;
incomingEmbeddable?: EmbeddablePackageState;
};
/**
* Builds the dashboard container and manages initial search session
*/
export const buildDashboardContainer = async ({
getLatestDashboardState,
initialDashboardState,
isEmbeddedExternally,
dashboardCapabilities,
incomingEmbeddable,
savedDashboard,
kibanaVersion,
embeddable,
history,
data,
}: BuildDashboardContainerProps) => {
const {
search: { session },
} = data;
// set up search session
enableDashboardSearchSessions({
data,
kibanaVersion,
savedDashboard,
initialDashboardState,
getLatestDashboardState,
canStoreSearchSession: dashboardCapabilities.storeSearchSession,
});
const searchSessionIdFromURL = getSearchSessionIdFromURL(history);
if (searchSessionIdFromURL) {
session.restore(searchSessionIdFromURL);
}
const dashboardFactory = embeddable.getEmbeddableFactory<
DashboardContainerInput,
ContainerOutput,
DashboardContainer
>(DASHBOARD_CONTAINER_TYPE);
if (!dashboardFactory) {
throw new EmbeddableFactoryNotFoundError('dashboard app requires dashboard embeddable factory');
}
/**
* Use an existing session instead of starting a new one if there is a session already, and dashboard is being created with an incoming
* embeddable.
*/
const existingSession = session.getSessionId();
const searchSessionId =
searchSessionIdFromURL ??
(existingSession && incomingEmbeddable ? existingSession : session.start());
// Build the initial input for the dashboard container based on the dashboard state.
const initialInput = stateToDashboardContainerInput({
isEmbeddedExternally: Boolean(isEmbeddedExternally),
dashboardState: initialDashboardState,
dashboardCapabilities,
incomingEmbeddable,
query: data.query,
searchSessionId,
savedDashboard,
});
/**
* Handle the Incoming Embeddable Part 1:
* If the incoming embeddable already exists e.g. if it has been edited by value, the incoming state for that panel needs to replace the
* state for the matching panel already in the dashboard. This needs to happen BEFORE the dashboard container is built, so that the panel
* retains the same placement.
*/
if (incomingEmbeddable?.embeddableId && initialInput.panels[incomingEmbeddable.embeddableId]) {
const originalPanelState = initialInput.panels[incomingEmbeddable.embeddableId];
initialInput.panels = {
...initialInput.panels,
[incomingEmbeddable.embeddableId]: {
gridData: originalPanelState.gridData,
type: incomingEmbeddable.type,
explicitInput: {
...originalPanelState.explicitInput,
...incomingEmbeddable.input,
id: incomingEmbeddable.embeddableId,
},
},
};
}
const dashboardContainer = await dashboardFactory.create(initialInput);
if (!dashboardContainer || isErrorEmbeddable(dashboardContainer)) {
tryDestroyDashboardContainer(dashboardContainer);
return;
}
/**
* Handle the Incoming Embeddable Part 2:
* If the incoming embeddable is new, we can add it to the container using `addNewEmbeddable` after the container is created
* this lets the container handle the placement of it (using the default placement algorithm "top left most open space")
*/
if (
incomingEmbeddable &&
(!incomingEmbeddable?.embeddableId ||
(incomingEmbeddable.embeddableId &&
!dashboardContainer.getInput().panels[incomingEmbeddable.embeddableId]))
) {
dashboardContainer.addNewEmbeddable<EmbeddableInput>(
incomingEmbeddable.type,
incomingEmbeddable.input
);
}
return dashboardContainer;
};
export const tryDestroyDashboardContainer = (
container: DashboardContainer | ErrorEmbeddable | undefined
) => {
try {
container?.destroy();
} catch (e) {
// destroy could throw if something has already destroyed the container
// eslint-disable-next-line no-console
console.warn(e);
}
};

View file

@ -0,0 +1,143 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import _ from 'lodash';
import { DashboardSavedObject } from '../../saved_dashboards';
import { getTagsFromSavedDashboard, migrateAppState } from '.';
import { EmbeddablePackageState, ViewMode } from '../../services/embeddable';
import {
convertPanelStateToSavedDashboardPanel,
convertSavedDashboardPanelToPanelState,
} from '../../../common/embeddable/embeddable_saved_object_converters';
import {
DashboardState,
RawDashboardState,
DashboardPanelMap,
SavedDashboardPanel,
DashboardAppServices,
DashboardContainerInput,
DashboardBuildContext,
} from '../../types';
interface SavedObjectToDashboardStateProps {
version: string;
hideWriteControls: boolean;
savedDashboard: DashboardSavedObject;
usageCollection: DashboardAppServices['usageCollection'];
savedObjectsTagging: DashboardAppServices['savedObjectsTagging'];
}
interface StateToDashboardContainerInputProps {
searchSessionId?: string;
isEmbeddedExternally?: boolean;
dashboardState: DashboardState;
savedDashboard: DashboardSavedObject;
query: DashboardBuildContext['query'];
incomingEmbeddable?: EmbeddablePackageState;
dashboardCapabilities: DashboardBuildContext['dashboardCapabilities'];
}
interface StateToRawDashboardStateProps {
version: string;
state: DashboardState;
}
/**
* Converts a dashboard saved object to a dashboard state by extracting raw state from the given Dashboard
* Saved Object migrating the panel states to the latest version, then converting each panel from a saved
* dashboard panel to a panel state.
*/
export const savedObjectToDashboardState = ({
version,
hideWriteControls,
savedDashboard,
usageCollection,
savedObjectsTagging,
}: SavedObjectToDashboardStateProps): DashboardState => {
const rawState = migrateAppState(
{
fullScreenMode: false,
title: savedDashboard.title,
query: savedDashboard.getQuery(),
filters: savedDashboard.getFilters(),
timeRestore: savedDashboard.timeRestore,
description: savedDashboard.description || '',
tags: getTagsFromSavedDashboard(savedDashboard, savedObjectsTagging),
panels: savedDashboard.panelsJSON ? JSON.parse(savedDashboard.panelsJSON) : [],
viewMode: savedDashboard.id || hideWriteControls ? ViewMode.VIEW : ViewMode.EDIT,
options: savedDashboard.optionsJSON ? JSON.parse(savedDashboard.optionsJSON) : {},
},
version,
usageCollection
);
const panels: DashboardPanelMap = {};
rawState.panels?.forEach((panel: SavedDashboardPanel) => {
panels[panel.panelIndex] = convertSavedDashboardPanelToPanelState(panel);
});
return { ...rawState, panels };
};
/**
* Converts a dashboard state object to dashboard container input
*/
export const stateToDashboardContainerInput = ({
dashboardCapabilities,
isEmbeddedExternally,
query: queryService,
searchSessionId,
savedDashboard,
dashboardState,
}: StateToDashboardContainerInputProps): DashboardContainerInput => {
const { filterManager, timefilter: timefilterService } = queryService;
const { timefilter } = timefilterService;
const {
expandedPanelId,
fullScreenMode,
description,
options,
viewMode,
panels,
query,
title,
} = dashboardState;
return {
refreshConfig: timefilter.getRefreshInterval(),
filters: filterManager.getFilters(),
isFullScreenMode: fullScreenMode,
id: savedDashboard.id || '',
dashboardCapabilities,
isEmbeddedExternally,
...(options || {}),
searchSessionId,
expandedPanelId,
description,
viewMode,
panels,
query,
title,
timeRange: {
..._.cloneDeep(timefilter.getTime()),
},
};
};
/**
* Converts a given dashboard state object to raw dashboard state. This is useful for sharing, and session restoration, as
* they require panels to be formatted as an array.
*/
export const stateToRawDashboardState = ({
version,
state,
}: StateToRawDashboardStateProps): RawDashboardState => {
const savedDashboardPanels = Object.values(state.panels).map((panel) =>
convertPanelStateToSavedDashboardPanel(panel, version)
);
return { ..._.omit(state, 'panels'), panels: savedDashboardPanels };
};

View file

@ -0,0 +1,131 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { History } from 'history';
import { DashboardConstants } from '../..';
import { DashboardState } from '../../types';
import { getDashboardTitle } from '../../dashboard_strings';
import { DashboardSavedObject } from '../../saved_dashboards';
import { getQueryParams } from '../../services/kibana_utils';
import { createQueryParamObservable } from '../../../../kibana_utils/public';
import { DASHBOARD_APP_URL_GENERATOR, DashboardUrlGeneratorState } from '../../url_generator';
import {
DataPublicPluginStart,
noSearchSessionStorageCapabilityMessage,
} from '../../services/data';
import { stateToRawDashboardState } from './convert_dashboard_state';
export const getSearchSessionIdFromURL = (history: History): string | undefined =>
getQueryParams(history.location)[DashboardConstants.SEARCH_SESSION_ID] as string | undefined;
export const getSessionURLObservable = (history: History) =>
createQueryParamObservable<string>(history, DashboardConstants.SEARCH_SESSION_ID);
export function createSessionRestorationDataProvider(deps: {
kibanaVersion: string;
data: DataPublicPluginStart;
getAppState: () => DashboardState;
getDashboardTitle: () => string;
getDashboardId: () => string;
}) {
return {
getName: async () => deps.getDashboardTitle(),
getUrlGeneratorData: async () => {
return {
urlGeneratorId: DASHBOARD_APP_URL_GENERATOR,
initialState: getUrlGeneratorState({ ...deps, shouldRestoreSearchSession: false }),
restoreState: getUrlGeneratorState({ ...deps, shouldRestoreSearchSession: true }),
};
},
};
}
/**
* Enables dashboard search sessions.
*/
export function enableDashboardSearchSessions({
canStoreSearchSession,
initialDashboardState,
getLatestDashboardState,
savedDashboard,
kibanaVersion,
data,
}: {
kibanaVersion: string;
data: DataPublicPluginStart;
canStoreSearchSession: boolean;
savedDashboard: DashboardSavedObject;
initialDashboardState: DashboardState;
getLatestDashboardState: () => DashboardState;
}) {
const dashboardTitle = getDashboardTitle(
initialDashboardState.title,
initialDashboardState.viewMode,
!savedDashboard.id
);
data.search.session.enableStorage(
createSessionRestorationDataProvider({
data,
kibanaVersion,
getDashboardTitle: () => dashboardTitle,
getDashboardId: () => savedDashboard?.id || '',
getAppState: getLatestDashboardState,
}),
{
isDisabled: () =>
canStoreSearchSession
? { disabled: false }
: {
disabled: true,
reasonText: noSearchSessionStorageCapabilityMessage,
},
}
);
}
/**
* Fetches the state to store when a session is saved so that this dashboard can be recreated exactly
* as it was.
*/
function getUrlGeneratorState({
data,
getAppState,
kibanaVersion,
getDashboardId,
shouldRestoreSearchSession,
}: {
kibanaVersion: string;
data: DataPublicPluginStart;
getAppState: () => DashboardState;
getDashboardId: () => string;
shouldRestoreSearchSession: boolean;
}): DashboardUrlGeneratorState {
const appState = stateToRawDashboardState({ state: getAppState(), version: kibanaVersion });
const { filterManager, queryString } = data.query;
const { timefilter } = data.query.timefilter;
return {
timeRange: shouldRestoreSearchSession ? timefilter.getAbsoluteTime() : timefilter.getTime(),
searchSessionId: shouldRestoreSearchSession ? data.search.session.getSessionId() : undefined,
panels: getDashboardId() ? undefined : appState.panels,
query: queryString.formatQuery(appState.query),
filters: filterManager.getFilters(),
savedQuery: appState.savedQuery,
dashboardId: getDashboardId(),
preserveSavedFilters: false,
viewMode: appState.viewMode,
useHash: false,
refreshInterval: shouldRestoreSearchSession
? {
pause: true, // force pause refresh interval when restoring a session
value: 0,
}
: undefined,
};
}

View file

@ -10,19 +10,19 @@ import { set } from '@elastic/safer-lodash-set';
import { Storage } from '../../services/kibana_utils';
import { NotificationsStart } from '../../services/core';
import { panelStorageErrorStrings } from '../../dashboard_strings';
import { SavedDashboardPanel } from '..';
import { DashboardState } from '../../types';
export const DASHBOARD_PANELS_UNSAVED_ID = 'unsavedDashboard';
const DASHBOARD_PANELS_SESSION_KEY = 'dashboardStateManagerPanels';
export class DashboardPanelStorage {
export class DashboardSessionStorage {
private sessionStorage: Storage;
constructor(private toasts: NotificationsStart['toasts'], private activeSpaceId: string) {
this.sessionStorage = new Storage(sessionStorage);
}
public clearPanels(id = DASHBOARD_PANELS_UNSAVED_ID) {
public clearState(id = DASHBOARD_PANELS_UNSAVED_ID) {
try {
const sessionStorage = this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY);
const sessionStorageForSpace = sessionStorage?.[this.activeSpaceId] || {};
@ -38,7 +38,7 @@ export class DashboardPanelStorage {
}
}
public getPanels(id = DASHBOARD_PANELS_UNSAVED_ID): SavedDashboardPanel[] | undefined {
public getState(id = DASHBOARD_PANELS_UNSAVED_ID): Partial<DashboardState> | undefined {
try {
return this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY)?.[this.activeSpaceId]?.[id];
} catch (e) {
@ -49,11 +49,11 @@ export class DashboardPanelStorage {
}
}
public setPanels(id = DASHBOARD_PANELS_UNSAVED_ID, newPanels: SavedDashboardPanel[]) {
public setState(id = DASHBOARD_PANELS_UNSAVED_ID, newState: Partial<DashboardState>) {
try {
const sessionStoragePanels = this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY) || {};
set(sessionStoragePanels, [this.activeSpaceId, id], newPanels);
this.sessionStorage.set(DASHBOARD_PANELS_SESSION_KEY, sessionStoragePanels);
const sessionStateStorage = this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY) || {};
set(sessionStateStorage, [this.activeSpaceId, id], newState);
this.sessionStorage.set(DASHBOARD_PANELS_SESSION_KEY, sessionStateStorage);
} catch (e) {
this.toasts.addDanger({
title: panelStorageErrorStrings.getPanelsSetError(e.message),
@ -64,9 +64,18 @@ export class DashboardPanelStorage {
public getDashboardIdsWithUnsavedChanges() {
try {
return Object.keys(
this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY)?.[this.activeSpaceId] || {}
);
const dashboardStatesInSpace =
this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY)?.[this.activeSpaceId] || {};
const dashboardsWithUnsavedChanges: string[] = [];
Object.keys(dashboardStatesInSpace).map((dashboardId) => {
if (
Object.keys(dashboardStatesInSpace[dashboardId]).some(
(stateKey) => stateKey !== 'viewMode'
)
)
dashboardsWithUnsavedChanges.push(dashboardId);
});
return dashboardsWithUnsavedChanges;
} catch (e) {
this.toasts.addDanger({
title: panelStorageErrorStrings.getPanelsGetError(e.message),

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { DashboardSavedObject } from '../..';
import { SavedObject } from '../../services/saved_objects';
import { SavedObjectsTaggingApi } from '../../services/saved_objects_tagging_oss';
import type { TagDecoratedSavedObject } from '../../services/saved_objects_tagging_oss';
// TS is picky with type guards, we can't just inline `() => false`
function defaultTaggingGuard(_obj: SavedObject): _obj is TagDecoratedSavedObject {
return false;
}
export const getTagsFromSavedDashboard = (
savedDashboard: DashboardSavedObject,
api?: SavedObjectsTaggingApi
) => {
const hasTaggingCapabilities = getHasTaggingCapabilitiesGuard(api);
return hasTaggingCapabilities(savedDashboard) ? savedDashboard.getTags() : [];
};
export const getHasTaggingCapabilitiesGuard = (api?: SavedObjectsTaggingApi) =>
api?.ui.hasTagDecoration || defaultTaggingGuard;

View file

@ -0,0 +1,129 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import _ from 'lodash';
import { DashboardPanelState } from '..';
import { esFilters, Filter } from '../../services/data';
import {
DashboardContainerInput,
DashboardOptions,
DashboardPanelMap,
DashboardState,
} from '../../types';
interface DashboardDiffCommon {
[key: string]: unknown;
}
type DashboardDiffCommonFilters = DashboardDiffCommon & { filters: Filter[] };
export const diffDashboardContainerInput = (
originalInput: DashboardContainerInput,
newInput: DashboardContainerInput
) => {
return commonDiffFilters<DashboardContainerInput>(
(originalInput as unknown) as DashboardDiffCommonFilters,
(newInput as unknown) as DashboardDiffCommonFilters,
['searchSessionId', 'lastReloadRequestTime']
);
};
export const diffDashboardState = (
original: DashboardState,
newState: DashboardState
): Partial<DashboardState> => {
const common = commonDiffFilters<DashboardState>(
(original as unknown) as DashboardDiffCommonFilters,
(newState as unknown) as DashboardDiffCommonFilters,
['viewMode', 'panels', 'options', 'savedQuery', 'expandedPanelId'],
true
);
return {
...common,
...(panelsAreEqual(original.panels, newState.panels) ? {} : { panels: newState.panels }),
...(optionsAreEqual(original.options, newState.options) ? {} : { options: newState.options }),
};
};
const optionsAreEqual = (optionsA: DashboardOptions, optionsB: DashboardOptions): boolean => {
const optionKeys = [...Object.keys(optionsA), ...Object.keys(optionsB)];
for (const key of optionKeys) {
if (
Boolean(((optionsA as unknown) as { [key: string]: boolean })[key]) !==
Boolean(((optionsB as unknown) as { [key: string]: boolean })[key])
) {
return false;
}
}
return true;
};
const panelsAreEqual = (panelsA: DashboardPanelMap, panelsB: DashboardPanelMap): boolean => {
const embeddableIdsA = Object.keys(panelsA);
const embeddableIdsB = Object.keys(panelsB);
if (
embeddableIdsA.length !== embeddableIdsB.length ||
_.xor(embeddableIdsA, embeddableIdsB).length > 0
) {
return false;
}
// embeddable ids are equal so let's compare individual panels.
for (const id of embeddableIdsA) {
if (
Object.keys(
commonDiff<DashboardPanelState>(
(panelsA[id] as unknown) as DashboardDiffCommon,
(panelsB[id] as unknown) as DashboardDiffCommon,
['panelRefName']
)
).length > 0
) {
return false;
}
}
return true;
};
const commonDiffFilters = <T extends { filters: Filter[] }>(
originalObj: DashboardDiffCommonFilters,
newObj: DashboardDiffCommonFilters,
omitKeys: string[],
ignorePinned?: boolean
): Partial<T> => {
const filtersAreDifferent = () =>
!esFilters.compareFilters(
originalObj.filters,
ignorePinned ? newObj.filters.filter((f) => !esFilters.isFilterPinned(f)) : newObj.filters,
esFilters.COMPARE_ALL_OPTIONS
);
const otherDifferences = commonDiff<T>(originalObj, newObj, [...omitKeys, 'filters']);
return _.cloneDeep({
...otherDifferences,
...(filtersAreDifferent() ? { filters: newObj.filters } : {}),
});
};
const commonDiff = <T>(
originalObj: DashboardDiffCommon,
newObj: DashboardDiffCommon,
omitKeys: string[]
) => {
const differences: Partial<T> = {};
const keys = [...Object.keys(originalObj), ...Object.keys(newObj)].filter(
(key) => !omitKeys.includes(key)
);
keys.forEach((key) => {
if (key === undefined) return;
if (!_.isEqual(originalObj[key], newObj[key])) {
(differences as { [key: string]: unknown })[key] = newObj[key];
}
});
return differences;
};

View file

@ -8,59 +8,68 @@
import _ from 'lodash';
import moment, { Moment } from 'moment';
import { Filter } from '../../services/data';
import { Optional } from '@kbn/utility-types';
import { Filter, TimeRange } from '../../services/data';
type TimeRangeCompare = Optional<TimeRange>;
/**
* @typedef {Object} QueryFilter
* @property query_string {Object}
* @property query_string.query {String}
* Converts the time to a utc formatted string. If the time is not valid (e.g. it might be in a relative format like
* 'now-15m', then it just returns what it was passed).
* @param time {string|Moment}
* @returns the time represented in utc format, or if the time range was not able to be parsed into a moment
* object, it returns the same object it was given.
*/
export const convertTimeToUTCString = (time?: string | Moment): undefined | string => {
if (moment(time).isValid()) {
return moment(time).utc().format('YYYY-MM-DDTHH:mm:ss.SSS[Z]');
} else {
// If it's not a valid moment date, then it should be a string representing a relative time
// like 'now' or 'now-15m'.
return time as string;
}
};
export class FilterUtils {
/**
* Converts the time to a utc formatted string. If the time is not valid (e.g. it might be in a relative format like
* 'now-15m', then it just returns what it was passed).
* @param time {string|Moment}
* @returns the time represented in utc format, or if the time range was not able to be parsed into a moment
* object, it returns the same object it was given.
*/
public static convertTimeToUTCString(time?: string | Moment): undefined | string {
if (moment(time).isValid()) {
return moment(time).utc().format('YYYY-MM-DDTHH:mm:ss.SSS[Z]');
} else {
// If it's not a valid moment date, then it should be a string representing a relative time
// like 'now' or 'now-15m'.
return time as string;
export const areTimeRangesEqual = (rangeA: TimeRangeCompare, rangeB: TimeRangeCompare): boolean => {
return areTimesEqual(rangeA.from, rangeB.from) && areTimesEqual(rangeA.to, rangeB.to);
};
/**
* Compares the two times, making sure they are in both compared in string format. Absolute times
* are sometimes stored as moment objects, but converted to strings when reloaded. Relative times are
* strings that are not convertible to moment objects.
* @param timeA {string|Moment}
* @param timeB {string|Moment}
* @returns {boolean}
*/
export const areTimesEqual = (timeA?: string | Moment, timeB?: string | Moment) => {
return convertTimeToUTCString(timeA) === convertTimeToUTCString(timeB);
};
/**
* Depending on how a dashboard is loaded, the filter object may contain a $$hashKey and $state that will throw
* off a filter comparison. This removes those variables.
* @param filters {Array.<Object>}
* @returns {Array.<Object>}
*/
export const cleanFiltersForComparison = (filters: Filter[]) => {
return _.map(filters, (filter) => {
const f: Partial<Filter> = _.omit(filter, ['$$hashKey', '$state']);
if (f.meta) {
// f.meta.value is the value displayed in the filter bar.
// It may also be loaded differently and shouldn't be used in this comparison.
return _.omit(f.meta, ['value']);
}
}
return f;
});
};
/**
* Compares the two times, making sure they are in both compared in string format. Absolute times
* are sometimes stored as moment objects, but converted to strings when reloaded. Relative times are
* strings that are not convertible to moment objects.
* @param timeA {string|Moment}
* @param timeB {string|Moment}
* @returns {boolean}
*/
public static areTimesEqual(timeA?: string | Moment, timeB?: string | Moment) {
return this.convertTimeToUTCString(timeA) === this.convertTimeToUTCString(timeB);
}
/**
* Depending on how a dashboard is loaded, the filter object may contain a $$hashKey and $state that will throw
* off a filter comparison. This removes those variables.
* @param filters {Array.<Object>}
* @returns {Array.<Object>}
*/
public static cleanFiltersForComparison(filters: Filter[]) {
return _.map(filters, (filter) => {
const f: Partial<Filter> = _.omit(filter, ['$$hashKey', '$state']);
if (f.meta) {
// f.meta.value is the value displayed in the filter bar.
// It may also be loaded differently and shouldn't be used in this comparison.
return _.omit(f.meta, ['value']);
}
return f;
});
}
}
export const cleanFiltersForSerialize = (filters: Filter[]): Filter[] => {
return filters.map((filter) => {
if (filter.meta.value) {
delete filter.meta.value;
}
return filter;
});
};

View file

@ -1,31 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { SavedObjectTagDecoratorTypeGuard } from '../../services/saved_objects_tagging_oss';
import { ViewMode } from '../../services/embeddable';
import { DashboardSavedObject } from '../../saved_dashboards';
import { DashboardAppStateDefaults } from '../../types';
export function getAppStateDefaults(
viewMode: ViewMode,
savedDashboard: DashboardSavedObject,
hasTaggingCapabilities: SavedObjectTagDecoratorTypeGuard
): DashboardAppStateDefaults {
return {
fullScreenMode: false,
title: savedDashboard.title,
description: savedDashboard.description || '',
tags: hasTaggingCapabilities(savedDashboard) ? savedDashboard.getTags() : [],
timeRestore: savedDashboard.timeRestore,
panels: savedDashboard.panelsJSON ? JSON.parse(savedDashboard.panelsJSON) : [],
options: savedDashboard.optionsJSON ? JSON.parse(savedDashboard.optionsJSON) : {},
query: savedDashboard.getQuery(),
filters: savedDashboard.getFilters(),
viewMode,
};
}

View file

@ -6,11 +6,28 @@
* Side Public License, v 1.
*/
export { saveDashboard } from './save_dashboard';
export { getAppStateDefaults } from './get_app_state_defaults';
export { migrateAppState } from './migrate_app_state';
export * from './filter_utils';
export { getDashboardIdFromUrl } from './url';
export { createSessionRestorationDataProvider } from './session_restoration';
export { saveDashboard } from './save_dashboard';
export { migrateAppState } from './migrate_app_state';
export { addHelpMenuToAppChrome } from './help_menu_util';
export { getTagsFromSavedDashboard } from './dashboard_tagging';
export { loadDashboardUrlState } from './load_dashboard_url_state';
export { DashboardSessionStorage } from './dashboard_session_storage';
export { loadSavedDashboardState } from './load_saved_dashboard_state';
export { attemptLoadDashboardByTitle } from './load_dashboard_by_title';
export { DashboardPanelStorage } from './dashboard_panel_storage';
export { syncDashboardFilterState } from './sync_dashboard_filter_state';
export { syncDashboardIndexPatterns } from './sync_dashboard_index_patterns';
export { syncDashboardContainerInput } from './sync_dashboard_container_input';
export { diffDashboardContainerInput, diffDashboardState } from './diff_dashboard_state';
export { buildDashboardContainer, tryDestroyDashboardContainer } from './build_dashboard_container';
export {
stateToDashboardContainerInput,
savedObjectToDashboardState,
} from './convert_dashboard_state';
export {
createSessionRestorationDataProvider,
enableDashboardSearchSessions,
getSearchSessionIdFromURL,
getSessionURLObservable,
} from './dashboard_session_restoration';

View file

@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import _ from 'lodash';
import { migrateAppState } from '.';
import { replaceUrlHashQuery } from '../../../../kibana_utils/public';
import { DASHBOARD_STATE_STORAGE_KEY } from '../../dashboard_constants';
import { convertSavedDashboardPanelToPanelState } from '../../../common/embeddable/embeddable_saved_object_converters';
import {
DashboardBuildContext,
DashboardPanelMap,
DashboardState,
RawDashboardState,
SavedDashboardPanel,
} from '../../types';
import { migrateLegacyQuery } from './migrate_legacy_query';
/**
* Loads any dashboard state from the URL, and removes the state from the URL.
*/
export const loadDashboardUrlState = ({
kibanaVersion,
usageCollection,
kbnUrlStateStorage,
}: DashboardBuildContext): Partial<DashboardState> => {
const rawAppStateInUrl = kbnUrlStateStorage.get<RawDashboardState>(DASHBOARD_STATE_STORAGE_KEY);
if (!rawAppStateInUrl) return {};
const panelsMap: DashboardPanelMap = {};
if (rawAppStateInUrl.panels && rawAppStateInUrl.panels.length > 0) {
const rawState = migrateAppState(rawAppStateInUrl, kibanaVersion, usageCollection);
rawState.panels?.forEach((panel: SavedDashboardPanel) => {
panelsMap[panel.panelIndex] = convertSavedDashboardPanelToPanelState(panel);
});
}
const migratedQuery = rawAppStateInUrl.query
? migrateLegacyQuery(rawAppStateInUrl.query)
: undefined;
// remove state from URL
kbnUrlStateStorage.kbnUrlControls.updateAsync((nextUrl) => {
if (nextUrl.includes(DASHBOARD_STATE_STORAGE_KEY)) {
return replaceUrlHashQuery(nextUrl, (query) => {
delete query[DASHBOARD_STATE_STORAGE_KEY];
return query;
});
}
return nextUrl;
}, true);
return {
..._.omit(rawAppStateInUrl, ['panels', 'query']),
...(migratedQuery ? { query: migratedQuery } : {}),
...(rawAppStateInUrl.panels ? { panels: panelsMap } : {}),
};
};

View file

@ -0,0 +1,82 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import _ from 'lodash';
import { savedObjectToDashboardState } from './convert_dashboard_state';
import { DashboardState, DashboardBuildContext } from '../../types';
import { DashboardConstants, DashboardSavedObject } from '../..';
import { getDashboard60Warning } from '../../dashboard_strings';
import { migrateLegacyQuery } from './migrate_legacy_query';
import { cleanFiltersForSerialize } from './filter_utils';
import { ViewMode } from '../../services/embeddable';
interface LoadSavedDashboardStateReturn {
savedDashboardState: DashboardState;
savedDashboard: DashboardSavedObject;
}
/**
* Loads, migrates, and returns state from a dashboard saved object.
*/
export const loadSavedDashboardState = async ({
query,
history,
notifications,
indexPatterns,
savedDashboards,
usageCollection,
savedDashboardId,
initializerContext,
savedObjectsTagging,
dashboardCapabilities,
}: DashboardBuildContext & { savedDashboardId?: string }): Promise<
LoadSavedDashboardStateReturn | undefined
> => {
const { hideWriteControls } = dashboardCapabilities;
const { queryString } = query;
// BWC - remove for 8.0
if (savedDashboardId === 'create') {
history.replace({
...history.location, // preserve query,
pathname: DashboardConstants.CREATE_NEW_DASHBOARD_URL,
});
notifications.toasts.addWarning(getDashboard60Warning());
return;
}
await indexPatterns.ensureDefaultIndexPattern();
let savedDashboard: DashboardSavedObject | undefined;
try {
savedDashboard = (await savedDashboards.get(savedDashboardId)) as DashboardSavedObject;
} catch (error) {
// E.g. a corrupt or deleted dashboard
notifications.toasts.addDanger(error.message);
history.push(DashboardConstants.LANDING_PAGE_PATH);
return;
}
if (!savedDashboard) return;
const savedDashboardState = savedObjectToDashboardState({
savedDashboard,
usageCollection,
hideWriteControls,
savedObjectsTagging,
version: initializerContext.env.packageInfo.version,
});
const isViewMode = hideWriteControls || Boolean(savedDashboard.id);
savedDashboardState.viewMode = isViewMode ? ViewMode.VIEW : ViewMode.EDIT;
savedDashboardState.filters = cleanFiltersForSerialize(savedDashboardState.filters);
savedDashboardState.query = migrateLegacyQuery(
savedDashboardState.query || queryString.getDefaultQuery()
);
return { savedDashboardState, savedDashboard };
};

View file

@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n';
import { METRIC_TYPE } from '@kbn/analytics';
import { UsageCollectionSetup } from '../../services/usage_collection';
import { DashboardAppState, SavedDashboardPanel } from '../../types';
import { RawDashboardState, SavedDashboardPanel } from '../../types';
import {
migratePanelsTo730,
SavedDashboardPanelTo60,
@ -28,10 +28,10 @@ import {
* Once we hit a major version, we can remove support for older style URLs and get rid of this logic.
*/
export function migrateAppState(
appState: { [key: string]: any } & DashboardAppState,
appState: { [key: string]: any } & RawDashboardState,
kibanaVersion: string,
usageCollection?: UsageCollectionSetup
): DashboardAppState {
): RawDashboardState {
if (!appState.panels) {
throw new Error(
i18n.translate('dashboard.panel.invalidData', {

View file

@ -6,40 +6,110 @@
* Side Public License, v 1.
*/
import { TimefilterContract } from '../../services/data';
import _ from 'lodash';
import { convertTimeToUTCString } from '.';
import { NotificationsStart } from '../../services/core';
import { DashboardSavedObject } from '../../saved_dashboards';
import { DashboardRedirect, DashboardState } from '../../types';
import { SavedObjectSaveOpts } from '../../services/saved_objects';
import { updateSavedDashboard } from './update_saved_dashboard';
import { DashboardStateManager } from '../dashboard_state_manager';
import { dashboardSaveToastStrings } from '../../dashboard_strings';
import { getHasTaggingCapabilitiesGuard } from './dashboard_tagging';
import { SavedObjectsTaggingApi } from '../../services/saved_objects_tagging_oss';
import { RefreshInterval, TimefilterContract, esFilters } from '../../services/data';
import { convertPanelStateToSavedDashboardPanel } from '../../../common/embeddable/embeddable_saved_object_converters';
import { DashboardSessionStorage } from './dashboard_session_storage';
export type SavedDashboardSaveOpts = SavedObjectSaveOpts & { stayInEditMode?: boolean };
/**
* Saves the dashboard.
* @param toJson A custom toJson function. Used because the previous code used
* the angularized toJson version, and it was unclear whether there was a reason not to use
* JSON.stringify
* @returns A promise that if resolved, will contain the id of the newly saved
* dashboard.
*/
export function saveDashboard(
toJson: (obj: any) => string,
timeFilter: TimefilterContract,
dashboardStateManager: DashboardStateManager,
saveOptions: SavedDashboardSaveOpts
): Promise<string> {
const savedDashboard = dashboardStateManager.savedDashboard;
const appState = dashboardStateManager.appState;
const hasTaggingCapabilities = dashboardStateManager.hasTaggingCapabilities;
updateSavedDashboard(savedDashboard, appState, timeFilter, hasTaggingCapabilities, toJson);
return savedDashboard.save(saveOptions).then((id: string) => {
if (id) {
// reset state only when save() was successful
// e.g. save() could be interrupted if title is duplicated and not confirmed
dashboardStateManager.lastSavedDashboardFilters = dashboardStateManager.getFilterState();
}
return id;
});
interface SaveDashboardProps {
version: string;
redirectTo: DashboardRedirect;
currentState: DashboardState;
timefilter: TimefilterContract;
saveOptions: SavedDashboardSaveOpts;
toasts: NotificationsStart['toasts'];
savedDashboard: DashboardSavedObject;
savedObjectsTagging?: SavedObjectsTaggingApi;
dashboardSessionStorage: DashboardSessionStorage;
}
export const saveDashboard = async ({
toasts,
version,
redirectTo,
timefilter,
saveOptions,
currentState,
savedDashboard,
savedObjectsTagging,
dashboardSessionStorage,
}: SaveDashboardProps): Promise<{ id?: string; redirected?: boolean; error?: any }> => {
const lastDashboardId = savedDashboard.id;
const hasTaggingCapabilities = getHasTaggingCapabilitiesGuard(savedObjectsTagging);
const { panels, title, tags, description, timeRestore, options } = currentState;
const savedDashboardPanels = Object.values(panels).map((panel) =>
convertPanelStateToSavedDashboardPanel(panel, version)
);
savedDashboard.title = title;
savedDashboard.description = description;
savedDashboard.timeRestore = timeRestore;
savedDashboard.optionsJSON = JSON.stringify(options);
savedDashboard.panelsJSON = JSON.stringify(savedDashboardPanels);
if (hasTaggingCapabilities(savedDashboard)) {
savedDashboard.setTags(tags);
}
const { from, to } = timefilter.getTime();
savedDashboard.timeFrom = savedDashboard.timeRestore ? convertTimeToUTCString(from) : undefined;
savedDashboard.timeTo = savedDashboard.timeRestore ? convertTimeToUTCString(to) : undefined;
const timeRestoreObj: RefreshInterval = _.pick(timefilter.getRefreshInterval(), [
'display',
'pause',
'section',
'value',
]) as RefreshInterval;
savedDashboard.refreshInterval = savedDashboard.timeRestore ? timeRestoreObj : undefined;
// only save unpinned filters
const unpinnedFilters = savedDashboard
.getFilters()
.filter((filter) => !esFilters.isFilterPinned(filter));
savedDashboard.searchSource.setField('filter', unpinnedFilters);
try {
const newId = await savedDashboard.save(saveOptions);
if (newId) {
toasts.addSuccess({
title: dashboardSaveToastStrings.getSuccessString(currentState.title),
'data-test-subj': 'saveDashboardSuccess',
});
/**
* If the dashboard id has been changed, redirect to the new ID to keep the url param in sync.
*/
if (newId !== lastDashboardId) {
dashboardSessionStorage.clearState(lastDashboardId);
redirectTo({
id: newId,
editMode: true,
useReplace: true,
destination: 'dashboard',
});
return { redirected: true, id: newId };
}
}
return { id: newId };
} catch (error) {
toasts.addDanger({
title: dashboardSaveToastStrings.getFailureString(currentState.title, error.message),
'data-test-subj': 'saveDashboardFailure',
});
return { error };
}
};

View file

@ -6,23 +6,24 @@
* Side Public License, v 1.
*/
import { dataPluginMock } from '../../../../data/public/mocks';
import { createSessionRestorationDataProvider } from './session_restoration';
import { getAppStateDefaults } from './get_app_state_defaults';
import { getSavedDashboardMock } from '../test_helpers';
import { SavedObjectTagDecoratorTypeGuard } from '../../../../saved_objects_tagging_oss/public';
import { ViewMode } from '../../services/embeddable';
import { dataPluginMock } from '../../../../data/public/mocks';
import { createSessionRestorationDataProvider, savedObjectToDashboardState } from '.';
describe('createSessionRestorationDataProvider', () => {
const mockDataPlugin = dataPluginMock.createStartContract();
const version = '8.0.0';
const searchSessionInfoProvider = createSessionRestorationDataProvider({
kibanaVersion: version,
data: mockDataPlugin,
getAppState: () =>
getAppStateDefaults(
ViewMode.VIEW,
getSavedDashboardMock(),
((() => false) as unknown) as SavedObjectTagDecoratorTypeGuard
),
savedObjectToDashboardState({
version,
hideWriteControls: false,
usageCollection: undefined,
savedObjectsTagging: undefined,
savedDashboard: getSavedDashboardMock(),
}),
getDashboardTitle: () => 'Dashboard',
getDashboardId: () => 'Id',
});

View file

@ -1,63 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { DASHBOARD_APP_URL_GENERATOR, DashboardUrlGeneratorState } from '../../url_generator';
import { DataPublicPluginStart } from '../../services/data';
import { DashboardAppState } from '../../types';
export function createSessionRestorationDataProvider(deps: {
data: DataPublicPluginStart;
getAppState: () => DashboardAppState;
getDashboardTitle: () => string;
getDashboardId: () => string;
}) {
return {
getName: async () => deps.getDashboardTitle(),
getUrlGeneratorData: async () => {
return {
urlGeneratorId: DASHBOARD_APP_URL_GENERATOR,
initialState: getUrlGeneratorState({ ...deps, shouldRestoreSearchSession: false }),
restoreState: getUrlGeneratorState({ ...deps, shouldRestoreSearchSession: true }),
};
},
};
}
function getUrlGeneratorState({
data,
getAppState,
getDashboardId,
shouldRestoreSearchSession,
}: {
data: DataPublicPluginStart;
getAppState: () => DashboardAppState;
getDashboardId: () => string;
shouldRestoreSearchSession: boolean;
}): DashboardUrlGeneratorState {
const appState = getAppState();
return {
dashboardId: getDashboardId(),
timeRange: shouldRestoreSearchSession
? data.query.timefilter.timefilter.getAbsoluteTime()
: data.query.timefilter.timefilter.getTime(),
filters: data.query.filterManager.getFilters(),
query: data.query.queryString.formatQuery(appState.query),
savedQuery: appState.savedQuery,
useHash: false,
preserveSavedFilters: false,
viewMode: appState.viewMode,
panels: getDashboardId() ? undefined : appState.panels,
searchSessionId: shouldRestoreSearchSession ? data.search.session.getSessionId() : undefined,
refreshInterval: shouldRestoreSearchSession
? {
pause: true, // force pause refresh interval when restoring a session
value: 0,
}
: undefined,
};
}

View file

@ -0,0 +1,201 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import _ from 'lodash';
import { Subscription } from 'rxjs';
import { debounceTime, tap } from 'rxjs/operators';
import { DashboardContainer } from '../embeddable';
import { esFilters, Filter, Query } from '../../services/data';
import { DashboardConstants, DashboardSavedObject } from '../..';
import { setExpandedPanelId, setFullScreenMode, setPanels, setQuery } from '../state';
import { diffDashboardContainerInput } from './diff_dashboard_state';
import { replaceUrlHashQuery } from '../../../../kibana_utils/public';
import { DashboardBuildContext, DashboardContainerInput } from '../../types';
import {
getSearchSessionIdFromURL,
getSessionURLObservable,
stateToDashboardContainerInput,
} from '.';
type SyncDashboardContainerCommon = DashboardBuildContext & {
dashboardContainer: DashboardContainer;
savedDashboard: DashboardSavedObject;
};
type ApplyStateChangesToContainerProps = SyncDashboardContainerCommon & {
force: boolean;
};
type ApplyContainerChangesToStateProps = SyncDashboardContainerCommon & {
applyFilters: (query: Query, filters: Filter[]) => void;
};
type SyncDashboardContainerProps = SyncDashboardContainerCommon & ApplyContainerChangesToStateProps;
/**
* Sets up two way binding between dashboard container and redux state.
*/
export const syncDashboardContainerInput = (
syncDashboardContainerProps: SyncDashboardContainerProps
) => {
const {
history,
dashboardContainer,
$onDashboardStateChange,
$triggerDashboardRefresh,
} = syncDashboardContainerProps;
const subscriptions = new Subscription();
subscriptions.add(
dashboardContainer
.getInput$()
.subscribe(() => applyContainerChangesToState(syncDashboardContainerProps))
);
subscriptions.add($onDashboardStateChange.subscribe(() => $triggerDashboardRefresh.next()));
subscriptions.add(
getSessionURLObservable(history).subscribe(() => {
$triggerDashboardRefresh.next({ force: true });
})
);
let forceRefresh: boolean = false;
subscriptions.add(
$triggerDashboardRefresh
.pipe(
tap((trigger) => {
forceRefresh = forceRefresh || (trigger?.force ?? false);
}),
debounceTime(DashboardConstants.CHANGE_APPLY_DEBOUNCE)
)
.subscribe(() => {
applyStateChangesToContainer({ ...syncDashboardContainerProps, force: forceRefresh });
forceRefresh = false;
})
);
return () => subscriptions.unsubscribe();
};
export const applyContainerChangesToState = ({
query,
applyFilters,
dashboardContainer,
getLatestDashboardState,
dispatchDashboardStateChange,
}: ApplyContainerChangesToStateProps) => {
const input = dashboardContainer.getInput();
const latestState = getLatestDashboardState();
if (Object.keys(latestState).length === 0) {
return;
}
const { filterManager } = query;
if (
!esFilters.compareFilters(
input.filters,
filterManager.getFilters(),
esFilters.COMPARE_ALL_OPTIONS
)
) {
// Add filters modifies the object passed to it, hence the clone deep.
filterManager.addFilters(_.cloneDeep(input.filters));
applyFilters(latestState.query, input.filters);
}
if (!_.isEqual(input.panels, latestState.panels)) {
dispatchDashboardStateChange(setPanels(input.panels));
}
if (!_.isEqual(input.query, latestState.query)) {
dispatchDashboardStateChange(setQuery(input.query));
}
if (!_.isEqual(input.expandedPanelId, latestState.expandedPanelId)) {
dispatchDashboardStateChange(setExpandedPanelId(input.expandedPanelId));
}
dispatchDashboardStateChange(setFullScreenMode(input.isFullScreenMode));
};
export const applyStateChangesToContainer = ({
force,
search,
history,
savedDashboard,
dashboardContainer,
kbnUrlStateStorage,
query: queryService,
isEmbeddedExternally,
dashboardCapabilities,
getLatestDashboardState,
}: ApplyStateChangesToContainerProps) => {
const latestState = getLatestDashboardState();
if (Object.keys(latestState).length === 0) {
return;
}
const currentDashboardStateAsInput = stateToDashboardContainerInput({
dashboardState: latestState,
isEmbeddedExternally,
dashboardCapabilities,
query: queryService,
savedDashboard,
});
const differences = diffDashboardContainerInput(
dashboardContainer.getInput(),
currentDashboardStateAsInput
);
if (force) {
differences.lastReloadRequestTime = Date.now();
}
if (Object.keys(differences).length !== 0) {
const shouldRefetch = Object.keys(differences).some(
(changeKey) => !noRefetchKeys.includes(changeKey as keyof DashboardContainerInput)
);
const newSearchSessionId: string | undefined = (() => {
// do not update session id if this is irrelevant state change to prevent excessive searches
if (!shouldRefetch) return;
const sessionApi = search.session;
let searchSessionIdFromURL = getSearchSessionIdFromURL(history);
if (searchSessionIdFromURL) {
if (sessionApi.isRestore() && sessionApi.isCurrentSession(searchSessionIdFromURL)) {
// navigating away from a restored session
kbnUrlStateStorage.kbnUrlControls.updateAsync((nextUrl) => {
if (nextUrl.includes(DashboardConstants.SEARCH_SESSION_ID)) {
return replaceUrlHashQuery(nextUrl, (query) => {
delete query[DashboardConstants.SEARCH_SESSION_ID];
return query;
});
}
return nextUrl;
});
searchSessionIdFromURL = undefined;
} else {
sessionApi.restore(searchSessionIdFromURL);
}
}
return searchSessionIdFromURL ?? sessionApi.start();
})();
dashboardContainer.updateInput({
...differences,
...(newSearchSessionId && { searchSessionId: newSearchSessionId }),
});
}
};
const noRefetchKeys: Readonly<Array<keyof DashboardContainerInput>> = [
'title',
'viewMode',
'useMargins',
'description',
'expandedPanelId',
'isFullScreenMode',
'isEmbeddedExternally',
] as const;

View file

@ -0,0 +1,157 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import _ from 'lodash';
import { merge } from 'rxjs';
import { debounceTime, finalize, map, switchMap, tap } from 'rxjs/operators';
import { setQuery } from '../state';
import { DashboardBuildContext, DashboardState } from '../../types';
import { DashboardSavedObject } from '../../saved_dashboards';
import { setFiltersAndQuery } from '../state/dashboard_state_slice';
import {
syncQueryStateWithUrl,
connectToQueryState,
Filter,
Query,
waitUntilNextSessionCompletes$,
QueryState,
} from '../../services/data';
import { cleanFiltersForSerialize } from '.';
type SyncDashboardFilterStateProps = DashboardBuildContext & {
initialDashboardState: DashboardState;
savedDashboard: DashboardSavedObject;
};
/**
* Applies initial state to the query service, and the saved dashboard search source, then
* Sets up syncing and subscriptions between the filter state from the Data plugin
* and the dashboard Redux store.
*/
export const syncDashboardFilterState = ({
search,
savedDashboard,
kbnUrlStateStorage,
query: queryService,
initialDashboardState,
$checkForUnsavedChanges,
$onDashboardStateChange,
$triggerDashboardRefresh,
dispatchDashboardStateChange,
}: SyncDashboardFilterStateProps) => {
const { filterManager, queryString, timefilter } = queryService;
const { timefilter: timefilterService } = timefilter;
// apply initial filters to the query service and to the saved dashboard
filterManager.setAppFilters(_.cloneDeep(initialDashboardState.filters));
savedDashboard.searchSource.setField('filter', initialDashboardState.filters);
// apply initial query to the query service and to the saved dashboard
queryString.setQuery(initialDashboardState.query);
savedDashboard.searchSource.setField('query', initialDashboardState.query);
/**
* If a global time range is not set explicitly and the time range was saved with the dashboard, apply
* initial time range and refresh interval to the query service.
*/
if (initialDashboardState.timeRestore) {
const initialGlobalQueryState = kbnUrlStateStorage.get<QueryState>('_g');
if (!initialGlobalQueryState?.time) {
if (savedDashboard.timeFrom && savedDashboard.timeTo) {
timefilterService.setTime({
from: savedDashboard.timeFrom,
to: savedDashboard.timeTo,
});
}
}
if (!initialGlobalQueryState?.refreshInterval) {
if (savedDashboard.refreshInterval) {
timefilterService.setRefreshInterval(savedDashboard.refreshInterval);
}
}
}
// this callback will be used any time new filters and query need to be applied.
const applyFilters = (query: Query, filters: Filter[]) => {
savedDashboard.searchSource.setField('query', query);
savedDashboard.searchSource.setField('filter', filters);
dispatchDashboardStateChange(setQuery(query));
};
// starts syncing `_g` portion of url with query services
const { stop: stopSyncingQueryServiceStateWithUrl } = syncQueryStateWithUrl(
queryService,
kbnUrlStateStorage
);
// starts syncing app filters between dashboard state and filterManager
const intermediateFilterState: { filters: Filter[]; query: Query } = {
query: initialDashboardState.query ?? queryString.getDefaultQuery(),
filters: initialDashboardState.filters ?? [],
};
const stopSyncingAppFilters = connectToQueryState(
queryService,
{
get: () => intermediateFilterState,
set: ({ filters, query }) => {
intermediateFilterState.filters = cleanFiltersForSerialize(filters ?? []) || [];
intermediateFilterState.query = query || queryString.getDefaultQuery();
dispatchDashboardStateChange(setFiltersAndQuery(intermediateFilterState));
},
state$: $onDashboardStateChange.pipe(
map((appState) => ({
filters: appState.filters,
query: appState.query,
}))
),
},
{
query: true,
filters: true,
}
);
// apply filters when the filter manager changes
const filterManagerSubscription = merge(filterManager.getUpdates$(), queryString.getUpdates$())
.pipe(debounceTime(100))
.subscribe(() => applyFilters(queryString.getQuery(), filterManager.getFilters()));
const timeRefreshSubscription = merge(
...[timefilterService.getRefreshIntervalUpdate$(), timefilterService.getTimeUpdate$()]
).subscribe(() => {
$triggerDashboardRefresh.next();
// manually check for unsaved changes here because the time range is not stored on the dashboardState,
// but it could trigger the unsaved changes badge.
$checkForUnsavedChanges.next();
});
const forceRefreshSubscription = timefilterService
.getAutoRefreshFetch$()
.pipe(
tap(() => {
$triggerDashboardRefresh.next({ force: true });
}),
switchMap((done) =>
// best way on a dashboard to estimate that panels are updated is to rely on search session service state
waitUntilNextSessionCompletes$(search.session).pipe(finalize(done))
)
)
.subscribe();
const stopSyncingDashboardFilterState = () => {
filterManagerSubscription.unsubscribe();
forceRefreshSubscription.unsubscribe();
timeRefreshSubscription.unsubscribe();
stopSyncingQueryServiceStateWithUrl();
stopSyncingAppFilters();
};
return { applyFilters, stopSyncingDashboardFilterState };
};

View file

@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { uniqBy } from 'lodash';
import deepEqual from 'fast-deep-equal';
import { merge, Observable, pipe } from 'rxjs';
import { distinctUntilChanged, switchMap, startWith, filter, mapTo, map } from 'rxjs/operators';
import { DashboardContainer } from '..';
import { isErrorEmbeddable } from '../../services/embeddable';
import { IndexPattern, IndexPatternsContract } from '../../services/data';
interface SyncDashboardIndexPatternsProps {
dashboardContainer: DashboardContainer;
indexPatterns: IndexPatternsContract;
onUpdateIndexPatterns: (newIndexPatterns: IndexPattern[]) => void;
}
export const syncDashboardIndexPatterns = ({
dashboardContainer,
indexPatterns,
onUpdateIndexPatterns,
}: SyncDashboardIndexPatternsProps) => {
const updateIndexPatternsOperator = pipe(
filter((container: DashboardContainer) => !!container && !isErrorEmbeddable(container)),
map((container: DashboardContainer): IndexPattern[] => {
let panelIndexPatterns: IndexPattern[] = [];
Object.values(container.getChildIds()).forEach((id) => {
const embeddableInstance = container.getChild(id);
if (isErrorEmbeddable(embeddableInstance)) return;
const embeddableIndexPatterns = (embeddableInstance.getOutput() as any).indexPatterns;
if (!embeddableIndexPatterns) return;
panelIndexPatterns.push(...embeddableIndexPatterns);
});
panelIndexPatterns = uniqBy(panelIndexPatterns, 'id');
return panelIndexPatterns;
}),
distinctUntilChanged((a, b) =>
deepEqual(
a.map((ip) => ip && ip.id),
b.map((ip) => ip && ip.id)
)
),
// using switchMap for previous task cancellation
switchMap((panelIndexPatterns: IndexPattern[]) => {
return new Observable((observer) => {
if (panelIndexPatterns && panelIndexPatterns.length > 0) {
if (observer.closed) return;
onUpdateIndexPatterns(panelIndexPatterns);
observer.complete();
} else {
indexPatterns.getDefault().then((defaultIndexPattern) => {
if (observer.closed) return;
onUpdateIndexPatterns([defaultIndexPattern as IndexPattern]);
observer.complete();
});
}
});
})
);
return merge(
// output of dashboard container itself
dashboardContainer.getOutput$(),
// plus output of dashboard container children,
// children may change, so make sure we subscribe/unsubscribe with switchMap
dashboardContainer.getOutput$().pipe(
map(() => dashboardContainer!.getChildIds()),
distinctUntilChanged(deepEqual),
switchMap((newChildIds: string[]) =>
merge(...newChildIds.map((childId) => dashboardContainer!.getChild(childId).getOutput$()))
)
)
)
.pipe(
mapTo(dashboardContainer),
startWith(dashboardContainer), // to trigger initial index pattern update
updateIndexPatternsOperator
)
.subscribe();
};

View file

@ -1,52 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import _ from 'lodash';
import type { SavedObjectTagDecoratorTypeGuard } from '../../services/saved_objects_tagging_oss';
import { RefreshInterval, TimefilterContract, esFilters } from '../../services/data';
import { FilterUtils } from './filter_utils';
import { DashboardSavedObject } from '../../saved_dashboards';
import { DashboardAppState } from '../../types';
export function updateSavedDashboard(
savedDashboard: DashboardSavedObject,
appState: DashboardAppState,
timeFilter: TimefilterContract,
hasTaggingCapabilities: SavedObjectTagDecoratorTypeGuard,
toJson: <T>(object: T) => string
) {
savedDashboard.title = appState.title;
savedDashboard.description = appState.description;
savedDashboard.timeRestore = appState.timeRestore;
savedDashboard.panelsJSON = toJson(appState.panels);
savedDashboard.optionsJSON = toJson(appState.options);
if (hasTaggingCapabilities(savedDashboard)) {
savedDashboard.setTags(appState.tags);
}
savedDashboard.timeFrom = savedDashboard.timeRestore
? FilterUtils.convertTimeToUTCString(timeFilter.getTime().from)
: undefined;
savedDashboard.timeTo = savedDashboard.timeRestore
? FilterUtils.convertTimeToUTCString(timeFilter.getTime().to)
: undefined;
const timeRestoreObj: RefreshInterval = _.pick(timeFilter.getRefreshInterval(), [
'display',
'pause',
'section',
'value',
]) as RefreshInterval;
savedDashboard.refreshInterval = savedDashboard.timeRestore ? timeRestoreObj : undefined;
// save only unpinned filters
const unpinnedFilters = savedDashboard
.getFilters()
.filter((filter) => !esFilters.isFilterPinned(filter));
savedDashboard.searchSource.setField('filter', unpinnedFilters);
}

View file

@ -19,13 +19,10 @@ import {
EUI_MODAL_CANCEL_BUTTON,
} from '@elastic/eui';
import React from 'react';
import { OverlayStart } from '../../../../../core/public';
import {
createConfirmStrings,
discardConfirmStrings,
leaveEditModeConfirmStrings,
} from '../../dashboard_strings';
import { toMountPoint } from '../../services/kibana_react';
import { createConfirmStrings, discardConfirmStrings } from '../../dashboard_strings';
export type DiscardOrKeepSelection = 'cancel' | 'discard' | 'keep';
@ -44,76 +41,6 @@ export const confirmDiscardUnsavedChanges = (overlays: OverlayStart, discardCall
}
});
export const confirmDiscardOrKeepUnsavedChanges = (
overlays: OverlayStart
): Promise<DiscardOrKeepSelection> => {
const titleId = 'confirmDiscardOrKeepTitle';
const descriptionId = 'confirmDiscardOrKeepDescription';
return new Promise((resolve) => {
const session = overlays.openModal(
toMountPoint(
<EuiFocusTrap clickOutsideDisables={true} initialFocus={'.discardConfirmKeepButton'}>
<EuiOutsideClickDetector onOutsideClick={() => session.close()}>
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descriptionId}
>
<EuiModalHeader data-test-subj="dashboardDiscardConfirm">
<EuiModalHeaderTitle>
<h2 id={titleId}>{leaveEditModeConfirmStrings.getLeaveEditModeTitle()}</h2>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiText>
<p id={descriptionId}>{leaveEditModeConfirmStrings.getLeaveEditModeSubtitle()}</p>
</EuiText>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty
data-test-subj="dashboardDiscardConfirmCancel"
onClick={() => session.close()}
>
{leaveEditModeConfirmStrings.getLeaveEditModeCancelButtonText()}
</EuiButtonEmpty>
<EuiButtonEmpty
color="danger"
data-test-subj="dashboardDiscardConfirmDiscard"
onClick={() => {
session.close();
resolve('discard');
}}
>
{leaveEditModeConfirmStrings.getLeaveEditModeDiscardButtonText()}
</EuiButtonEmpty>
<EuiButton
fill
data-test-subj="dashboardDiscardConfirmKeep"
className="discardConfirmKeepButton"
onClick={() => {
session.close();
resolve('keep');
}}
>
{leaveEditModeConfirmStrings.getLeaveEditModeKeepChangesText()}
</EuiButton>
</EuiModalFooter>
</div>
</EuiOutsideClickDetector>
</EuiFocusTrap>
),
{
'data-test-subj': 'dashboardDiscardConfirmModal',
maxWidth: 550,
}
);
});
};
export const confirmCreateWithUnsaved = (
overlays: OverlayStart,
startBlankCallback: () => void,

View file

@ -6,80 +6,16 @@
* Side Public License, v 1.
*/
import React from 'react';
import { mount } from 'enzyme';
import {
IUiSettingsClient,
PluginInitializerContext,
ScopedHistory,
SimpleSavedObject,
} from '../../../../../core/public';
import { I18nProvider } from '@kbn/i18n/react';
import { SavedObjectLoader, SavedObjectLoaderFindOptions } from '../../services/saved_objects';
import { IndexPatternsContract, SavedQueryService } from '../../services/data';
import { NavigationPublicPluginStart } from '../../services/navigation';
import { DashboardAppServices } from '../../types';
import { SimpleSavedObject } from '../../../../../core/public';
import { KibanaContextProvider } from '../../services/kibana_react';
import { createKbnUrlStateStorage } from '../../services/kibana_utils';
import { savedObjectsPluginMock } from '../../../../saved_objects/public/mocks';
import { DashboardListing, DashboardListingProps } from './dashboard_listing';
import { embeddablePluginMock } from '../../../../embeddable/public/mocks';
import { visualizationsPluginMock } from '../../../../visualizations/public/mocks';
import { DashboardAppServices, DashboardAppCapabilities } from '../types';
import { dataPluginMock } from '../../../../data/public/mocks';
import { chromeServiceMock, coreMock } from '../../../../../core/public/mocks';
import { I18nProvider } from '@kbn/i18n/react';
import React from 'react';
import { UrlForwardingStart } from '../../../../url_forwarding/public';
import { DashboardPanelStorage } from '../lib';
function makeDefaultServices(): DashboardAppServices {
const core = coreMock.createStart();
const savedDashboards = {} as SavedObjectLoader;
savedDashboards.find = (search: string, sizeOrOptions: number | SavedObjectLoaderFindOptions) => {
const size = typeof sizeOrOptions === 'number' ? sizeOrOptions : sizeOrOptions.size ?? 10;
const hits = [];
for (let i = 0; i < size; i++) {
hits.push({
id: `dashboard${i}`,
title: `dashboard${i} - ${search} - title`,
description: `dashboard${i} desc`,
});
}
return Promise.resolve({
total: size,
hits,
});
};
const dashboardPanelStorage = ({
getDashboardIdsWithUnsavedChanges: jest
.fn()
.mockResolvedValue(['dashboardUnsavedOne', 'dashboardUnsavedTwo']),
} as unknown) as DashboardPanelStorage;
return {
savedObjects: savedObjectsPluginMock.createStartContract(),
embeddable: embeddablePluginMock.createInstance().doStart(),
dashboardCapabilities: {} as DashboardAppCapabilities,
initializerContext: {} as PluginInitializerContext,
chrome: chromeServiceMock.createStartContract(),
navigation: {} as NavigationPublicPluginStart,
savedObjectsClient: core.savedObjects.client,
data: dataPluginMock.createStartContract(),
indexPatterns: {} as IndexPatternsContract,
scopedHistory: () => ({} as ScopedHistory),
savedQueryService: {} as SavedQueryService,
setHeaderActionMenu: (mountPoint) => {},
urlForwarding: {} as UrlForwardingStart,
uiSettings: {} as IUiSettingsClient,
restorePreviousUrl: () => {},
onAppLeave: (handler) => {},
allowByValueEmbeddables: true,
dashboardPanelStorage,
savedDashboards,
core,
visualizations: visualizationsPluginMock.createStartContract(),
};
}
import { makeDefaultServices } from '../test_helpers';
function makeDefaultProps(): DashboardListingProps {
return {

View file

@ -10,7 +10,7 @@ import { FormattedMessage } from '@kbn/i18n/react';
import { EuiLink, EuiButton, EuiEmptyPrompt } from '@elastic/eui';
import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import { attemptLoadDashboardByTitle } from '../lib';
import { DashboardAppServices, DashboardRedirect } from '../types';
import { DashboardAppServices, DashboardRedirect } from '../../types';
import { getDashboardBreadcrumb, dashboardListingTable } from '../../dashboard_strings';
import { ApplicationStart, SavedObjectsFindOptionsReference } from '../../../../../core/public';
import { syncQueryStateWithUrl } from '../../services/data';
@ -43,13 +43,13 @@ export const DashboardListing = ({
savedObjectsClient,
savedObjectsTagging,
dashboardCapabilities,
dashboardPanelStorage,
dashboardSessionStorage,
chrome: { setBreadcrumbs },
},
} = useKibana<DashboardAppServices>();
const [unsavedDashboardIds, setUnsavedDashboardIds] = useState<string[]>(
dashboardPanelStorage.getDashboardIdsWithUnsavedChanges()
dashboardSessionStorage.getDashboardIdsWithUnsavedChanges()
);
// Set breadcrumbs useEffect
@ -104,19 +104,19 @@ export const DashboardListing = ({
);
const createItem = useCallback(() => {
if (!dashboardPanelStorage.dashboardHasUnsavedEdits()) {
if (!dashboardSessionStorage.dashboardHasUnsavedEdits()) {
redirectTo({ destination: 'dashboard' });
} else {
confirmCreateWithUnsaved(
core.overlays,
() => {
dashboardPanelStorage.clearPanels();
dashboardSessionStorage.clearState();
redirectTo({ destination: 'dashboard' });
},
() => redirectTo({ destination: 'dashboard' })
);
}
}, [dashboardPanelStorage, redirectTo, core.overlays]);
}, [dashboardSessionStorage, redirectTo, core.overlays]);
const emptyPrompt = useMemo(
() => getNoItemsMessage(hideWriteControls, core.application, createItem),
@ -145,11 +145,11 @@ export const DashboardListing = ({
const deleteItems = useCallback(
(dashboards: Array<{ id: string }>) => {
dashboards.map((d) => dashboardPanelStorage.clearPanels(d.id));
setUnsavedDashboardIds(dashboardPanelStorage.getDashboardIdsWithUnsavedChanges());
dashboards.map((d) => dashboardSessionStorage.clearState(d.id));
setUnsavedDashboardIds(dashboardSessionStorage.getDashboardIdsWithUnsavedChanges());
return savedDashboards.delete(dashboards.map((d) => d.id));
},
[savedDashboards, dashboardPanelStorage]
[savedDashboards, dashboardSessionStorage]
);
const editItem = useCallback(
@ -196,7 +196,7 @@ export const DashboardListing = ({
redirectTo={redirectTo}
unsavedDashboardIds={unsavedDashboardIds}
refreshUnsavedDashboards={() =>
setUnsavedDashboardIds(dashboardPanelStorage.getDashboardIdsWithUnsavedChanges())
setUnsavedDashboardIds(dashboardSessionStorage.getDashboardIdsWithUnsavedChanges())
}
/>
</TableListView>

View file

@ -6,14 +6,14 @@
* Side Public License, v 1.
*/
import React, { useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { useEffect } from 'react';
import { EuiCallOut } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { RouteComponentProps } from 'react-router-dom';
import { useKibana, toMountPoint } from '../../services/kibana_react';
import { DashboardAppServices } from '../types';
import { DashboardAppServices } from '../../types';
import { DashboardConstants } from '../..';
let bannerId: string | undefined;

View file

@ -11,14 +11,14 @@ import { findTestSubject } from '@elastic/eui/lib/test';
import { waitFor } from '@testing-library/react';
import { mount } from 'enzyme';
import React from 'react';
import { DashboardSavedObject } from '../..';
import { coreMock } from '../../../../../core/public/mocks';
import { KibanaContextProvider } from '../../services/kibana_react';
import { DashboardAppServices } from '../../types';
import { SavedObjectLoader } from '../../services/saved_objects';
import { DashboardPanelStorage } from '../lib';
import { DASHBOARD_PANELS_UNSAVED_ID } from '../lib/dashboard_panel_storage';
import { DashboardAppServices } from '../types';
import { KibanaContextProvider } from '../../services/kibana_react';
import { DASHBOARD_PANELS_UNSAVED_ID } from '../lib/dashboard_session_storage';
import { DashboardUnsavedListing, DashboardUnsavedListingProps } from './dashboard_unsaved_listing';
import { makeDefaultServices } from '../test_helpers';
const mockedDashboards: { [key: string]: DashboardSavedObject } = {
dashboardUnsavedOne: {
@ -35,20 +35,16 @@ const mockedDashboards: { [key: string]: DashboardSavedObject } = {
} as DashboardSavedObject,
};
function makeDefaultServices(): DashboardAppServices {
const core = coreMock.createStart();
core.overlays.openConfirm = jest.fn().mockResolvedValue(true);
function makeServices(): DashboardAppServices {
const services = makeDefaultServices();
const savedDashboards = {} as SavedObjectLoader;
savedDashboards.get = jest
.fn()
.mockImplementation((id: string) => Promise.resolve(mockedDashboards[id]));
const dashboardPanelStorage = {} as DashboardPanelStorage;
dashboardPanelStorage.clearPanels = jest.fn();
return ({
dashboardPanelStorage,
return {
...services,
savedDashboards,
core,
} as unknown) as DashboardAppServices;
};
}
const makeDefaultProps = (): DashboardUnsavedListingProps => ({
@ -64,7 +60,7 @@ function mountWith({
services?: DashboardAppServices;
props?: DashboardUnsavedListingProps;
}) {
const services = incomingServices ?? makeDefaultServices();
const services = incomingServices ?? makeServices();
const props = incomingProps ?? makeDefaultProps();
const wrappingComponent: React.FC<{
children: React.ReactNode;
@ -140,14 +136,14 @@ describe('Unsaved listing', () => {
waitFor(() => {
component.update();
expect(services.core.overlays.openConfirm).toHaveBeenCalled();
expect(services.dashboardPanelStorage.clearPanels).toHaveBeenCalledWith(
expect(services.dashboardSessionStorage.clearState).toHaveBeenCalledWith(
'dashboardUnsavedOne'
);
});
});
it('removes unsaved changes from any dashboard which errors on fetch', async () => {
const services = makeDefaultServices();
const services = makeServices();
const props = makeDefaultProps();
services.savedDashboards.get = jest.fn().mockImplementation((id: string) => {
if (id === 'failCase1' || id === 'failCase2') {
@ -166,12 +162,12 @@ describe('Unsaved listing', () => {
const { component } = mountWith({ services, props });
waitFor(() => {
component.update();
expect(services.dashboardPanelStorage.clearPanels).toHaveBeenCalledWith('failCase1');
expect(services.dashboardPanelStorage.clearPanels).toHaveBeenCalledWith('failCase2');
expect(services.dashboardSessionStorage.clearState).toHaveBeenCalledWith('failCase1');
expect(services.dashboardSessionStorage.clearState).toHaveBeenCalledWith('failCase2');
// clearing panels from dashboard with errors should cause getDashboardIdsWithUnsavedChanges to be called again.
expect(
services.dashboardPanelStorage.getDashboardIdsWithUnsavedChanges
services.dashboardSessionStorage.getDashboardIdsWithUnsavedChanges
).toHaveBeenCalledTimes(2);
});
});

View file

@ -19,8 +19,8 @@ import React, { useCallback, useEffect, useState } from 'react';
import { DashboardSavedObject } from '../..';
import { dashboardUnsavedListingStrings, getNewDashboardTitle } from '../../dashboard_strings';
import { useKibana } from '../../services/kibana_react';
import { DASHBOARD_PANELS_UNSAVED_ID } from '../lib/dashboard_panel_storage';
import { DashboardAppServices, DashboardRedirect } from '../types';
import { DASHBOARD_PANELS_UNSAVED_ID } from '../lib/dashboard_session_storage';
import { DashboardAppServices, DashboardRedirect } from '../../types';
import { confirmDiscardUnsavedChanges } from './confirm_overlays';
const DashboardUnsavedItem = ({
@ -115,7 +115,7 @@ export const DashboardUnsavedListing = ({
}: DashboardUnsavedListingProps) => {
const {
services: {
dashboardPanelStorage,
dashboardSessionStorage,
savedDashboards,
core: { overlays },
},
@ -133,11 +133,11 @@ export const DashboardUnsavedListing = ({
const onDiscard = useCallback(
(id?: string) => {
confirmDiscardUnsavedChanges(overlays, () => {
dashboardPanelStorage.clearPanels(id);
dashboardSessionStorage.clearState(id);
refreshUnsavedDashboards();
});
},
[overlays, refreshUnsavedDashboards, dashboardPanelStorage]
[overlays, refreshUnsavedDashboards, dashboardSessionStorage]
);
useEffect(() => {
@ -161,7 +161,7 @@ export const DashboardUnsavedListing = ({
const newItems = dashboards.reduce((map, dashboard) => {
if (typeof dashboard === 'string') {
hasError = true;
dashboardPanelStorage.clearPanels(dashboard);
dashboardSessionStorage.clearState(dashboard);
return map;
}
return {
@ -178,7 +178,7 @@ export const DashboardUnsavedListing = ({
return () => {
canceled = true;
};
}, [savedDashboards, dashboardPanelStorage, refreshUnsavedDashboards, unsavedDashboardIds]);
}, [savedDashboards, dashboardSessionStorage, refreshUnsavedDashboards, unsavedDashboardIds]);
return unsavedDashboardIds.length === 0 ? null : (
<>

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { DashboardRootState, DashboardDispatch } from './dashboard_state_store';
export const useDashboardDispatch = () => useDispatch<DashboardDispatch>();
export const useDashboardSelector: TypedUseSelectorHook<DashboardRootState> = useSelector;

View file

@ -0,0 +1,112 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { Filter, Query } from '../../services/data';
import { ViewMode } from '../../services/embeddable';
import { DashboardOptions, DashboardPanelMap, DashboardState } from '../../types';
export const dashboardStateSlice = createSlice({
name: 'dashboardState',
initialState: {} as DashboardState,
reducers: {
setDashboardState: (state, action: PayloadAction<DashboardState>) => {
return action.payload;
},
updateState: (state, action: PayloadAction<Partial<DashboardState>>) => {
state = { ...state, ...action.payload };
},
setDashboardOptions: (state, action: PayloadAction<DashboardOptions>) => {
state.options = action.payload;
},
setStateFromSaveModal: (
state,
action: PayloadAction<{
title: string;
description: string;
tags?: string[];
timeRestore: boolean;
}>
) => {
state.title = action.payload.title;
state.description = action.payload.description;
state.timeRestore = action.payload.timeRestore;
if (action.payload.tags) {
state.tags = action.payload.tags;
}
},
setUseMargins: (state, action: PayloadAction<boolean>) => {
state.options.useMargins = action.payload;
},
setSyncColors: (state, action: PayloadAction<boolean>) => {
state.options.syncColors = action.payload;
},
setHidePanelTitles: (state, action: PayloadAction<boolean>) => {
state.options.hidePanelTitles = action.payload;
},
setPanels: (state, action: PayloadAction<DashboardPanelMap>) => {
state.panels = action.payload;
},
setExpandedPanelId: (state, action: PayloadAction<string | undefined>) => {
state.expandedPanelId = action.payload;
},
setFullScreenMode: (state, action: PayloadAction<boolean>) => {
state.fullScreenMode = action.payload;
},
setSavedQueryId: (state, action: PayloadAction<string | undefined>) => {
state.savedQuery = action.payload;
},
setTimeRestore: (state, action: PayloadAction<boolean>) => {
state.timeRestore = action.payload;
},
setDescription: (state, action: PayloadAction<string>) => {
state.description = action.payload;
},
setViewMode: (state, action: PayloadAction<ViewMode>) => {
state.viewMode = action.payload;
},
setFiltersAndQuery: (state, action: PayloadAction<{ filters: Filter[]; query: Query }>) => {
state.filters = action.payload.filters;
state.query = action.payload.query;
},
setFilters: (state, action: PayloadAction<Filter[]>) => {
state.filters = action.payload;
},
setTags: (state, action: PayloadAction<string[]>) => {
state.tags = action.payload;
},
setTitle: (state, action: PayloadAction<string>) => {
state.description = action.payload;
},
setQuery: (state, action: PayloadAction<Query>) => {
state.query = action.payload;
},
},
});
export const {
setStateFromSaveModal,
setDashboardOptions,
setExpandedPanelId,
setHidePanelTitles,
setFiltersAndQuery,
setDashboardState,
setFullScreenMode,
setSavedQueryId,
setDescription,
setTimeRestore,
setSyncColors,
setUseMargins,
setViewMode,
setFilters,
setPanels,
setTitle,
setQuery,
setTags,
} = dashboardStateSlice.actions;

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { configureStore } from '@reduxjs/toolkit';
import { dashboardStateSlice } from './dashboard_state_slice';
export const dashboardStateStore = configureStore({
reducer: { dashboardStateReducer: dashboardStateSlice.reducer },
});
export type DashboardRootState = ReturnType<typeof dashboardStateStore.getState>;
export type DashboardDispatch = typeof dashboardStateStore.dispatch;

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export * from './dashboard_state_slice';
export { dashboardStateStore } from './dashboard_state_store';
export { useDashboardDispatch, useDashboardSelector } from './dashboard_state_hooks';

View file

@ -6,8 +6,9 @@
* Side Public License, v 1.
*/
import { DashboardContainerInput } from '../..';
import { DashboardPanelState } from '../embeddable';
import { ViewMode, EmbeddableInput } from '../../services/embeddable';
import { DashboardContainerInput, DashboardPanelState } from '../embeddable';
export function getSampleDashboardInput(
overrides?: Partial<DashboardContainerInput>

View file

@ -8,3 +8,4 @@
export { getSampleDashboardInput, getSampleDashboardPanel } from './get_sample_dashboard_input';
export { getSavedDashboardMock } from './get_saved_dashboard_mock';
export { makeDefaultServices } from './make_default_services';

View file

@ -0,0 +1,97 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { DashboardSessionStorage } from '../lib';
import { dataPluginMock } from '../../../../data/public/mocks';
import { getSavedDashboardMock } from './get_saved_dashboard_mock';
import { UrlForwardingStart } from '../../../../url_forwarding/public';
import { NavigationPublicPluginStart } from '../../services/navigation';
import { DashboardAppServices, DashboardAppCapabilities } from '../../types';
import { embeddablePluginMock } from '../../../../embeddable/public/mocks';
import { IndexPatternsContract, SavedQueryService } from '../../services/data';
import { savedObjectsPluginMock } from '../../../../saved_objects/public/mocks';
import { visualizationsPluginMock } from '../../../../visualizations/public/mocks';
import { PluginInitializerContext, ScopedHistory } from '../../../../../core/public';
import { SavedObjectLoader, SavedObjectLoaderFindOptions } from '../../services/saved_objects';
import {
chromeServiceMock,
coreMock,
uiSettingsServiceMock,
} from '../../../../../core/public/mocks';
export function makeDefaultServices(): DashboardAppServices {
const core = coreMock.createStart();
core.overlays.openConfirm = jest.fn().mockResolvedValue(true);
const savedDashboards = {} as SavedObjectLoader;
savedDashboards.find = (search: string, sizeOrOptions: number | SavedObjectLoaderFindOptions) => {
const size = typeof sizeOrOptions === 'number' ? sizeOrOptions : sizeOrOptions.size ?? 10;
const hits = [];
for (let i = 0; i < size; i++) {
hits.push({
id: `dashboard${i}`,
title: `dashboard${i} - ${search} - title`,
description: `dashboard${i} desc`,
});
}
return Promise.resolve({
total: size,
hits,
});
};
savedDashboards.get = jest
.fn()
.mockImplementation((id?: string) => Promise.resolve(getSavedDashboardMock({ id })));
const dashboardSessionStorage = ({
getDashboardIdsWithUnsavedChanges: jest
.fn()
.mockResolvedValue(['dashboardUnsavedOne', 'dashboardUnsavedTwo']),
getState: jest.fn().mockReturnValue(undefined),
setState: jest.fn(),
} as unknown) as DashboardSessionStorage;
dashboardSessionStorage.clearState = jest.fn();
const defaultCapabilities: DashboardAppCapabilities = {
show: true,
createNew: true,
saveQuery: true,
createShortUrl: true,
hideWriteControls: false,
storeSearchSession: true,
mapsCapabilities: { save: true },
visualizeCapabilities: { save: true },
};
const initializerContext = {
env: { packageInfo: { version: '8.0.0' } },
} as PluginInitializerContext;
return {
visualizations: visualizationsPluginMock.createStartContract(),
savedObjects: savedObjectsPluginMock.createStartContract(),
embeddable: embeddablePluginMock.createInstance().doStart(),
uiSettings: uiSettingsServiceMock.createStartContract(),
chrome: chromeServiceMock.createStartContract(),
navigation: {} as NavigationPublicPluginStart,
savedObjectsClient: core.savedObjects.client,
dashboardCapabilities: defaultCapabilities,
data: dataPluginMock.createStartContract(),
indexPatterns: {} as IndexPatternsContract,
savedQueryService: {} as SavedQueryService,
scopedHistory: () => ({} as ScopedHistory),
setHeaderActionMenu: (mountPoint) => {},
urlForwarding: {} as UrlForwardingStart,
allowByValueEmbeddables: true,
restorePreviousUrl: () => {},
onAppLeave: (handler) => {},
dashboardSessionStorage,
initializerContext,
savedDashboards,
core,
};
}

View file

@ -7,92 +7,94 @@
*/
import { METRIC_TYPE } from '@kbn/analytics';
import { Required } from '@kbn/utility-types';
import { EuiHorizontalRule } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import angular from 'angular';
import UseUnmount from 'react-use/lib/useUnmount';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import UseUnmount from 'react-use/lib/useUnmount';
import { UI_SETTINGS } from '../../../common';
import { BaseVisType, VisTypeAlias } from '../../../../visualizations/public';
import {
AddFromLibraryButton,
PrimaryActionButton,
QuickButtonGroup,
SolutionToolbar,
QuickButtonProps,
} from '../../../../presentation_util/public';
import { useKibana } from '../../services/kibana_react';
import { IndexPattern, SavedQuery, TimefilterContract } from '../../services/data';
import { isErrorEmbeddable, openAddPanelFlyout, ViewMode } from '../../services/embeddable';
import {
getSavedObjectFinder,
SavedObjectSaveOpts,
SaveResult,
showSaveModal,
} from '../../services/saved_objects';
import { LazyLabsFlyout, withSuspense } from '../../../../presentation_util/public';
import { NavAction } from '../../types';
import { DashboardSavedObject } from '../..';
import { DashboardStateManager } from '../dashboard_state_manager';
import { saveDashboard } from '../lib';
import {
DashboardAppServices,
DashboardEmbedSettings,
DashboardRedirect,
DashboardSaveOptions,
} from '../types';
import { getTopNavConfig } from './get_top_nav_config';
import { TopNavIds } from './top_nav_ids';
import { EditorMenu } from './editor_menu';
import { UI_SETTINGS } from '../../../common';
import { SavedQuery } from '../../services/data';
import { DashboardSaveModal } from './save_modal';
import { showCloneModal } from './show_clone_modal';
import { showOptionsPopover } from './show_options_popover';
import { TopNavIds } from './top_nav_ids';
import { ShowShareModal } from './show_share_modal';
import { confirmDiscardOrKeepUnsavedChanges } from '../listing/confirm_overlays';
import { getTopNavConfig } from './get_top_nav_config';
import { OverlayRef } from '../../../../../core/public';
import { useKibana } from '../../services/kibana_react';
import { showOptionsPopover } from './show_options_popover';
import { DashboardConstants } from '../../dashboard_constants';
import { getNewDashboardTitle, unsavedChangesBadge } from '../../dashboard_strings';
import { DASHBOARD_PANELS_UNSAVED_ID } from '../lib/dashboard_panel_storage';
import { DashboardContainer } from '..';
import { EditorMenu } from './editor_menu';
import { TopNavMenuProps } from '../../../../navigation/public';
import { confirmDiscardUnsavedChanges } from '../listing/confirm_overlays';
import { BaseVisType, VisTypeAlias } from '../../../../visualizations/public';
import { DashboardAppState, DashboardSaveOptions, NavAction } from '../../types';
import { isErrorEmbeddable, openAddPanelFlyout, ViewMode } from '../../services/embeddable';
import { DashboardAppServices, DashboardEmbedSettings, DashboardRedirect } from '../../types';
import { getSavedObjectFinder, SaveResult, showSaveModal } from '../../services/saved_objects';
import { getCreateVisualizationButtonTitle, unsavedChangesBadge } from '../../dashboard_strings';
import {
setFullScreenMode,
setHidePanelTitles,
setSavedQueryId,
setStateFromSaveModal,
setSyncColors,
setUseMargins,
setViewMode,
useDashboardDispatch,
useDashboardSelector,
} from '../state';
import {
AddFromLibraryButton,
LazyLabsFlyout,
PrimaryActionButton,
QuickButtonGroup,
QuickButtonProps,
SolutionToolbar,
withSuspense,
} from '../../../../presentation_util/public';
export interface DashboardTopNavState {
chromeIsVisible: boolean;
addPanelOverlay?: OverlayRef;
savedQuery?: SavedQuery;
isSaveInProgress?: boolean;
}
type CompleteDashboardAppState = Required<
DashboardAppState,
| 'getLatestDashboardState'
| 'dashboardContainer'
| 'savedDashboard'
| 'indexPatterns'
| 'applyFilters'
>;
export const isCompleteDashboardAppState = (
state: DashboardAppState
): state is CompleteDashboardAppState => {
return (
Boolean(state.getLatestDashboardState) &&
Boolean(state.dashboardContainer) &&
Boolean(state.savedDashboard) &&
Boolean(state.indexPatterns) &&
Boolean(state.applyFilters)
);
};
export interface DashboardTopNavProps {
onQuerySubmit: (_payload: unknown, isUpdate: boolean | undefined) => void;
dashboardStateManager: DashboardStateManager;
dashboardContainer: DashboardContainer;
dashboardAppState: CompleteDashboardAppState;
embedSettings?: DashboardEmbedSettings;
savedDashboard: DashboardSavedObject;
timefilter: TimefilterContract;
indexPatterns: IndexPattern[];
redirectTo: DashboardRedirect;
unsavedChanges: boolean;
clearUnsavedChanges: () => void;
lastDashboardId?: string;
viewMode: ViewMode;
}
const Flyout = withSuspense(LazyLabsFlyout, null);
const LabsFlyout = withSuspense(LazyLabsFlyout, null);
export function DashboardTopNav({
dashboardStateManager,
clearUnsavedChanges,
dashboardContainer,
lastDashboardId,
unsavedChanges,
savedDashboard,
onQuerySubmit,
dashboardAppState,
embedSettings,
indexPatterns,
redirectTo,
timefilter,
viewMode,
}: DashboardTopNavProps) {
const {
core,
@ -102,17 +104,24 @@ export function DashboardTopNav({
embeddable,
navigation,
uiSettings,
setHeaderActionMenu,
savedObjectsTagging,
dashboardCapabilities,
dashboardPanelStorage,
allowByValueEmbeddables,
visualizations,
usageCollection,
initializerContext,
savedObjectsTagging,
setHeaderActionMenu,
dashboardCapabilities,
dashboardSessionStorage,
allowByValueEmbeddables,
} = useKibana<DashboardAppServices>().services;
const { version: kibanaVersion } = initializerContext.env.packageInfo;
const timefilter = data.query.timefilter.timefilter;
const toasts = core.notifications.toasts;
const dispatchDashboardStateChange = useDashboardDispatch();
const dashboardState = useDashboardSelector((state) => state.dashboardStateReducer);
const [mounted, setMounted] = useState(true);
const [state, setState] = useState<DashboardTopNavState>({ chromeIsVisible: false });
const [isSaveInProgress, setIsSaveInProgress] = useState(false);
const [isLabsShown, setIsLabsShown] = useState(false);
const lensAlias = visualizations.getAliases().find(({ name }) => name === 'lens');
@ -130,25 +139,25 @@ export function DashboardTopNav({
const visibleSubscription = chrome.getIsVisible$().subscribe((chromeIsVisible) => {
setState((s) => ({ ...s, chromeIsVisible }));
});
const { id, title, getFullEditPath } = savedDashboard;
if (id || allowByValueEmbeddables) {
const { id, title, getFullEditPath } = dashboardAppState.savedDashboard;
if (id && title) {
chrome.recentlyAccessed.add(
getFullEditPath(dashboardStateManager.getIsEditMode()),
title || getNewDashboardTitle(),
id || DASHBOARD_PANELS_UNSAVED_ID
getFullEditPath(dashboardState.viewMode === ViewMode.EDIT),
title,
id
);
}
return () => {
visibleSubscription.unsubscribe();
};
}, [chrome, allowByValueEmbeddables, dashboardStateManager, savedDashboard]);
}, [chrome, allowByValueEmbeddables, dashboardState.viewMode, dashboardAppState.savedDashboard]);
const addFromLibrary = useCallback(() => {
if (!isErrorEmbeddable(dashboardContainer)) {
if (!isErrorEmbeddable(dashboardAppState.dashboardContainer)) {
setState((s) => ({
...s,
addPanelOverlay: openAddPanelFlyout({
embeddable: dashboardContainer,
embeddable: dashboardAppState.dashboardContainer,
getAllFactories: embeddable.getEmbeddableFactories,
getFactory: embeddable.getEmbeddableFactory,
notifications: core.notifications,
@ -158,9 +167,9 @@ export function DashboardTopNav({
}));
}
}, [
dashboardAppState.dashboardContainer,
embeddable.getEmbeddableFactories,
embeddable.getEmbeddableFactory,
dashboardContainer,
core.notifications,
core.savedObjects,
core.overlays,
@ -209,291 +218,220 @@ export function DashboardTopNav({
const onChangeViewMode = useCallback(
(newMode: ViewMode) => {
clearAddPanel();
const isPageRefresh = newMode === dashboardStateManager.getViewMode();
const isLeavingEditMode = !isPageRefresh && newMode === ViewMode.VIEW;
const willLoseChanges = isLeavingEditMode && dashboardStateManager.getIsDirty(timefilter);
function switchViewMode() {
dashboardStateManager.switchViewMode(newMode);
if (savedDashboard?.id && allowByValueEmbeddables) {
const { getFullEditPath, title, id } = savedDashboard;
chrome.recentlyAccessed.add(getFullEditPath(newMode === ViewMode.EDIT), title, id);
}
}
const willLoseChanges = newMode === ViewMode.VIEW && dashboardAppState.hasUnsavedChanges;
if (!willLoseChanges) {
switchViewMode();
dispatchDashboardStateChange(setViewMode(newMode));
return;
}
function discardChanges() {
dashboardStateManager.resetState();
dashboardStateManager.clearUnsavedPanels();
// We need to do a hard reset of the timepicker. appState will not reload like
// it does on 'open' because it's been saved to the url and the getAppState.previouslyStored() check on
// reload will cause it not to sync.
if (dashboardStateManager.getIsTimeSavedWithDashboard()) {
dashboardStateManager.syncTimefilterWithDashboardTime(timefilter);
dashboardStateManager.syncTimefilterWithDashboardRefreshInterval(timefilter);
}
dashboardStateManager.switchViewMode(ViewMode.VIEW);
}
confirmDiscardOrKeepUnsavedChanges(core.overlays).then((selection) => {
if (selection === 'discard') {
discardChanges();
}
if (selection !== 'cancel') {
switchViewMode();
}
});
confirmDiscardUnsavedChanges(core.overlays, () =>
dashboardAppState.resetToLastSavedState?.()
);
},
[
timefilter,
core.overlays,
clearAddPanel,
savedDashboard,
dashboardStateManager,
allowByValueEmbeddables,
chrome.recentlyAccessed,
]
[clearAddPanel, core.overlays, dashboardAppState, dispatchDashboardStateChange]
);
/**
* Saves the dashboard.
*
* @param {object} [saveOptions={}]
* @property {boolean} [saveOptions.confirmOverwrite=false] - If true, attempts to create the source so it
* can confirm an overwrite if a document with the id already exists.
* @property {boolean} [saveOptions.isTitleDuplicateConfirmed=false] - If true, save allowed with duplicate title
* @property {func} [saveOptions.onTitleDuplicate] - function called if duplicate title exists.
* When not provided, confirm modal will be displayed asking user to confirm or cancel save.
* @return {Promise}
* @resolved {String} - The id of the doc
*/
const save = useCallback(
async (saveOptions: SavedObjectSaveOpts) => {
setIsSaveInProgress(true);
return saveDashboard(angular.toJson, timefilter, dashboardStateManager, saveOptions)
.then(function (id) {
if (id) {
core.notifications.toasts.addSuccess({
title: i18n.translate('dashboard.dashboardWasSavedSuccessMessage', {
defaultMessage: `Dashboard '{dashTitle}' was saved`,
values: { dashTitle: dashboardStateManager.savedDashboard.title },
}),
'data-test-subj': 'saveDashboardSuccess',
});
dashboardPanelStorage.clearPanels(lastDashboardId);
if (id !== lastDashboardId) {
redirectTo({
id,
// editMode: true,
destination: 'dashboard',
useReplace: true,
});
} else {
dashboardStateManager.resetState();
chrome.docTitle.change(dashboardStateManager.savedDashboard.lastSavedTitle);
}
}
setIsSaveInProgress(false);
return { id };
})
.catch((error) => {
core.notifications?.toasts.addDanger({
title: i18n.translate('dashboard.dashboardWasNotSavedDangerMessage', {
defaultMessage: `Dashboard '{dashTitle}' was not saved. Error: {errorMessage}`,
values: {
dashTitle: dashboardStateManager.savedDashboard.title,
errorMessage: error.message,
},
}),
'data-test-subj': 'saveDashboardFailure',
});
return { error };
});
},
[
core.notifications.toasts,
dashboardStateManager,
dashboardPanelStorage,
lastDashboardId,
chrome.docTitle,
redirectTo,
timefilter,
]
);
const runSave = useCallback(async () => {
const currentTitle = dashboardStateManager.getTitle();
const currentDescription = dashboardStateManager.getDescription();
const currentTimeRestore = dashboardStateManager.getTimeRestore();
let currentTags: string[] = [];
if (savedObjectsTagging) {
const dashboard = dashboardStateManager.savedDashboard;
if (savedObjectsTagging.ui.hasTagDecoration(dashboard)) {
currentTags = dashboard.getTags();
}
}
const onSave = ({
const runSaveAs = useCallback(async () => {
const currentState = dashboardAppState.getLatestDashboardState();
const onSave = async ({
newTags,
newTitle,
newDescription,
newCopyOnSave,
newTimeRestore,
onTitleDuplicate,
isTitleDuplicateConfirmed,
newTags,
}: DashboardSaveOptions): Promise<SaveResult> => {
dashboardStateManager.setTitle(newTitle);
dashboardStateManager.setDescription(newDescription);
dashboardStateManager.savedDashboard.copyOnSave = newCopyOnSave;
dashboardStateManager.setTimeRestore(newTimeRestore);
if (savedObjectsTagging && newTags) {
dashboardStateManager.setTags(newTags);
}
const saveOptions = {
confirmOverwrite: false,
isTitleDuplicateConfirmed,
onTitleDuplicate,
};
const stateFromSaveModal = {
title: newTitle,
description: newDescription,
timeRestore: newTimeRestore,
tags: [] as string[],
};
if (savedObjectsTagging && newTags) {
stateFromSaveModal.tags = newTags;
}
return save(saveOptions).then((response: SaveResult) => {
// If the save wasn't successful, put the original values back.
if (!(response as { id: string }).id) {
dashboardStateManager.setTitle(currentTitle);
dashboardStateManager.setDescription(currentDescription);
dashboardStateManager.setTimeRestore(currentTimeRestore);
if (savedObjectsTagging) {
dashboardStateManager.setTags(currentTags);
}
}
return response;
dashboardAppState.savedDashboard.copyOnSave = newCopyOnSave;
const saveResult = await saveDashboard({
toasts,
timefilter,
redirectTo,
saveOptions,
savedObjectsTagging,
version: kibanaVersion,
dashboardSessionStorage,
savedDashboard: dashboardAppState.savedDashboard,
currentState: { ...currentState, ...stateFromSaveModal },
});
if (saveResult.id && !saveResult.redirected) {
dispatchDashboardStateChange(setStateFromSaveModal(stateFromSaveModal));
dashboardAppState.updateLastSavedState?.();
chrome.docTitle.change(stateFromSaveModal.title);
}
return saveResult.id ? { id: saveResult.id } : { error: saveResult.error };
};
const lastDashboardId = dashboardAppState.savedDashboard.id;
const currentTags = savedObjectsTagging?.ui.hasTagDecoration(dashboardAppState.savedDashboard)
? dashboardAppState.savedDashboard.getTags()
: [];
const dashboardSaveModal = (
<DashboardSaveModal
onSave={onSave}
onClose={() => {}}
title={currentTitle}
description={currentDescription}
tags={currentTags}
title={currentState.title}
timeRestore={currentState.timeRestore}
description={currentState.description}
savedObjectsTagging={savedObjectsTagging}
timeRestore={currentTimeRestore}
showCopyOnSave={lastDashboardId ? true : false}
/>
);
clearAddPanel();
showSaveModal(dashboardSaveModal, core.i18n.Context);
}, [
save,
clearAddPanel,
lastDashboardId,
core.i18n.Context,
dispatchDashboardStateChange,
dashboardSessionStorage,
savedObjectsTagging,
dashboardStateManager,
dashboardAppState,
core.i18n.Context,
chrome.docTitle,
clearAddPanel,
kibanaVersion,
timefilter,
redirectTo,
toasts,
]);
const runQuickSave = useCallback(async () => {
const currentTitle = dashboardStateManager.getTitle();
const currentDescription = dashboardStateManager.getDescription();
const currentTimeRestore = dashboardStateManager.getTimeRestore();
let currentTags: string[] = [];
if (savedObjectsTagging) {
const dashboard = dashboardStateManager.savedDashboard;
if (savedObjectsTagging.ui.hasTagDecoration(dashboard)) {
currentTags = dashboard.getTags();
}
}
setIsSaveInProgress(true);
save({}).then((response: SaveResult) => {
// If the save wasn't successful, put the original values back.
if (!(response as { id: string }).id) {
dashboardStateManager.setTitle(currentTitle);
dashboardStateManager.setDescription(currentDescription);
dashboardStateManager.setTimeRestore(currentTimeRestore);
if (savedObjectsTagging) {
dashboardStateManager.setTags(currentTags);
}
} else {
clearUnsavedChanges();
}
setIsSaveInProgress(false);
return response;
setState((s) => ({ ...s, isSaveInProgress: true }));
const currentState = dashboardAppState.getLatestDashboardState();
const saveResult = await saveDashboard({
toasts,
timefilter,
redirectTo,
currentState,
saveOptions: {},
savedObjectsTagging,
version: kibanaVersion,
dashboardSessionStorage,
savedDashboard: dashboardAppState.savedDashboard,
});
}, [save, savedObjectsTagging, dashboardStateManager, clearUnsavedChanges]);
if (saveResult.id && !saveResult.redirected) {
dashboardAppState.updateLastSavedState?.();
}
// turn off save in progress after the next change check. This prevents the save button from flashing
setTimeout(() => {
if (!mounted) return;
setState((s) => ({ ...s, isSaveInProgress: false }));
}, DashboardConstants.CHANGE_CHECK_DEBOUNCE);
}, [
dashboardSessionStorage,
savedObjectsTagging,
dashboardAppState,
kibanaVersion,
timefilter,
redirectTo,
mounted,
toasts,
]);
const runClone = useCallback(() => {
const currentTitle = dashboardStateManager.getTitle();
const currentState = dashboardAppState.getLatestDashboardState();
const onClone = async (
newTitle: string,
isTitleDuplicateConfirmed: boolean,
onTitleDuplicate: () => void
) => {
dashboardStateManager.savedDashboard.copyOnSave = true;
dashboardStateManager.setTitle(newTitle);
dashboardAppState.savedDashboard.copyOnSave = true;
const saveOptions = {
confirmOverwrite: false,
isTitleDuplicateConfirmed,
onTitleDuplicate,
};
return save(saveOptions).then((response: { id?: string } | { error: Error }) => {
// If the save wasn't successful, put the original title back.
if ((response as { error: Error }).error) {
dashboardStateManager.setTitle(currentTitle);
}
return response;
const saveResult = await saveDashboard({
toasts,
timefilter,
redirectTo,
saveOptions,
savedObjectsTagging,
version: kibanaVersion,
dashboardSessionStorage,
savedDashboard: dashboardAppState.savedDashboard,
currentState: { ...currentState, title: newTitle },
});
return saveResult.id ? { id: saveResult.id } : { error: saveResult.error };
};
showCloneModal(onClone, currentState.title);
}, [
dashboardSessionStorage,
savedObjectsTagging,
dashboardAppState,
kibanaVersion,
redirectTo,
timefilter,
toasts,
]);
showCloneModal(onClone, currentTitle);
}, [dashboardStateManager, save]);
const showOptions = useCallback(
(anchorElement: HTMLElement) => {
const currentState = dashboardAppState.getLatestDashboardState();
showOptionsPopover({
anchorElement,
useMargins: currentState.options.useMargins,
onUseMarginsChange: (isChecked: boolean) => {
dispatchDashboardStateChange(setUseMargins(isChecked));
},
syncColors: Boolean(currentState.options.syncColors),
onSyncColorsChange: (isChecked: boolean) => {
dispatchDashboardStateChange(setSyncColors(isChecked));
},
hidePanelTitles: currentState.options.hidePanelTitles,
onHidePanelTitlesChange: (isChecked: boolean) => {
dispatchDashboardStateChange(setHidePanelTitles(isChecked));
},
});
},
[dashboardAppState, dispatchDashboardStateChange]
);
const showShare = useCallback(
(anchorElement: HTMLElement) => {
if (!share) return;
const currentState = dashboardAppState.getLatestDashboardState();
ShowShareModal({
share,
kibanaVersion,
anchorElement,
dashboardCapabilities,
currentDashboardState: currentState,
savedDashboard: dashboardAppState.savedDashboard,
isDirty: Boolean(dashboardAppState.hasUnsavedChanges),
});
},
[dashboardAppState, dashboardCapabilities, share, kibanaVersion]
);
const dashboardTopNavActions = useMemo(() => {
const actions = {
[TopNavIds.FULL_SCREEN]: () => {
dashboardStateManager.setFullScreenMode(true);
},
[TopNavIds.FULL_SCREEN]: () => dispatchDashboardStateChange(setFullScreenMode(true)),
[TopNavIds.EXIT_EDIT_MODE]: () => onChangeViewMode(ViewMode.VIEW),
[TopNavIds.ENTER_EDIT_MODE]: () => onChangeViewMode(ViewMode.EDIT),
[TopNavIds.SAVE]: runSave,
[TopNavIds.QUICK_SAVE]: runQuickSave,
[TopNavIds.OPTIONS]: showOptions,
[TopNavIds.SAVE]: runSaveAs,
[TopNavIds.CLONE]: runClone,
[TopNavIds.OPTIONS]: (anchorElement) => {
showOptionsPopover({
anchorElement,
useMargins: dashboardStateManager.getUseMargins(),
onUseMarginsChange: (isChecked: boolean) => {
dashboardStateManager.setUseMargins(isChecked);
},
syncColors: dashboardStateManager.getSyncColors(),
onSyncColorsChange: (isChecked: boolean) => {
dashboardStateManager.setSyncColors(isChecked);
},
hidePanelTitles: dashboardStateManager.getHidePanelTitles(),
onHidePanelTitlesChange: (isChecked: boolean) => {
dashboardStateManager.setHidePanelTitles(isChecked);
},
});
},
} as { [key: string]: NavAction };
if (share) {
actions[TopNavIds.SHARE] = (anchorElement) =>
ShowShareModal({
share,
anchorElement,
savedDashboard,
dashboardStateManager,
dashboardCapabilities,
});
actions[TopNavIds.SHARE] = showShare;
}
if (isLabsEnabled) {
@ -503,13 +441,13 @@ export function DashboardTopNav({
}
return actions;
}, [
dashboardCapabilities,
dashboardStateManager,
dispatchDashboardStateChange,
onChangeViewMode,
savedDashboard,
runClone,
runSave,
runQuickSave,
showOptions,
runSaveAs,
showShare,
runClone,
share,
isLabsEnabled,
isLabsShown,
@ -517,43 +455,49 @@ export function DashboardTopNav({
UseUnmount(() => {
clearAddPanel();
setMounted(false);
});
const getNavBarProps = () => {
const getNavBarProps = (): TopNavMenuProps => {
const { hasUnsavedChanges, savedDashboard } = dashboardAppState;
const shouldShowNavBarComponent = (forceShow: boolean): boolean =>
(forceShow || state.chromeIsVisible) && !dashboardStateManager.getFullScreenMode();
(forceShow || state.chromeIsVisible) && !dashboardState.fullScreenMode;
const shouldShowFilterBar = (forceHide: boolean): boolean =>
!forceHide &&
(data.query.filterManager.getFilters().length > 0 ||
!dashboardStateManager.getFullScreenMode());
(data.query.filterManager.getFilters().length > 0 || !dashboardState.fullScreenMode);
const isFullScreenMode = dashboardStateManager.getFullScreenMode();
const screenTitle = dashboardStateManager.getTitle();
const isFullScreenMode = dashboardState.fullScreenMode;
const showTopNavMenu = shouldShowNavBarComponent(Boolean(embedSettings?.forceShowTopNavMenu));
const showQueryInput = shouldShowNavBarComponent(Boolean(embedSettings?.forceShowQueryInput));
const showDatePicker = shouldShowNavBarComponent(Boolean(embedSettings?.forceShowDatePicker));
const showQueryBar = showQueryInput || showDatePicker;
const showFilterBar = shouldShowFilterBar(Boolean(embedSettings?.forceHideFilterBar));
const showQueryBar = showQueryInput || showDatePicker;
const showSearchBar = showQueryBar || showFilterBar;
const screenTitle = dashboardState.title;
const topNav = getTopNavConfig(viewMode, dashboardTopNavActions, {
hideWriteControls: dashboardCapabilities.hideWriteControls,
isNewDashboard: !savedDashboard.id,
isDirty: dashboardStateManager.getIsDirty(timefilter),
isSaveInProgress,
isLabsEnabled,
});
const topNav = getTopNavConfig(
dashboardAppState.getLatestDashboardState().viewMode,
dashboardTopNavActions,
{
hideWriteControls: dashboardCapabilities.hideWriteControls,
isDirty: Boolean(dashboardAppState.hasUnsavedChanges),
isSaveInProgress: state.isSaveInProgress,
isNewDashboard: !savedDashboard.id,
isLabsEnabled,
}
);
const badges = unsavedChanges
? [
{
'data-test-subj': 'dashboardUnsavedChangesBadge',
badgeText: unsavedChangesBadge.getUnsavedChangedBadgeText(),
color: 'secondary',
},
]
: undefined;
const badges =
hasUnsavedChanges && dashboardState.viewMode === ViewMode.EDIT
? [
{
'data-test-subj': 'dashboardUnsavedChangesBadge',
badgeText: unsavedChangesBadge.getUnsavedChangedBadgeText(),
color: 'secondary',
},
]
: undefined;
return {
badges,
@ -561,36 +505,25 @@ export function DashboardTopNav({
config: showTopNavMenu ? topNav : undefined,
className: isFullScreenMode ? 'kbnTopNavMenu-isFullScreen' : undefined,
screenTitle,
showTopNavMenu,
showSearchBar,
showQueryBar,
showQueryInput,
showDatePicker,
showFilterBar,
setMenuMountPoint: embedSettings ? undefined : setHeaderActionMenu,
indexPatterns,
indexPatterns: dashboardAppState.indexPatterns,
showSaveQuery: dashboardCapabilities.saveQuery,
useDefaultBehaviors: true,
onQuerySubmit,
onSavedQueryUpdated: (savedQuery: SavedQuery) => {
const allFilters = data.query.filterManager.getFilters();
data.query.filterManager.setFilters(allFilters);
dashboardStateManager.applyFilters(savedQuery.attributes.query, allFilters);
if (savedQuery.attributes.timefilter) {
timefilter.setTime({
from: savedQuery.attributes.timefilter.from,
to: savedQuery.attributes.timefilter.to,
});
if (savedQuery.attributes.timefilter.refreshInterval) {
timefilter.setRefreshInterval(savedQuery.attributes.timefilter.refreshInterval);
}
}
setState((s) => ({ ...s, savedQuery }));
},
savedQuery: state.savedQuery,
savedQueryId: dashboardStateManager.getSavedQueryId(),
onSavedQueryIdChange: (newId: string | undefined) =>
dashboardStateManager.setSavedQueryId(newId),
savedQueryId: dashboardState.savedQuery,
onQuerySubmit: (_payload, isUpdate) => {
if (isUpdate === false) {
dashboardAppState.$triggerDashboardRefresh.next({ force: true });
}
},
onSavedQueryIdChange: (newId: string | undefined) => {
dispatchDashboardStateChange(setSavedQueryId(newId));
},
};
};
@ -636,9 +569,9 @@ export function DashboardTopNav({
<>
<TopNavMenu {...getNavBarProps()} />
{isLabsEnabled && isLabsShown ? (
<Flyout solutions={['dashboard']} onClose={() => setIsLabsShown(false)} />
<LabsFlyout solutions={['dashboard']} onClose={() => setIsLabsShown(false)} />
) : null}
{viewMode !== ViewMode.VIEW ? (
{dashboardState.viewMode !== ViewMode.VIEW ? (
<>
<EuiHorizontalRule margin="none" />
<SolutionToolbar isDarkModeEnabled={IS_DARK_THEME}>
@ -646,9 +579,7 @@ export function DashboardTopNav({
primaryActionButton: (
<PrimaryActionButton
isDarkModeEnabled={IS_DARK_THEME}
label={i18n.translate('dashboard.solutionToolbar.addPanelButtonLabel', {
defaultMessage: 'Create visualization',
})}
label={getCreateVisualizationButtonTitle()}
onClick={createNewVisType(lensAlias)}
iconType="lensApp"
data-test-subj="dashboardAddNewPanelButton"
@ -664,7 +595,7 @@ export function DashboardTopNav({
extraButtons: [
<EditorMenu
createNewVisType={createNewVisType}
dashboardContainer={dashboardContainer}
dashboardContainer={dashboardAppState.dashboardContainer}
/>,
],
}}

View file

@ -18,7 +18,7 @@ import { BaseVisType, VisGroups, VisTypeAlias } from '../../../../visualizations
import { SolutionToolbarPopover } from '../../../../presentation_util/public';
import { EmbeddableFactoryDefinition, EmbeddableInput } from '../../services/embeddable';
import { useKibana } from '../../services/kibana_react';
import { DashboardAppServices } from '../types';
import { DashboardAppServices } from '../../types';
import { DashboardContainer } from '..';
import { DashboardConstants } from '../../dashboard_constants';
import { dashboardReplacePanelAction } from '../../dashboard_strings';

View file

@ -12,7 +12,7 @@ import { EuiFormRow, EuiTextArea, EuiSwitch } from '@elastic/eui';
import type { SavedObjectsTaggingApi } from '../../services/saved_objects_tagging_oss';
import { SavedObjectSaveModal } from '../../services/saved_objects';
import { DashboardSaveOptions } from '../types';
import { DashboardSaveOptions } from '../../types';
interface Props {
onSave: ({

View file

@ -14,18 +14,20 @@ import { DashboardSavedObject } from '../..';
import { setStateToKbnUrl, unhashUrl } from '../../services/kibana_utils';
import { SharePluginStart } from '../../services/share';
import { dashboardUrlParams } from '../dashboard_router';
import { DashboardStateManager } from '../dashboard_state_manager';
import { shareModalStrings } from '../../dashboard_strings';
import { DashboardAppCapabilities } from '../types';
import { DashboardAppCapabilities, DashboardState } from '../../types';
import { stateToRawDashboardState } from '../lib/convert_dashboard_state';
const showFilterBarId = 'showFilterBar';
interface ShowShareModalProps {
isDirty: boolean;
kibanaVersion: string;
share: SharePluginStart;
anchorElement: HTMLElement;
savedDashboard: DashboardSavedObject;
currentDashboardState: DashboardState;
dashboardCapabilities: DashboardAppCapabilities;
dashboardStateManager: DashboardStateManager;
}
export const showPublicUrlSwitch = (anonymousUserCapabilities: Capabilities) => {
@ -38,10 +40,12 @@ export const showPublicUrlSwitch = (anonymousUserCapabilities: Capabilities) =>
export function ShowShareModal({
share,
isDirty,
kibanaVersion,
anchorElement,
savedDashboard,
dashboardCapabilities,
dashboardStateManager,
currentDashboardState,
}: ShowShareModalProps) {
const EmbedUrlParamExtension = ({
setParamValue,
@ -101,12 +105,13 @@ export function ShowShareModal({
};
share.toggleShareContextMenu({
isDirty,
anchorElement,
allowEmbed: true,
allowShortUrl: dashboardCapabilities.createShortUrl,
shareableUrl: setStateToKbnUrl(
'_a',
dashboardStateManager.getAppState(),
stateToRawDashboardState({ state: currentDashboardState, version: kibanaVersion }),
{ useHash: false, storeInHashQuery: true },
unhashUrl(window.location.href)
),
@ -115,7 +120,6 @@ export function ShowShareModal({
sharingData: {
title: savedDashboard.title,
},
isDirty: dashboardStateManager.getIsDirty(),
embedUrlParamExtensions: [
{
paramName: 'embed',

View file

@ -1,88 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
AppMountParameters,
CoreStart,
SavedObjectsClientContract,
ScopedHistory,
ChromeStart,
IUiSettingsClient,
PluginInitializerContext,
} from 'kibana/public';
import { SharePluginStart } from '../services/share';
import { EmbeddableStart } from '../services/embeddable';
import { UsageCollectionSetup } from '../services/usage_collection';
import { NavigationPublicPluginStart } from '../services/navigation';
import { SavedObjectsTaggingApi } from '../services/saved_objects_tagging_oss';
import { DataPublicPluginStart, IndexPatternsContract } from '../services/data';
import { SavedObjectLoader, SavedObjectsStart } from '../services/saved_objects';
import { DashboardPanelStorage } from './lib';
import { UrlForwardingStart } from '../../../url_forwarding/public';
import { VisualizationsStart } from '../../../visualizations/public';
export type DashboardRedirect = (props: RedirectToProps) => void;
export type RedirectToProps =
| { destination: 'dashboard'; id?: string; useReplace?: boolean; editMode?: boolean }
| { destination: 'listing'; filter?: string; useReplace?: boolean };
export interface DashboardEmbedSettings {
forceShowTopNavMenu?: boolean;
forceShowQueryInput?: boolean;
forceShowDatePicker?: boolean;
forceHideFilterBar?: boolean;
}
export interface DashboardSaveOptions {
newTitle: string;
newTags?: string[];
newDescription: string;
newCopyOnSave: boolean;
newTimeRestore: boolean;
onTitleDuplicate: () => void;
isTitleDuplicateConfirmed: boolean;
}
export interface DashboardAppCapabilities {
visualizeCapabilities: { save: boolean };
mapsCapabilities: { save: boolean };
hideWriteControls: boolean;
createShortUrl: boolean;
saveQuery: boolean;
createNew: boolean;
show: boolean;
storeSearchSession: boolean;
}
export interface DashboardAppServices {
core: CoreStart;
chrome: ChromeStart;
share?: SharePluginStart;
embeddable: EmbeddableStart;
data: DataPublicPluginStart;
uiSettings: IUiSettingsClient;
restorePreviousUrl: () => void;
savedObjects: SavedObjectsStart;
allowByValueEmbeddables: boolean;
urlForwarding: UrlForwardingStart;
savedDashboards: SavedObjectLoader;
scopedHistory: () => ScopedHistory;
indexPatterns: IndexPatternsContract;
usageCollection?: UsageCollectionSetup;
navigation: NavigationPublicPluginStart;
dashboardPanelStorage: DashboardPanelStorage;
dashboardCapabilities: DashboardAppCapabilities;
initializerContext: PluginInitializerContext;
onAppLeave: AppMountParameters['onAppLeave'];
savedObjectsTagging?: SavedObjectsTaggingApi;
savedObjectsClient: SavedObjectsClientContract;
setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'];
savedQueryService: DataPublicPluginStart['query']['savedQueries'];
visualizations: VisualizationsStart;
}

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
const DASHBOARD_STATE_STORAGE_KEY = '_a';
export const DASHBOARD_STATE_STORAGE_KEY = '_a';
export const DashboardConstants = {
LANDING_PAGE_PATH: '/list',
@ -17,6 +17,8 @@ export const DashboardConstants = {
DASHBOARDS_ID: 'dashboards',
DASHBOARD_ID: 'dashboard',
SEARCH_SESSION_ID: 'searchSessionId',
CHANGE_CHECK_DEBOUNCE: 100,
CHANGE_APPLY_DEBOUNCE: 50,
};
export function createDashboardEditUrl(id?: string, editMode?: boolean) {

View file

@ -215,6 +215,22 @@ export const dashboardReadonlyBadge = {
}),
};
export const dashboardSaveToastStrings = {
getSuccessString: (dashTitle: string) =>
i18n.translate('dashboard.dashboardWasSavedSuccessMessage', {
defaultMessage: `Dashboard '{dashTitle}' was saved`,
values: { dashTitle },
}),
getFailureString: (dashTitle: string, errorMessage: string) =>
i18n.translate('dashboard.dashboardWasNotSavedDangerMessage', {
defaultMessage: `Dashboard '{dashTitle}' was not saved. Error: {errorMessage}`,
values: {
dashTitle,
errorMessage,
},
}),
};
/*
Modals
*/
@ -359,17 +375,9 @@ export const emptyScreenStrings = {
i18n.translate('dashboard.fillDashboardTitle', {
defaultMessage: 'This dashboard is empty. Let\u2019s fill it up!',
}),
getHowToStartWorkingOnNewDashboardDescription1: () =>
i18n.translate('dashboard.howToStartWorkingOnNewDashboardDescription1', {
defaultMessage: 'Click',
}),
getHowToStartWorkingOnNewDashboardDescription2: () =>
i18n.translate('dashboard.howToStartWorkingOnNewDashboardDescription2', {
defaultMessage: 'in the menu bar above to start adding panels.',
}),
getHowToStartWorkingOnNewDashboardEditLinkText: () =>
i18n.translate('dashboard.howToStartWorkingOnNewDashboardEditLinkText', {
defaultMessage: 'Edit',
getHowToStartWorkingOnNewDashboardDescription: () =>
i18n.translate('dashboard.howToStartWorkingOnNewDashboardDescription', {
defaultMessage: 'Click edit in the menu bar above to start adding panels.',
}),
getHowToStartWorkingOnNewDashboardEditLinkAriaLabel: () =>
i18n.translate('dashboard.howToStartWorkingOnNewDashboardEditLinkAriaLabel', {
@ -442,3 +450,8 @@ export const dashboardUnsavedListingStrings = {
defaultMessage: 'Discard changes',
}),
};
export const getCreateVisualizationButtonTitle = () =>
i18n.translate('dashboard.solutionToolbar.addPanelButtonLabel', {
defaultMessage: 'Create visualization',
});

View file

@ -11,7 +11,6 @@ import { DashboardPlugin } from './plugin';
export {
DashboardContainer,
DashboardContainerInput,
DashboardContainerFactoryDefinition,
DASHBOARD_CONTAINER_TYPE,
} from './application';
@ -29,7 +28,7 @@ export {
DashboardUrlGeneratorState,
} from './url_generator';
export { DashboardSavedObject } from './saved_dashboards';
export { SavedDashboardPanel } from './types';
export { SavedDashboardPanel, DashboardContainerInput } from './types';
export function plugin(initializerContext: PluginInitializerContext) {
return new DashboardPlugin(initializerContext);

View file

@ -14,6 +14,7 @@ import { createDashboardEditUrl } from '../dashboard_constants';
import { extractReferences, injectReferences } from '../../common/saved_dashboard_references';
import { SavedObjectAttributes, SavedObjectReference } from '../../../../core/types';
import { DashboardOptions } from '../types';
export interface DashboardSavedObject extends SavedObject {
id?: string;
@ -97,9 +98,10 @@ export function createSavedDashboardClass(
panelsJSON: '[]',
optionsJSON: JSON.stringify({
// for BWC reasons we can't default dashboards that already exist without this setting to true.
useMargins: !id,
useMargins: true,
syncColors: false,
hidePanelTitles: false,
}),
} as DashboardOptions),
version: 1,
timeRestore: false,
timeTo: undefined,

View file

@ -6,109 +6,195 @@
* Side Public License, v 1.
*/
import { SavedObject as SavedObjectType, SavedObjectAttributes } from 'src/core/public';
import { Query, Filter } from './services/data';
import { ViewMode } from './services/embeddable';
import {
AppMountParameters,
CoreStart,
SavedObjectsClientContract,
ScopedHistory,
ChromeStart,
IUiSettingsClient,
PluginInitializerContext,
} from 'kibana/public';
import { History } from 'history';
import { AnyAction, Dispatch } from 'redux';
import { BehaviorSubject, Subject } from 'rxjs';
import { Query, Filter, IndexPattern, RefreshInterval, TimeRange } from './services/data';
import { ContainerInput, EmbeddableInput, ViewMode } from './services/embeddable';
import { SharePluginStart } from './services/share';
import { EmbeddableStart } from './services/embeddable';
import { DashboardSessionStorage } from './application/lib';
import { UrlForwardingStart } from '../../url_forwarding/public';
import { UsageCollectionSetup } from './services/usage_collection';
import { NavigationPublicPluginStart } from './services/navigation';
import { DashboardPanelState, SavedDashboardPanel } from '../common/types';
import { SavedObjectsTaggingApi } from './services/saved_objects_tagging_oss';
import { DataPublicPluginStart, IndexPatternsContract } from './services/data';
import { SavedObjectLoader, SavedObjectsStart } from './services/saved_objects';
import { IKbnUrlStateStorage } from './services/kibana_utils';
import { DashboardContainer, DashboardSavedObject } from '.';
import { VisualizationsStart } from '../../visualizations/public';
import { SavedDashboardPanel } from '../common/types';
export { SavedDashboardPanel };
// TODO: Replace Saved object interfaces by the ones Core will provide when it is ready.
export type SavedObjectAttribute =
| string
| number
| boolean
| null
| undefined
| SavedObjectAttributes
| SavedObjectAttributes[];
export interface SimpleSavedObject<T extends SavedObjectAttributes> {
attributes: T;
_version?: SavedObjectType<T>['version'];
id: SavedObjectType<T>['id'];
type: SavedObjectType<T>['type'];
migrationVersion: SavedObjectType<T>['migrationVersion'];
error: SavedObjectType<T>['error'];
references: SavedObjectType<T>['references'];
get(key: string): any;
set(key: string, value: any): T;
has(key: string): boolean;
save(): Promise<SimpleSavedObject<T>>;
delete(): void;
}
interface FieldSubType {
multi?: { parent: string };
nested?: { path: string };
}
export interface Field {
name: string;
type: string;
// esTypes might be undefined on old index patterns that have not been refreshed since we added
// this prop. It is also undefined on scripted fields.
esTypes?: string[];
aggregatable: boolean;
filterable: boolean;
searchable: boolean;
subType?: FieldSubType;
}
export type NavAction = (anchorElement?: any) => void;
export interface DashboardAppState {
panels: SavedDashboardPanel[];
fullScreenMode: boolean;
title: string;
description: string;
tags: string[];
timeRestore: boolean;
options: {
hidePanelTitles: boolean;
useMargins: boolean;
syncColors?: boolean;
};
query: Query | string;
filters: Filter[];
viewMode: ViewMode;
expandedPanelId?: string;
savedQuery?: string;
}
export type DashboardAppStateDefaults = DashboardAppState & {
description?: string;
};
/**
* Panels are not added to the URL
*/
export type DashboardAppStateInUrl = Omit<DashboardAppState, 'panels'> & {
panels?: SavedDashboardPanel[];
};
export interface DashboardAppStateTransitions {
set: (
state: DashboardAppState
) => <T extends keyof DashboardAppState>(
prop: T,
value: DashboardAppState[T]
) => DashboardAppState;
setOption: (
state: DashboardAppState
) => <T extends keyof DashboardAppState['options']>(
prop: T,
value: DashboardAppState['options'][T]
) => DashboardAppState;
}
export interface SavedDashboardPanelMap {
[key: string]: SavedDashboardPanel;
}
export interface StagedFilter {
field: string;
value: string;
operator: string;
index: string;
export interface DashboardPanelMap {
[key: string]: DashboardPanelState;
}
/**
* DashboardState contains all pieces of tracked state for an individual dashboard
*/
export interface DashboardState {
query: Query;
title: string;
tags: string[];
filters: Filter[];
viewMode: ViewMode;
description: string;
savedQuery?: string;
timeRestore: boolean;
fullScreenMode: boolean;
expandedPanelId?: string;
options: DashboardOptions;
panels: DashboardPanelMap;
}
/**
* RawDashboardState is the dashboard state as directly loaded from the panelJSON
*/
export type RawDashboardState = Omit<DashboardState, 'panels'> & { panels: SavedDashboardPanel[] };
export interface DashboardContainerInput extends ContainerInput {
dashboardCapabilities?: DashboardAppCapabilities;
refreshConfig?: RefreshInterval;
isEmbeddedExternally?: boolean;
isFullScreenMode: boolean;
expandedPanelId?: string;
timeRange: TimeRange;
description?: string;
useMargins: boolean;
syncColors?: boolean;
viewMode: ViewMode;
filters: Filter[];
title: string;
query: Query;
panels: {
[panelId: string]: DashboardPanelState<EmbeddableInput & { [k: string]: unknown }>;
};
}
/**
* DashboardAppState contains all the tools the dashboard application uses to track,
* update, and view its state.
*/
export interface DashboardAppState {
hasUnsavedChanges?: boolean;
indexPatterns?: IndexPattern[];
updateLastSavedState?: () => void;
resetToLastSavedState?: () => void;
savedDashboard?: DashboardSavedObject;
dashboardContainer?: DashboardContainer;
getLatestDashboardState?: () => DashboardState;
$triggerDashboardRefresh: Subject<{ force?: boolean }>;
$onDashboardStateChange: BehaviorSubject<DashboardState>;
applyFilters?: (query: Query, filters: Filter[]) => void;
}
/**
* The shared services and tools used to build a dashboard from a saved object ID.
*/
export type DashboardBuildContext = Pick<
DashboardAppServices,
| 'embeddable'
| 'indexPatterns'
| 'savedDashboards'
| 'usageCollection'
| 'initializerContext'
| 'savedObjectsTagging'
| 'dashboardCapabilities'
> & {
query: DashboardAppServices['data']['query'];
search: DashboardAppServices['data']['search'];
notifications: DashboardAppServices['core']['notifications'];
history: History;
kibanaVersion: string;
isEmbeddedExternally: boolean;
kbnUrlStateStorage: IKbnUrlStateStorage;
$checkForUnsavedChanges: Subject<unknown>;
getLatestDashboardState: () => DashboardState;
dispatchDashboardStateChange: Dispatch<AnyAction>;
$triggerDashboardRefresh: Subject<{ force?: boolean }>;
$onDashboardStateChange: BehaviorSubject<DashboardState>;
};
export interface DashboardOptions {
hidePanelTitles: boolean;
useMargins: boolean;
syncColors: boolean;
}
export type DashboardRedirect = (props: RedirectToProps) => void;
export type RedirectToProps =
| { destination: 'dashboard'; id?: string; useReplace?: boolean; editMode?: boolean }
| { destination: 'listing'; filter?: string; useReplace?: boolean };
export interface DashboardEmbedSettings {
forceHideFilterBar?: boolean;
forceShowTopNavMenu?: boolean;
forceShowQueryInput?: boolean;
forceShowDatePicker?: boolean;
}
export interface DashboardSaveOptions {
newTitle: string;
newTags?: string[];
newDescription: string;
newCopyOnSave: boolean;
newTimeRestore: boolean;
onTitleDuplicate: () => void;
isTitleDuplicateConfirmed: boolean;
}
export interface DashboardAppCapabilities {
show: boolean;
createNew: boolean;
saveQuery: boolean;
createShortUrl: boolean;
hideWriteControls: boolean;
storeSearchSession: boolean;
mapsCapabilities: { save: boolean };
visualizeCapabilities: { save: boolean };
}
export interface DashboardAppServices {
core: CoreStart;
chrome: ChromeStart;
share?: SharePluginStart;
embeddable: EmbeddableStart;
data: DataPublicPluginStart;
uiSettings: IUiSettingsClient;
restorePreviousUrl: () => void;
savedObjects: SavedObjectsStart;
allowByValueEmbeddables: boolean;
urlForwarding: UrlForwardingStart;
savedDashboards: SavedObjectLoader;
scopedHistory: () => ScopedHistory;
visualizations: VisualizationsStart;
indexPatterns: IndexPatternsContract;
usageCollection?: UsageCollectionSetup;
navigation: NavigationPublicPluginStart;
dashboardCapabilities: DashboardAppCapabilities;
initializerContext: PluginInitializerContext;
onAppLeave: AppMountParameters['onAppLeave'];
savedObjectsTagging?: SavedObjectsTaggingApi;
savedObjectsClient: SavedObjectsClientContract;
dashboardSessionStorage: DashboardSessionStorage;
setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'];
savedQueryService: DataPublicPluginStart['query']['savedQueries'];
}

View file

@ -8,7 +8,7 @@
import React from 'react';
import { i18n } from '@kbn/i18n';
import { get } from 'lodash';
import { get, omit } from 'lodash';
import { I18nStart, NotificationsStart } from 'src/core/public';
import { SavedObjectSaveModal, OnSaveProps, SaveResult } from '../../../../saved_objects/public';
import {
@ -150,12 +150,10 @@ export class AttributeService<
const wrappedInput = (await this.wrapAttributes(newAttributes, true)) as RefType;
// Remove unneeded attributes from the original input.
delete (input as { [ATTRIBUTE_SERVICE_KEY]?: SavedObjectAttributes })[
ATTRIBUTE_SERVICE_KEY
];
const newInput = omit(input, ATTRIBUTE_SERVICE_KEY);
// Combine input and wrapped input to preserve any passed in explicit Input.
resolve({ ...input, ...wrappedInput });
resolve({ ...newInput, ...wrappedInput });
return { id: wrappedInput.savedObjectId };
} catch (error) {
reject(error);

View file

@ -110,13 +110,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
it('Exit out of edit mode', async () => {
await PageObjects.dashboard.clickDiscardChanges(false);
await PageObjects.dashboard.clickCancelOutOfEditMode(false);
await a11y.testAppSnapshot();
});
it('Discard changes', async () => {
await testSubjects.exists('dashboardDiscardConfirmDiscard');
await testSubjects.click('dashboardDiscardConfirmDiscard');
await PageObjects.common.clickConfirmOnModal();
await PageObjects.dashboard.getIsInViewMode();
await a11y.testAppSnapshot();
});

View file

@ -34,6 +34,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const dashboardPanelActions = getService('dashboardPanelActions');
const dashboardAddPanel = getService('dashboardAddPanel');
const enableNewChartLibraryDebug = async () => {
if (await PageObjects.visChart.isNewChartsLibraryEnabled()) {
await elasticChart.setNewChartUiDebugFlag();
await queryBar.submitQuery();
}
};
describe('dashboard state', function describeIndexTests() {
// Used to track flag before and after reset
let isNewChartsLibraryEnabled = false;
@ -84,10 +91,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.dashboard.gotoDashboardLandingPage();
await PageObjects.dashboard.loadSavedDashboard(dashboarName);
if (await PageObjects.visChart.isNewChartsLibraryEnabled()) {
await elasticChart.setNewChartUiDebugFlag();
await queryBar.submitQuery();
}
await enableNewChartLibraryDebug();
const colorChoiceRetained = await PageObjects.visChart.doesSelectedLegendColorExist(
overwriteColor
@ -149,11 +153,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('Saved search will update when the query is changed in the URL', async () => {
const currentQuery = await queryBar.getQueryString();
expect(currentQuery).to.equal('');
const currentUrl = await browser.getCurrentUrl();
const newUrl = currentUrl.replace('query:%27%27', 'query:%27abc12345678910%27');
// Don't add the timestamp to the url or it will cause a hard refresh and we want to test a
// soft refresh.
await browser.get(newUrl.toString(), false);
const currentUrl = await getUrlFromShare();
const newUrl = currentUrl.replace(`query:''`, `query:'abc12345678910'`);
// We need to add a timestamp to the URL because URL changes now only work with a hard refresh.
await browser.get(newUrl.toString());
await PageObjects.header.waitUntilLoadingHasFinished();
const headers = await PageObjects.discover.getColumnHeaders();
@ -200,20 +204,34 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
return sharedUrl;
};
const hardRefresh = async (newUrl: string) => {
// We need to add a timestamp to the URL because URL changes now only work with a hard refresh.
await browser.get(newUrl.toString());
const alert = await browser.getAlert();
await alert?.accept();
await enableNewChartLibraryDebug();
await PageObjects.dashboard.waitForRenderComplete();
};
describe('Directly modifying url updates dashboard state', () => {
it('for query parameter', async function () {
before(async () => {
await PageObjects.dashboard.gotoDashboardLandingPage();
await PageObjects.dashboard.clickNewDashboard();
await PageObjects.timePicker.setHistoricalDataRange();
});
it('for query parameter', async function () {
const currentQuery = await queryBar.getQueryString();
expect(currentQuery).to.equal('');
const currentUrl = await browser.getCurrentUrl();
const newUrl = currentUrl.replace('query:%27%27', 'query:%27hi%27');
// Don't add the timestamp to the url or it will cause a hard refresh and we want to test a
// soft refresh.
await browser.get(newUrl.toString(), false);
const currentUrl = await getUrlFromShare();
const newUrl = currentUrl.replace(`query:''`, `query:'hi:hello'`);
// We need to add a timestamp to the URL because URL changes now only work with a hard refresh.
await browser.get(newUrl.toString());
const newQuery = await queryBar.getQueryString();
expect(newQuery).to.equal('hi');
expect(newQuery).to.equal('hi:hello');
await queryBar.clearQuery();
await queryBar.clickQuerySubmitButton();
});
it('for panel size parameters', async function () {
@ -224,7 +242,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
`w:${DEFAULT_PANEL_WIDTH}`,
`w:${DEFAULT_PANEL_WIDTH * 2}`
);
await browser.get(newUrl.toString(), false);
await hardRefresh(newUrl);
await retry.try(async () => {
const newPanelDimensions = await PageObjects.dashboard.getPanelDimensions();
if (newPanelDimensions.length < 0) {
@ -247,7 +266,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.dashboard.waitForRenderComplete();
const currentUrl = await getUrlFromShare();
const newUrl = currentUrl.replace(/panels:\!\(.*\),query/, 'panels:!(),query');
await browser.get(newUrl.toString(), false);
await hardRefresh(newUrl);
await retry.try(async () => {
const newPanelCount = await PageObjects.dashboard.getPanelCount();
@ -257,10 +276,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
describe('for embeddable config color parameters on a visualization', () => {
let originalPieSliceStyle = '';
it('updates a pie slice color on a soft refresh', async function () {
await dashboardAddPanel.addVisualization(PIE_CHART_VIS_NAME);
before(async () => {
await dashboardAddPanel.addVisualization(PIE_CHART_VIS_NAME);
await enableNewChartLibraryDebug();
originalPieSliceStyle = await pieChart.getPieSliceStyle(`80,000`);
});
it('updates a pie slice color on a hard refresh', async function () {
await PageObjects.visChart.openLegendOptionColors(
'80,000',
`[data-title="${PIE_CHART_VIS_NAME}"]`
@ -268,7 +291,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.visChart.selectNewLegendColorChoice('#F9D9F9');
const currentUrl = await getUrlFromShare();
const newUrl = currentUrl.replace('F9D9F9', 'FFFFFF');
await browser.get(newUrl.toString(), false);
await hardRefresh(newUrl);
await PageObjects.header.waitUntilLoadingHasFinished();
await retry.try(async () => {
@ -296,7 +319,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const newUrl = isNewChartsLibraryEnabled
? currentUrl.replace(`'80000':%23FFFFFF`, '')
: currentUrl.replace(`vis:(colors:('80,000':%23FFFFFF))`, '');
await browser.get(newUrl.toString(), false);
await hardRefresh(newUrl);
await PageObjects.header.waitUntilLoadingHasFinished();
await retry.try(async () => {

View file

@ -12,7 +12,6 @@ import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const retry = getService('retry');
const browser = getService('browser');
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
const dashboardPanelActions = getService('dashboardPanelActions');
@ -53,18 +52,5 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(panelCountAfterMaxThenMinimize).to.be(panelCount);
});
});
it('minimizes using the browser back button', async () => {
const panelCount = await PageObjects.dashboard.getPanelCount();
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.clickExpandPanelToggle();
await browser.goBack();
await retry.try(async () => {
const panelCountAfterMaxThenMinimize = await PageObjects.dashboard.getPanelCount();
expect(panelCountAfterMaxThenMinimize).to.be(panelCount);
});
});
});
}

View file

@ -15,7 +15,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
const dashboardAddPanel = getService('dashboardAddPanel');
const testSubjects = getService('testSubjects');
const PageObjects = getPageObjects(['dashboard', 'header', 'common', 'visualize', 'timePicker']);
const dashboardName = 'dashboard with filter';
const filterBar = getService('filterBar');
@ -33,7 +32,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('create new dashboard opens in edit mode', async function () {
await PageObjects.dashboard.gotoDashboardLandingPage();
await PageObjects.dashboard.clickNewDashboard();
await PageObjects.dashboard.clickCancelOutOfEditMode();
const isInViewMode = await PageObjects.dashboard.getIsInViewMode();
expect(isInViewMode).to.be(false);
});
it('existing dashboard opens in view mode', async function () {
@ -72,7 +72,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
'Sep 19, 2013 @ 06:31:44.000',
'Sep 19, 2013 @ 06:31:44.000'
);
await PageObjects.dashboard.clickDiscardChanges();
await PageObjects.dashboard.clickCancelOutOfEditMode();
const newTime = await PageObjects.timePicker.getTimeConfig();
@ -85,7 +85,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await queryBar.setQuery(`${originalQuery}and extra stuff`);
await queryBar.submitQuery();
await PageObjects.dashboard.clickDiscardChanges();
await PageObjects.dashboard.clickCancelOutOfEditMode();
const query = await queryBar.getQueryString();
expect(query).to.equal(originalQuery);
@ -105,7 +105,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
hasFilter = await filterBar.hasFilter('animal', 'dog');
expect(hasFilter).to.be(false);
await PageObjects.dashboard.clickDiscardChanges();
await PageObjects.dashboard.clickCancelOutOfEditMode();
hasFilter = await filterBar.hasFilter('animal', 'dog');
expect(hasFilter).to.be(true);
@ -122,13 +122,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
redirectToOrigin: true,
});
await PageObjects.dashboard.clickDiscardChanges(false);
await PageObjects.dashboard.clickCancelOutOfEditMode(false);
// for this sleep see https://github.com/elastic/kibana/issues/22299
await PageObjects.common.sleep(500);
// confirm lose changes
await testSubjects.exists('dashboardDiscardConfirmDiscard');
await testSubjects.click('dashboardDiscardConfirmDiscard');
await PageObjects.common.clickConfirmOnModal();
const panelCount = await PageObjects.dashboard.getPanelCount();
expect(panelCount).to.eql(originalPanelCount);
@ -138,7 +137,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const originalPanelCount = await PageObjects.dashboard.getPanelCount();
await dashboardAddPanel.addVisualization('new viz panel');
await PageObjects.dashboard.clickDiscardChanges();
await PageObjects.dashboard.clickCancelOutOfEditMode();
const panelCount = await PageObjects.dashboard.getPanelCount();
expect(panelCount).to.eql(originalPanelCount);
@ -158,10 +157,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
'Sep 19, 2015 @ 06:31:44.000',
'Sep 19, 2015 @ 06:31:44.000'
);
await PageObjects.dashboard.clickDiscardChanges(false);
await PageObjects.dashboard.clickCancelOutOfEditMode(false);
await testSubjects.exists('dashboardDiscardConfirmCancel');
await testSubjects.click('dashboardDiscardConfirmCancel');
await PageObjects.common.clickCancelOnModal();
await PageObjects.dashboard.saveDashboard(dashboardName, {
storeTimeWithDashboard: true,
});
@ -188,10 +186,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
);
const newTime = await PageObjects.timePicker.getTimeConfig();
await PageObjects.dashboard.clickDiscardChanges(false);
await PageObjects.dashboard.clickCancelOutOfEditMode(false);
await testSubjects.exists('dashboardDiscardConfirmCancel');
await testSubjects.click('dashboardDiscardConfirmCancel');
await PageObjects.common.clickCancelOnModal();
await PageObjects.dashboard.saveDashboard(dashboardName, { storeTimeWithDashboard: true });
await PageObjects.dashboard.loadSavedDashboard(dashboardName);

View file

@ -37,14 +37,9 @@ export default function ({ getService, getPageObjects }) {
describe('state:storeInSessionStorage', () => {
async function getStateFromUrl() {
const currentUrl = await browser.getCurrentUrl();
let match = currentUrl.match(/(.*)?_g=(.*)&_a=(.*)/);
if (match) return [match[2], match[3]];
match = currentUrl.match(/(.*)?_a=(.*)&_g=(.*)/);
if (match) return [match[3], match[2]];
if (!match) {
throw new Error('State in url is missing or malformed: ' + currentUrl);
}
const match = currentUrl.match(/(.*)?_g=(.*)/);
if (match) return match[2];
throw new Error('State in url is missing or malformed: ' + currentUrl);
}
it('defaults to null', async () => {
@ -59,12 +54,11 @@ export default function ({ getService, getPageObjects }) {
await PageObjects.common.navigateToApp('dashboard');
await PageObjects.dashboard.clickNewDashboard();
await PageObjects.timePicker.setDefaultAbsoluteRange();
const [globalState, appState] = await getStateFromUrl();
const globalState = await getStateFromUrl();
// We don't have to be exact, just need to ensure it's greater than when the hashed variation is being used,
// which is less than 20 characters.
expect(globalState.length).to.be.greaterThan(20);
expect(appState.length).to.be.greaterThan(20);
});
it('setting to true change is preserved', async function () {
@ -81,12 +75,11 @@ export default function ({ getService, getPageObjects }) {
await PageObjects.common.navigateToApp('dashboard');
await PageObjects.dashboard.clickNewDashboard();
await PageObjects.timePicker.setDefaultAbsoluteRange();
const [globalState, appState] = await getStateFromUrl();
const globalState = await getStateFromUrl();
// We don't have to be exact, just need to ensure it's less than the unhashed version, which will be
// greater than 20 characters with the default state plus a time.
expect(globalState.length).to.be.lessThan(20);
expect(appState.length).to.be.lessThan(20);
});
it("changing 'state:storeInSessionStorage' also takes effect without full page reload", async () => {
@ -95,11 +88,10 @@ export default function ({ getService, getPageObjects }) {
await PageObjects.settings.clickKibanaSettings();
await PageObjects.settings.toggleAdvancedSettingCheckbox('state:storeInSessionStorage');
await PageObjects.header.clickDashboard();
const [globalState, appState] = await getStateFromUrl();
const globalState = await getStateFromUrl();
// We don't have to be exact, just need to ensure it's greater than when the hashed variation is being used,
// which is less than 20 characters.
expect(globalState.length).to.be.greaterThan(20);
expect(appState.length).to.be.greaterThan(20);
});
});

View file

@ -242,7 +242,10 @@ export class DashboardPageObject extends FtrService {
public async switchToEditMode() {
this.log.debug('Switching to edit mode');
await this.testSubjects.click('dashboardEditMode');
if (await this.testSubjects.exists('dashboardEditMode')) {
// if the dashboard is not already in edit mode
await this.testSubjects.click('dashboardEditMode');
}
// wait until the count of dashboard panels equals the count of toggle menu icons
await this.retry.waitFor('in edit mode', async () => {
const panels = await this.testSubjects.findAll('embeddablePanel', 2500);
@ -258,22 +261,17 @@ export class DashboardPageObject extends FtrService {
public async clickCancelOutOfEditMode(accept = true) {
this.log.debug('clickCancelOutOfEditMode');
if (await this.getIsInViewMode()) return;
await this.retry.waitFor('leave edit mode button enabled', async () => {
const leaveEditModeButton = await this.testSubjects.find('dashboardViewOnlyMode');
const isDisabled = await leaveEditModeButton.getAttribute('disabled');
return !isDisabled;
});
await this.testSubjects.click('dashboardViewOnlyMode');
if (accept) {
const confirmation = await this.testSubjects.exists('dashboardDiscardConfirmKeep');
const confirmation = await this.testSubjects.exists('confirmModalTitleText');
if (confirmation) {
await this.testSubjects.click('dashboardDiscardConfirmKeep');
}
}
}
public async clickDiscardChanges(accept = true) {
this.log.debug('clickDiscardChanges');
await this.testSubjects.click('dashboardViewOnlyMode');
if (accept) {
const confirmation = await this.testSubjects.exists('dashboardDiscardConfirmDiscard');
if (confirmation) {
await this.testSubjects.click('dashboardDiscardConfirmDiscard');
await this.common.clickConfirmOnModal();
}
}
}

View file

@ -94,7 +94,7 @@ export class PieChartService extends FtrService {
const selectedSlice = slices.filter((slice) => {
return slice.name.toString() === name.replace(',', '');
});
return selectedSlice[0].color;
return selectedSlice[0]?.color;
}
const pieSlice = await this.getPieSlice(name);
return await pieSlice.getAttribute('style');

View file

@ -619,10 +619,7 @@
"dashboard.featureCatalogue.dashboardTitle": "ダッシュボード",
"dashboard.fillDashboardTitle": "このダッシュボードは空です。コンテンツを追加しましょう!",
"dashboard.helpMenu.appName": "ダッシュボード",
"dashboard.howToStartWorkingOnNewDashboardDescription1": "上記のメニューバーの",
"dashboard.howToStartWorkingOnNewDashboardDescription2": "をクリックするとパネルの追加を開始できます。",
"dashboard.howToStartWorkingOnNewDashboardEditLinkAriaLabel": "ダッシュボードを編集",
"dashboard.howToStartWorkingOnNewDashboardEditLinkText": "編集",
"dashboard.listing.createNewDashboard.combineDataViewFromKibanaAppDescription": "あらゆるKibanaアプリからダッシュボードにデータビューを組み合わせて、すべてを1か所に表示できます。",
"dashboard.listing.createNewDashboard.createButtonLabel": "新規ダッシュボードを作成",
"dashboard.listing.createNewDashboard.newToKibanaDescription": "Kibanaは初心者ですか{sampleDataInstallLink}してお試しください。",
@ -667,9 +664,6 @@
"dashboard.panelStorageError.setError": "保存されていない変更の設定中にエラーが発生しました。{message}",
"dashboard.placeholder.factory.displayName": "プレースホルダー",
"dashboard.savedDashboard.newDashboardTitle": "新規ダッシュボード",
"dashboard.solutionToolbar.addPanelButtonLabel": "ビジュアライゼーションを作成",
"dashboard.solutionToolbar.editorMenuButtonLabel": "すべてのタイプ",
"dashboard.stateManager.timeNotSavedWithDashboardErrorMessage": "このダッシュボードに時刻が保存されていないため、同期できません。",
"dashboard.strings.dashboardEditTitle": "{title}を編集中",
"dashboard.topNav.cloneModal.cancelButtonLabel": "キャンセル",
"dashboard.topNav.cloneModal.cloneDashboardModalHeaderTitle": "ダッシュボードのクローンを作成",

View file

@ -622,10 +622,7 @@
"dashboard.featureCatalogue.dashboardTitle": "仪表板",
"dashboard.fillDashboardTitle": "此仪表板是空的。让我们来填充它!",
"dashboard.helpMenu.appName": "仪表板",
"dashboard.howToStartWorkingOnNewDashboardDescription1": "单击",
"dashboard.howToStartWorkingOnNewDashboardDescription2": "上面菜单栏以开始添加面板。",
"dashboard.howToStartWorkingOnNewDashboardEditLinkAriaLabel": "编辑仪表板",
"dashboard.howToStartWorkingOnNewDashboardEditLinkText": "编辑",
"dashboard.listing.createNewDashboard.combineDataViewFromKibanaAppDescription": "您可以将任何 Kibana 应用的数据视图组合到一个仪表板中,从而在一个位置查看所有内容。",
"dashboard.listing.createNewDashboard.createButtonLabel": "创建新的仪表板",
"dashboard.listing.createNewDashboard.newToKibanaDescription": "Kibana 新手?{sampleDataInstallLink}来试用一下。",
@ -670,9 +667,6 @@
"dashboard.panelStorageError.setError": "设置未保存更改时遇到错误:{message}",
"dashboard.placeholder.factory.displayName": "占位符",
"dashboard.savedDashboard.newDashboardTitle": "新建仪表板",
"dashboard.solutionToolbar.addPanelButtonLabel": "创建可视化",
"dashboard.solutionToolbar.editorMenuButtonLabel": "所有类型",
"dashboard.stateManager.timeNotSavedWithDashboardErrorMessage": "时间未随此仪表板保存,因此无法同步。",
"dashboard.strings.dashboardEditTitle": "正在编辑 {title}",
"dashboard.topNav.cloneModal.cancelButtonLabel": "取消",
"dashboard.topNav.cloneModal.cloneDashboardModalHeaderTitle": "克隆仪表板",

View file

@ -99,7 +99,7 @@ export default function ({ getPageObjects, getService }) {
describe('save as', () => {
it('should return to dashboard and add new panel', async () => {
await PageObjects.maps.saveMap('Clone of map embeddable example');
await PageObjects.dashboard.waitForRenderComplete();
await PageObjects.header.waitUntilLoadingHasFinished();
const panelCount = await PageObjects.dashboard.getPanelCount();
expect(panelCount).to.equal(3);
});

View file

@ -61,6 +61,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.dashboard.waitForRenderComplete();
// saving dashboard to populate map buffer. See https://github.com/elastic/kibana/pull/91148 for more info
// This can be removed after a fix to https://github.com/elastic/kibana/issues/98180 is completed
await PageObjects.dashboard.switchToEditMode();
await PageObjects.dashboard.clickQuickSave();
await PageObjects.dashboard.clickCancelOutOfEditMode();
await searchSessions.expectState('completed');
await searchSessions.save();
await searchSessions.expectState('backgroundCompleted');

View file

@ -2149,6 +2149,15 @@
"@types/node" "*"
jest-mock "^26.6.2"
"@jest/fake-timers@^24.9.0":
version "24.9.0"
resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-24.9.0.tgz#ba3e6bf0eecd09a636049896434d306636540c93"
integrity sha512-eWQcNa2YSwzXWIMC5KufBh3oWRIijrQFROsIqt6v/NS9Io/gknw1jsAC9c+ih/RQX4A3O7SeWAhQeN0goKhT9A==
dependencies:
"@jest/types" "^24.9.0"
jest-message-util "^24.9.0"
jest-mock "^24.9.0"
"@jest/fake-timers@^26.6.2":
version "26.6.2"
resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-26.6.2.tgz#459c329bcf70cee4af4d7e3f3e67848123535aad"
@ -16958,6 +16967,15 @@ jest-each@^26.6.2:
jest-util "^26.6.2"
pretty-format "^26.6.2"
jest-environment-jsdom-thirteen@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/jest-environment-jsdom-thirteen/-/jest-environment-jsdom-thirteen-1.0.1.tgz#113e3c8aed945dadbc826636fa21139c69567bb5"
integrity sha512-Zi7OuKF7HMLlBvomitd5eKp5Ykc4Wvw0d+i+cpbCaE+7kmvL24SO4ssDmKrT++aANXR4T8+pmoJIlav5gr2peQ==
dependencies:
jest-mock "^24.0.0"
jest-util "^24.0.0"
jsdom "^13.0.0"
jest-environment-jsdom@^26.6.2:
version "26.6.2"
resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-26.6.2.tgz#78d09fe9cf019a357009b9b7e1f101d23bd1da3e"
@ -17100,6 +17118,13 @@ jest-message-util@^26.6.2:
slash "^3.0.0"
stack-utils "^2.0.2"
jest-mock@^24.0.0, jest-mock@^24.9.0:
version "24.9.0"
resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-24.9.0.tgz#c22835541ee379b908673ad51087a2185c13f1c6"
integrity sha512-3BEYN5WbSq9wd+SyLDES7AHnjH9A/ROBwmz7l2y+ol+NtSFO8DYiEBzoO1CeFc9a8DYy10EO4dDFVv/wN3zl1w==
dependencies:
"@jest/types" "^24.9.0"
jest-mock@^26.6.2:
version "26.6.2"
resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-26.6.2.tgz#d6cb712b041ed47fe0d9b6fc3474bc6543feb302"
@ -17299,6 +17324,24 @@ jest-styled-components@^7.0.3:
dependencies:
css "^2.2.4"
jest-util@^24.0.0:
version "24.9.0"
resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-24.9.0.tgz#7396814e48536d2e85a37de3e4c431d7cb140162"
integrity sha512-x+cZU8VRmOJxbA1K5oDBdxQmdq0OIdADarLxk0Mq+3XS4jgvhG/oKGWcIDCtPG0HgjxOYvF+ilPJQsAyXfbNOg==
dependencies:
"@jest/console" "^24.9.0"
"@jest/fake-timers" "^24.9.0"
"@jest/source-map" "^24.9.0"
"@jest/test-result" "^24.9.0"
"@jest/types" "^24.9.0"
callsites "^3.0.0"
chalk "^2.0.1"
graceful-fs "^4.1.15"
is-ci "^2.0.0"
mkdirp "^0.5.1"
slash "^2.0.0"
source-map "^0.6.0"
jest-util@^26.0.0, jest-util@^26.6.2:
version "26.6.2"
resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-26.6.2.tgz#907535dbe4d5a6cb4c47ac9b926f6af29576cbc1"
@ -17505,7 +17548,7 @@ jsbn@~0.1.0:
resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
jsdom@13.1.0:
jsdom@13.1.0, jsdom@^13.0.0:
version "13.1.0"
resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-13.1.0.tgz#fa7356f0cc8111d0f1077cb7800d06f22f1d66c7"
integrity sha512-C2Kp0qNuopw0smXFaHeayvharqF3kkcNqlcIlSX71+3XrsOFwkEPLt/9f5JksMmaul2JZYIQuY+WTpqHpQQcLg==