mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[Dashboard] Rebuild State Management (#97941)
* Rebuilt dashboard state management system with RTK.
This commit is contained in:
parent
1cd88f4a44
commit
b3ed014c1a
80 changed files with 3196 additions and 3566 deletions
|
@ -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",
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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>,
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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> </span>}
|
||||
<EuiLink onClick={onLinkClick} aria-label={ariaLabel} data-test-subj={dataTestSubj || ''}>
|
||||
{linkText}
|
||||
</EuiLink>
|
||||
<span> </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>
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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' },
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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 };
|
||||
};
|
|
@ -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]);
|
||||
};
|
|
@ -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();
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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 };
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
};
|
|
@ -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 };
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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),
|
|
@ -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;
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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 } : {}),
|
||||
};
|
||||
};
|
|
@ -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 };
|
||||
};
|
|
@ -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', {
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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;
|
|
@ -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 };
|
||||
};
|
|
@ -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();
|
||||
};
|
|
@ -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);
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 : (
|
||||
<>
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
12
src/plugins/dashboard/public/application/state/index.ts
Normal file
12
src/plugins/dashboard/public/application/state/index.ts
Normal 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';
|
|
@ -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>
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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}
|
||||
/>,
|
||||
],
|
||||
}}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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: ({
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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'];
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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": "ダッシュボードのクローンを作成",
|
||||
|
|
|
@ -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": "克隆仪表板",
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
|
|
45
yarn.lock
45
yarn.lock
|
@ -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==
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue