kibana/src/plugins/dashboard/public/application/dashboard_app_controller.tsx

1161 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import _, { uniqBy } from 'lodash';
import { i18n } from '@kbn/i18n';
import { EUI_MODAL_CANCEL_BUTTON, EuiCheckboxGroup } from '@elastic/eui';
import { EuiCheckboxGroupIdToSelectedMap } from '@elastic/eui/src/components/form/checkbox/checkbox_group';
import React, { useState, ReactElement } from 'react';
import ReactDOM from 'react-dom';
import angular from 'angular';
import deepEqual from 'fast-deep-equal';
import { Observable, pipe, Subscription, merge } from 'rxjs';
import {
filter,
map,
debounceTime,
mapTo,
startWith,
switchMap,
distinctUntilChanged,
} from 'rxjs/operators';
import { History } from 'history';
import { SavedObjectSaveOpts } from 'src/plugins/saved_objects/public';
import { NavigationPublicPluginStart as NavigationStart } from 'src/plugins/navigation/public';
import { DashboardEmptyScreen, DashboardEmptyScreenProps } from './dashboard_empty_screen';
import {
connectToQueryState,
esFilters,
IndexPattern,
IndexPatternsContract,
QueryState,
SavedQuery,
syncQueryStateWithUrl,
} from '../../../data/public';
import { getSavedObjectFinder, SaveResult, showSaveModal } from '../../../saved_objects/public';
import {
DASHBOARD_CONTAINER_TYPE,
DashboardContainer,
DashboardContainerInput,
DashboardPanelState,
} from './embeddable';
import {
EmbeddableFactoryNotFoundError,
ErrorEmbeddable,
isErrorEmbeddable,
openAddPanelFlyout,
ViewMode,
ContainerOutput,
EmbeddableInput,
} from '../../../embeddable/public';
import { NavAction, SavedDashboardPanel } from '../types';
import { showOptionsPopover } from './top_nav/show_options_popover';
import { DashboardSaveModal } from './top_nav/save_modal';
import { showCloneModal } from './top_nav/show_clone_modal';
import { saveDashboard } from './lib';
import { DashboardStateManager } from './dashboard_state_manager';
import { createDashboardEditUrl, DashboardConstants } from '../dashboard_constants';
import { getTopNavConfig } from './top_nav/get_top_nav_config';
import { TopNavIds } from './top_nav/top_nav_ids';
import { getDashboardTitle } from './dashboard_strings';
import { DashboardAppScope } from './dashboard_app';
import { convertSavedDashboardPanelToPanelState } from './lib/embeddable_saved_object_converters';
import { RenderDeps } from './application';
import { IKbnUrlStateStorage, setStateToKbnUrl, unhashUrl } from '../../../kibana_utils/public';
import {
addFatalError,
AngularHttpError,
KibanaLegacyStart,
subscribeWithScope,
} from '../../../kibana_legacy/public';
import { migrateLegacyQuery } from './lib/migrate_legacy_query';
export interface DashboardAppControllerDependencies extends RenderDeps {
$scope: DashboardAppScope;
$route: any;
$routeParams: any;
indexPatterns: IndexPatternsContract;
dashboardConfig: KibanaLegacyStart['dashboardConfig'];
history: History;
kbnUrlStateStorage: IKbnUrlStateStorage;
navigation: NavigationStart;
}
enum UrlParams {
SHOW_TOP_MENU = 'show-top-menu',
SHOW_QUERY_INPUT = 'show-query-input',
SHOW_TIME_FILTER = 'show-time-filter',
SHOW_FILTER_BAR = 'show-filter-bar',
HIDE_FILTER_BAR = 'hide-filter-bar',
}
interface UrlParamsSelectedMap {
[UrlParams.SHOW_TOP_MENU]: boolean;
[UrlParams.SHOW_QUERY_INPUT]: boolean;
[UrlParams.SHOW_TIME_FILTER]: boolean;
[UrlParams.SHOW_FILTER_BAR]: boolean;
}
interface UrlParamValues extends Omit<UrlParamsSelectedMap, UrlParams.SHOW_FILTER_BAR> {
[UrlParams.HIDE_FILTER_BAR]: boolean;
}
export class DashboardAppController {
// Part of the exposed plugin API - do not remove without careful consideration.
appStatus: {
dirty: boolean;
};
constructor({
pluginInitializerContext,
$scope,
$route,
$routeParams,
dashboardConfig,
indexPatterns,
savedQueryService,
embeddable,
share,
dashboardCapabilities,
scopedHistory,
embeddableCapabilities: { visualizeCapabilities, mapsCapabilities },
data: { query: queryService, search: searchService },
core: {
notifications,
overlays,
chrome,
fatalErrors,
uiSettings,
savedObjects,
http,
i18n: i18nStart,
},
history,
setHeaderActionMenu,
kbnUrlStateStorage,
usageCollection,
navigation,
}: DashboardAppControllerDependencies) {
const filterManager = queryService.filterManager;
const timefilter = queryService.timefilter.timefilter;
const queryStringManager = queryService.queryString;
const isEmbeddedExternally = Boolean($routeParams.embed);
// url param rules should only apply when embedded (e.g. url?embed=true)
const shouldForceDisplay = (param: string): boolean =>
isEmbeddedExternally && Boolean($routeParams[param]);
const forceShowTopNavMenu = shouldForceDisplay(UrlParams.SHOW_TOP_MENU);
const forceShowQueryInput = shouldForceDisplay(UrlParams.SHOW_QUERY_INPUT);
const forceShowDatePicker = shouldForceDisplay(UrlParams.SHOW_TIME_FILTER);
const forceHideFilterBar = shouldForceDisplay(UrlParams.HIDE_FILTER_BAR);
let lastReloadRequestTime = 0;
const dash = ($scope.dash = $route.current.locals.dash);
if (dash.id) {
chrome.docTitle.change(dash.title);
}
let incomingEmbeddable = embeddable
.getStateTransfer(scopedHistory())
.getIncomingEmbeddablePackage();
const dashboardStateManager = new DashboardStateManager({
savedDashboard: dash,
hideWriteControls: dashboardConfig.getHideWriteControls(),
kibanaVersion: pluginInitializerContext.env.packageInfo.version,
kbnUrlStateStorage,
history,
usageCollection,
});
// sync initial app filters from state to filterManager
// if there is an existing similar global filter, then leave it as global
filterManager.setAppFilters(_.cloneDeep(dashboardStateManager.appState.filters));
queryStringManager.setQuery(migrateLegacyQuery(dashboardStateManager.appState.query));
// setup syncing of app filters between appState and filterManager
const stopSyncingAppFilters = connectToQueryState(
queryService,
{
set: ({ filters, query }) => {
dashboardStateManager.setFilters(filters || []);
dashboardStateManager.setQuery(query || queryStringManager.getDefaultQuery());
},
get: () => ({
filters: dashboardStateManager.appState.filters,
query: dashboardStateManager.getQuery(),
}),
state$: dashboardStateManager.appState$.pipe(
map((state) => ({
filters: state.filters,
query: queryStringManager.formatQuery(state.query),
}))
),
},
{
filters: esFilters.FilterStateStore.APP_STATE,
query: true,
}
);
// The hash check is so we only update the time filter on dashboard open, not during
// normal cross app navigation.
if (dashboardStateManager.getIsTimeSavedWithDashboard()) {
const initialGlobalStateInUrl = kbnUrlStateStorage.get<QueryState>('_g');
if (!initialGlobalStateInUrl?.time) {
dashboardStateManager.syncTimefilterWithDashboardTime(timefilter);
}
if (!initialGlobalStateInUrl?.refreshInterval) {
dashboardStateManager.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
dashboardStateManager.startStateSyncing();
$scope.showSaveQuery = dashboardCapabilities.saveQuery as boolean;
const getShouldShowEditHelp = () =>
!dashboardStateManager.getPanels().length &&
dashboardStateManager.getIsEditMode() &&
!dashboardConfig.getHideWriteControls();
const getShouldShowViewHelp = () =>
!dashboardStateManager.getPanels().length &&
dashboardStateManager.getIsViewMode() &&
!dashboardConfig.getHideWriteControls();
const shouldShowUnauthorizedEmptyState = () => {
const readonlyMode =
!dashboardStateManager.getPanels().length &&
!getShouldShowEditHelp() &&
!getShouldShowViewHelp() &&
dashboardConfig.getHideWriteControls();
const userHasNoPermissions =
!dashboardStateManager.getPanels().length &&
!visualizeCapabilities.save &&
!mapsCapabilities.save;
return readonlyMode || userHasNoPermissions;
};
const addVisualization = () => {
navActions[TopNavIds.VISUALIZE]();
};
function getDashboardIndexPatterns(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;
}
const updateIndexPatternsOperator = pipe(
filter((container: DashboardContainer) => !!container && !isErrorEmbeddable(container)),
map(getDashboardIndexPatterns),
distinctUntilChanged((a, b) =>
deepEqual(
a.map((ip) => ip.id),
b.map((ip) => ip.id)
)
),
// using switchMap for previous task cancellation
switchMap((panelIndexPatterns: IndexPattern[]) => {
return new Observable((observer) => {
if (panelIndexPatterns && panelIndexPatterns.length > 0) {
$scope.$evalAsync(() => {
if (observer.closed) return;
$scope.indexPatterns = panelIndexPatterns;
observer.complete();
});
} else {
indexPatterns.getDefault().then((defaultIndexPattern) => {
if (observer.closed) return;
$scope.$evalAsync(() => {
if (observer.closed) return;
$scope.indexPatterns = [defaultIndexPattern as IndexPattern];
observer.complete();
});
});
}
});
})
);
const getEmptyScreenProps = (
shouldShowEditHelp: boolean,
isEmptyInReadOnlyMode: boolean
): DashboardEmptyScreenProps => {
const emptyScreenProps: DashboardEmptyScreenProps = {
onLinkClick: shouldShowEditHelp ? $scope.showAddPanel : $scope.enterEditMode,
showLinkToVisualize: shouldShowEditHelp,
uiSettings,
http,
};
if (shouldShowEditHelp) {
emptyScreenProps.onVisualizeClick = addVisualization;
}
if (isEmptyInReadOnlyMode) {
emptyScreenProps.isReadonlyMode = true;
}
return emptyScreenProps;
};
const getDashboardInput = (): 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,
},
};
incomingEmbeddable = undefined;
}
const shouldShowEditHelp = getShouldShowEditHelp();
const shouldShowViewHelp = getShouldShowViewHelp();
const isEmptyInReadonlyMode = shouldShowUnauthorizedEmptyState();
return {
id: dashboardStateManager.savedDashboard.id || '',
filters: filterManager.getFilters(),
hidePanelTitles: dashboardStateManager.getHidePanelTitles(),
query: $scope.model.query,
timeRange: {
..._.cloneDeep(timefilter.getTime()),
},
refreshConfig: timefilter.getRefreshInterval(),
viewMode: dashboardStateManager.getViewMode(),
panels: embeddablesMap,
isFullScreenMode: dashboardStateManager.getFullScreenMode(),
isEmbeddedExternally,
isEmptyState: shouldShowEditHelp || shouldShowViewHelp || isEmptyInReadonlyMode,
useMargins: dashboardStateManager.getUseMargins(),
lastReloadRequestTime,
title: dashboardStateManager.getTitle(),
description: dashboardStateManager.getDescription(),
expandedPanelId: dashboardStateManager.getExpandedPanelId(),
};
};
const updateState = () => {
// Following the "best practice" of always have a '.' in your ng-models
// https://github.com/angular/angular.js/wiki/Understanding-Scopes
$scope.model = {
query: dashboardStateManager.getQuery(),
filters: filterManager.getFilters(),
timeRestore: dashboardStateManager.getTimeRestore(),
title: dashboardStateManager.getTitle(),
description: dashboardStateManager.getDescription(),
timeRange: timefilter.getTime(),
refreshInterval: timefilter.getRefreshInterval(),
};
$scope.panels = dashboardStateManager.getPanels();
};
updateState();
let dashboardContainer: DashboardContainer | undefined;
let inputSubscription: Subscription | undefined;
let outputSubscription: Subscription | undefined;
const dashboardDom = document.getElementById('dashboardViewport');
const dashboardFactory = embeddable.getEmbeddableFactory<
DashboardContainerInput,
ContainerOutput,
DashboardContainer
>(DASHBOARD_CONTAINER_TYPE);
if (dashboardFactory) {
const searchSessionId = searchService.session.start();
dashboardFactory
.create({ ...getDashboardInput(), searchSessionId })
.then((container: DashboardContainer | ErrorEmbeddable | undefined) => {
if (container && !isErrorEmbeddable(container)) {
dashboardContainer = container;
dashboardContainer.renderEmpty = () => {
const shouldShowEditHelp = getShouldShowEditHelp();
const shouldShowViewHelp = getShouldShowViewHelp();
const isEmptyInReadOnlyMode = shouldShowUnauthorizedEmptyState();
const isEmptyState =
shouldShowEditHelp || shouldShowViewHelp || isEmptyInReadOnlyMode;
return isEmptyState ? (
<DashboardEmptyScreen
{...getEmptyScreenProps(shouldShowEditHelp, isEmptyInReadOnlyMode)}
/>
) : null;
};
outputSubscription = 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();
inputSubscription = dashboardContainer.getInput$().subscribe(() => {
let dirty = false;
// This has to be first because handleDashboardContainerChanges causes
// appState.save which will cause refreshDashboardContainer to be called.
if (
!esFilters.compareFilters(
container.getInput().filters,
filterManager.getFilters(),
esFilters.COMPARE_ALL_OPTIONS
)
) {
// Add filters modifies the object passed to it, hence the clone deep.
filterManager.addFilters(_.cloneDeep(container.getInput().filters));
dashboardStateManager.applyFilters(
$scope.model.query,
container.getInput().filters
);
dirty = true;
}
dashboardStateManager.handleDashboardContainerChanges(container);
$scope.$evalAsync(() => {
if (dirty) {
updateState();
}
});
});
dashboardStateManager.registerChangeListener(() => {
// 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.
refreshDashboardContainer();
});
// If the incomingEmbeddable does not yet exist in the panels listing, create a new panel using the container's addEmbeddable method.
if (
incomingEmbeddable &&
(!incomingEmbeddable.embeddableId ||
!container.getInput().panels[incomingEmbeddable.embeddableId])
) {
container.addNewEmbeddable<EmbeddableInput>(
incomingEmbeddable.type,
incomingEmbeddable.input
);
}
}
if (dashboardDom && container) {
container.render(dashboardDom);
}
});
}
// Part of the exposed plugin API - do not remove without careful consideration.
this.appStatus = {
dirty: !dash.id,
};
dashboardStateManager.registerChangeListener((status) => {
this.appStatus.dirty = status.dirty || !dash.id;
updateState();
});
dashboardStateManager.applyFilters(
dashboardStateManager.getQuery() || queryStringManager.getDefaultQuery(),
filterManager.getFilters()
);
const landingPageUrl = () => `#${DashboardConstants.LANDING_PAGE_PATH}`;
const getDashTitle = () =>
getDashboardTitle(
dashboardStateManager.getTitle(),
dashboardStateManager.getViewMode(),
dashboardStateManager.getIsDirty(timefilter),
dashboardStateManager.isNew()
);
// Push breadcrumbs to new header navigation
const updateBreadcrumbs = () => {
chrome.setBreadcrumbs([
{
text: i18n.translate('dashboard.dashboardAppBreadcrumbsTitle', {
defaultMessage: 'Dashboard',
}),
href: landingPageUrl(),
},
{ text: getDashTitle() },
]);
};
updateBreadcrumbs();
dashboardStateManager.registerChangeListener(updateBreadcrumbs);
const getChangesFromAppStateForContainerState = () => {
const appStateDashboardInput = getDashboardInput();
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'])).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;
}
});
// cloneDeep hack is needed, as there are multiple place, 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);
};
const refreshDashboardContainer = () => {
const changes = getChangesFromAppStateForContainerState();
if (changes && dashboardContainer) {
const searchSessionId = searchService.session.start();
dashboardContainer.updateInput({ ...changes, searchSessionId });
}
};
$scope.handleRefresh = function (_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.
lastReloadRequestTime = new Date().getTime();
refreshDashboardContainer();
}
};
const updateStateFromSavedQuery = (savedQuery: SavedQuery) => {
const allFilters = filterManager.getFilters();
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);
}
}
// Making this method sync broke the updates.
// Temporary fix, until we fix the complex state in this file.
setTimeout(() => {
filterManager.setFilters(allFilters);
}, 0);
};
$scope.$watch('savedQuery', (newSavedQuery: SavedQuery) => {
if (!newSavedQuery) return;
dashboardStateManager.setSavedQueryId(newSavedQuery.id);
updateStateFromSavedQuery(newSavedQuery);
});
$scope.$watch(
() => {
return dashboardStateManager.getSavedQueryId();
},
(newSavedQueryId) => {
if (!newSavedQueryId) {
$scope.savedQuery = undefined;
return;
}
if (!$scope.savedQuery || newSavedQueryId !== $scope.savedQuery.id) {
savedQueryService.getSavedQuery(newSavedQueryId).then((savedQuery: SavedQuery) => {
$scope.$evalAsync(() => {
$scope.savedQuery = savedQuery;
updateStateFromSavedQuery(savedQuery);
});
});
}
}
);
$scope.indexPatterns = [];
$scope.$watch(
() => dashboardCapabilities.saveQuery,
(newCapability) => {
$scope.showSaveQuery = newCapability as boolean;
}
);
const onSavedQueryIdChange = (savedQueryId?: string) => {
dashboardStateManager.setSavedQueryId(savedQueryId);
};
const shouldShowFilterBar = (forceHide: boolean): boolean =>
!forceHide && ($scope.model.filters.length > 0 || !dashboardStateManager.getFullScreenMode());
const shouldShowNavBarComponent = (forceShow: boolean): boolean =>
(forceShow || $scope.isVisible) && !dashboardStateManager.getFullScreenMode();
const getNavBarProps = () => {
const isFullScreenMode = dashboardStateManager.getFullScreenMode();
const screenTitle = dashboardStateManager.getTitle();
const showTopNavMenu = shouldShowNavBarComponent(forceShowTopNavMenu);
const showQueryInput = shouldShowNavBarComponent(forceShowQueryInput);
const showDatePicker = shouldShowNavBarComponent(forceShowDatePicker);
const showQueryBar = showQueryInput || showDatePicker;
const showFilterBar = shouldShowFilterBar(forceHideFilterBar);
const showSearchBar = showQueryBar || showFilterBar;
return {
appName: 'dashboard',
config: showTopNavMenu ? $scope.topNavMenu : undefined,
className: isFullScreenMode ? 'kbnTopNavMenu-isFullScreen' : undefined,
screenTitle,
showTopNavMenu,
showSearchBar,
showQueryBar,
showQueryInput,
showDatePicker,
showFilterBar,
indexPatterns: $scope.indexPatterns,
showSaveQuery: $scope.showSaveQuery,
savedQuery: $scope.savedQuery,
onSavedQueryIdChange,
savedQueryId: dashboardStateManager.getSavedQueryId(),
useDefaultBehaviors: true,
onQuerySubmit: $scope.handleRefresh,
};
};
const dashboardNavBar = document.getElementById('dashboardChrome');
const updateNavBar = () => {
ReactDOM.render(
<navigation.ui.TopNavMenu
{...getNavBarProps()}
{...(isEmbeddedExternally ? {} : { setMenuMountPoint: setHeaderActionMenu })}
/>,
dashboardNavBar
);
};
const unmountNavBar = () => {
if (dashboardNavBar) {
ReactDOM.unmountComponentAtNode(dashboardNavBar);
}
};
$scope.timefilterSubscriptions$ = new Subscription();
const timeChanges$ = merge(timefilter.getRefreshIntervalUpdate$(), timefilter.getTimeUpdate$());
$scope.timefilterSubscriptions$.add(
subscribeWithScope(
$scope,
timeChanges$,
{
next: () => {
updateState();
refreshDashboardContainer();
},
},
(error: AngularHttpError | Error | string) => addFatalError(fatalErrors, error)
)
);
function updateViewMode(newMode: ViewMode) {
dashboardStateManager.switchViewMode(newMode);
}
const onChangeViewMode = (newMode: ViewMode) => {
const isPageRefresh = newMode === dashboardStateManager.getViewMode();
const isLeavingEditMode = !isPageRefresh && newMode === ViewMode.VIEW;
const willLoseChanges = isLeavingEditMode && dashboardStateManager.getIsDirty(timefilter);
if (!willLoseChanges) {
updateViewMode(newMode);
return;
}
function revertChangesAndExitEditMode() {
dashboardStateManager.resetState();
// This is only necessary for new dashboards, which will default to Edit mode.
updateViewMode(ViewMode.VIEW);
// 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);
}
// Angular's $location skips this update because of history updates from syncState which happen simultaneously
// when calling kbnUrl.change() angular schedules url update and when angular finally starts to process it,
// the update is considered outdated and angular skips it
// so have to use implementation of dashboardStateManager.changeDashboardUrl, which workarounds those issues
dashboardStateManager.changeDashboardUrl(
dash.id ? createDashboardEditUrl(dash.id) : DashboardConstants.CREATE_NEW_DASHBOARD_URL
);
}
overlays
.openConfirm(
i18n.translate('dashboard.changeViewModeConfirmModal.discardChangesDescription', {
defaultMessage: `Once you discard your changes, there's no getting them back.`,
}),
{
confirmButtonText: i18n.translate(
'dashboard.changeViewModeConfirmModal.confirmButtonLabel',
{ defaultMessage: 'Discard changes' }
),
cancelButtonText: i18n.translate(
'dashboard.changeViewModeConfirmModal.cancelButtonLabel',
{ defaultMessage: 'Continue editing' }
),
defaultFocusedButton: EUI_MODAL_CANCEL_BUTTON,
title: i18n.translate('dashboard.changeViewModeConfirmModal.discardChangesTitle', {
defaultMessage: 'Discard changes to dashboard?',
}),
}
)
.then((isConfirmed) => {
if (isConfirmed) {
revertChangesAndExitEditMode();
}
});
updateNavBar();
};
/**
* 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
*/
function save(saveOptions: SavedObjectSaveOpts): Promise<SaveResult> {
return saveDashboard(angular.toJson, timefilter, dashboardStateManager, saveOptions)
.then(function (id) {
if (id) {
notifications.toasts.addSuccess({
title: i18n.translate('dashboard.dashboardWasSavedSuccessMessage', {
defaultMessage: `Dashboard '{dashTitle}' was saved`,
values: { dashTitle: dash.title },
}),
'data-test-subj': 'saveDashboardSuccess',
});
if (dash.id !== $routeParams.id) {
// Angular's $location skips this update because of history updates from syncState which happen simultaneously
// when calling kbnUrl.change() angular schedules url update and when angular finally starts to process it,
// the update is considered outdated and angular skips it
// so have to use implementation of dashboardStateManager.changeDashboardUrl, which workarounds those issues
dashboardStateManager.changeDashboardUrl(createDashboardEditUrl(dash.id));
} else {
chrome.docTitle.change(dash.lastSavedTitle);
updateViewMode(ViewMode.VIEW);
}
}
return { id };
})
.catch((error) => {
notifications.toasts.addDanger({
title: i18n.translate('dashboard.dashboardWasNotSavedDangerMessage', {
defaultMessage: `Dashboard '{dashTitle}' was not saved. Error: {errorMessage}`,
values: {
dashTitle: dash.title,
errorMessage: error.message,
},
}),
'data-test-subj': 'saveDashboardFailure',
});
return { error };
});
}
$scope.showAddPanel = () => {
dashboardStateManager.setFullScreenMode(false);
/*
* Temp solution for triggering menu click.
* When de-angularizing this code, please call the underlaying action function
* directly and not via the top nav object.
**/
navActions[TopNavIds.ADD_EXISTING]();
};
$scope.enterEditMode = () => {
dashboardStateManager.setFullScreenMode(false);
/*
* Temp solution for triggering menu click.
* When de-angularizing this code, please call the underlaying action function
* directly and not via the top nav object.
**/
navActions[TopNavIds.ENTER_EDIT_MODE]();
};
const navActions: {
[key: string]: NavAction;
} = {};
navActions[TopNavIds.FULL_SCREEN] = () => {
dashboardStateManager.setFullScreenMode(true);
updateNavBar();
};
navActions[TopNavIds.EXIT_EDIT_MODE] = () => onChangeViewMode(ViewMode.VIEW);
navActions[TopNavIds.ENTER_EDIT_MODE] = () => onChangeViewMode(ViewMode.EDIT);
navActions[TopNavIds.SAVE] = () => {
const currentTitle = dashboardStateManager.getTitle();
const currentDescription = dashboardStateManager.getDescription();
const currentTimeRestore = dashboardStateManager.getTimeRestore();
const onSave = ({
newTitle,
newDescription,
newCopyOnSave,
newTimeRestore,
isTitleDuplicateConfirmed,
onTitleDuplicate,
}: {
newTitle: string;
newDescription: string;
newCopyOnSave: boolean;
newTimeRestore: boolean;
isTitleDuplicateConfirmed: boolean;
onTitleDuplicate: () => void;
}) => {
dashboardStateManager.setTitle(newTitle);
dashboardStateManager.setDescription(newDescription);
dashboardStateManager.savedDashboard.copyOnSave = newCopyOnSave;
dashboardStateManager.setTimeRestore(newTimeRestore);
const saveOptions = {
confirmOverwrite: false,
isTitleDuplicateConfirmed,
onTitleDuplicate,
};
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);
}
return response;
});
};
const dashboardSaveModal = (
<DashboardSaveModal
onSave={onSave}
onClose={() => {}}
title={currentTitle}
description={currentDescription}
timeRestore={currentTimeRestore}
showCopyOnSave={dash.id ? true : false}
/>
);
showSaveModal(dashboardSaveModal, i18nStart.Context);
};
navActions[TopNavIds.CLONE] = () => {
const currentTitle = dashboardStateManager.getTitle();
const onClone = (
newTitle: string,
isTitleDuplicateConfirmed: boolean,
onTitleDuplicate: () => void
) => {
dashboardStateManager.savedDashboard.copyOnSave = true;
dashboardStateManager.setTitle(newTitle);
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);
}
updateNavBar();
return response;
});
};
showCloneModal(onClone, currentTitle);
};
navActions[TopNavIds.ADD_EXISTING] = () => {
if (dashboardContainer && !isErrorEmbeddable(dashboardContainer)) {
openAddPanelFlyout({
embeddable: dashboardContainer,
getAllFactories: embeddable.getEmbeddableFactories,
getFactory: embeddable.getEmbeddableFactory,
notifications,
overlays,
SavedObjectFinder: getSavedObjectFinder(savedObjects, uiSettings),
});
}
};
navActions[TopNavIds.VISUALIZE] = async () => {
const type = 'visualization';
const factory = embeddable.getEmbeddableFactory(type);
if (!factory) {
throw new EmbeddableFactoryNotFoundError(type);
}
const explicitInput = await factory.getExplicitInput();
if (dashboardContainer) {
await dashboardContainer.addNewEmbeddable(type, explicitInput);
}
};
navActions[TopNavIds.OPTIONS] = (anchorElement) => {
showOptionsPopover({
anchorElement,
useMargins: dashboardStateManager.getUseMargins(),
onUseMarginsChange: (isChecked: boolean) => {
dashboardStateManager.setUseMargins(isChecked);
},
hidePanelTitles: dashboardStateManager.getHidePanelTitles(),
onHidePanelTitlesChange: (isChecked: boolean) => {
dashboardStateManager.setHidePanelTitles(isChecked);
},
});
};
if (share) {
// the share button is only availabale if "share" plugin contract enabled
navActions[TopNavIds.SHARE] = (anchorElement) => {
const EmbedUrlParamExtension = ({
setParamValue,
}: {
setParamValue: (paramUpdate: UrlParamValues) => void;
}): ReactElement => {
const [urlParamsSelectedMap, setUrlParamsSelectedMap] = useState<UrlParamsSelectedMap>({
[UrlParams.SHOW_TOP_MENU]: false,
[UrlParams.SHOW_QUERY_INPUT]: false,
[UrlParams.SHOW_TIME_FILTER]: false,
[UrlParams.SHOW_FILTER_BAR]: true,
});
const checkboxes = [
{
id: UrlParams.SHOW_TOP_MENU,
label: i18n.translate('dashboard.embedUrlParamExtension.topMenu', {
defaultMessage: 'Top menu',
}),
},
{
id: UrlParams.SHOW_QUERY_INPUT,
label: i18n.translate('dashboard.embedUrlParamExtension.query', {
defaultMessage: 'Query',
}),
},
{
id: UrlParams.SHOW_TIME_FILTER,
label: i18n.translate('dashboard.embedUrlParamExtension.timeFilter', {
defaultMessage: 'Time filter',
}),
},
{
id: UrlParams.SHOW_FILTER_BAR,
label: i18n.translate('dashboard.embedUrlParamExtension.filterBar', {
defaultMessage: 'Filter bar',
}),
},
];
const handleChange = (param: string): void => {
const urlParamsSelectedMapUpdate = {
...urlParamsSelectedMap,
[param]: !urlParamsSelectedMap[param as keyof UrlParamsSelectedMap],
};
setUrlParamsSelectedMap(urlParamsSelectedMapUpdate);
const urlParamValues = {
[UrlParams.SHOW_TOP_MENU]: urlParamsSelectedMap[UrlParams.SHOW_TOP_MENU],
[UrlParams.SHOW_QUERY_INPUT]: urlParamsSelectedMap[UrlParams.SHOW_QUERY_INPUT],
[UrlParams.SHOW_TIME_FILTER]: urlParamsSelectedMap[UrlParams.SHOW_TIME_FILTER],
[UrlParams.HIDE_FILTER_BAR]: !urlParamsSelectedMap[UrlParams.SHOW_FILTER_BAR],
[param === UrlParams.SHOW_FILTER_BAR ? UrlParams.HIDE_FILTER_BAR : param]:
param === UrlParams.SHOW_FILTER_BAR
? urlParamsSelectedMap[UrlParams.SHOW_FILTER_BAR]
: !urlParamsSelectedMap[param as keyof UrlParamsSelectedMap],
};
setParamValue(urlParamValues);
};
return (
<EuiCheckboxGroup
options={checkboxes}
idToSelectedMap={(urlParamsSelectedMap as unknown) as EuiCheckboxGroupIdToSelectedMap}
onChange={handleChange}
legend={{
children: i18n.translate('dashboard.embedUrlParamExtension.include', {
defaultMessage: 'Include',
}),
}}
data-test-subj="embedUrlParamExtension"
/>
);
};
share.toggleShareContextMenu({
anchorElement,
allowEmbed: true,
allowShortUrl:
!dashboardConfig.getHideWriteControls() || dashboardCapabilities.createShortUrl,
shareableUrl: setStateToKbnUrl(
'_a',
dashboardStateManager.getAppState(),
{ useHash: false, storeInHashQuery: true },
unhashUrl(window.location.href)
),
objectId: dash.id,
objectType: 'dashboard',
sharingData: {
title: dash.title,
},
isDirty: dashboardStateManager.getIsDirty(),
embedUrlParamExtensions: [
{
paramName: 'embed',
component: EmbedUrlParamExtension,
},
],
});
};
}
updateViewMode(dashboardStateManager.getViewMode());
const filterChanges = merge(filterManager.getUpdates$(), queryStringManager.getUpdates$()).pipe(
debounceTime(100)
);
// update root source when filters update
const updateSubscription = filterChanges.subscribe({
next: () => {
$scope.model.filters = filterManager.getFilters();
$scope.model.query = queryStringManager.getQuery();
dashboardStateManager.applyFilters($scope.model.query, $scope.model.filters);
},
});
const visibleSubscription = chrome.getIsVisible$().subscribe((isVisible) => {
$scope.$evalAsync(() => {
$scope.isVisible = isVisible;
updateNavBar();
});
});
dashboardStateManager.registerChangeListener(() => {
// view mode could have changed, so trigger top nav update
$scope.topNavMenu = getTopNavConfig(
dashboardStateManager.getViewMode(),
navActions,
dashboardConfig.getHideWriteControls()
);
updateNavBar();
});
$scope.$watch('indexPatterns', () => {
updateNavBar();
});
$scope.$on('$destroy', () => {
// we have to unmount nav bar manually to make sure all internal subscriptions are unsubscribed
unmountNavBar();
updateSubscription.unsubscribe();
stopSyncingQueryServiceStateWithUrl();
stopSyncingAppFilters();
visibleSubscription.unsubscribe();
$scope.timefilterSubscriptions$.unsubscribe();
dashboardStateManager.destroy();
if (inputSubscription) {
inputSubscription.unsubscribe();
}
if (outputSubscription) {
outputSubscription.unsubscribe();
}
if (dashboardContainer) {
dashboardContainer.destroy();
}
searchService.session.clear();
});
}
}