/* * 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 { [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('_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 ? ( ) : 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( 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 = {}; // 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( , 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 { 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 = ( {}} 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({ [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 ( ); }; 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(); }); } }