From a121e6f6977d54be9d99426744d81b2c473967bb Mon Sep 17 00:00:00 2001 From: abbyhu2000 Date: Thu, 11 May 2023 22:02:14 +0000 Subject: [PATCH] Replace angular modules with react components Use React to start up the dashboard app, and use react routing to configure basic routing for dashboard plugin. Signed-off-by: abbyhu2000 --- .../dashboard/public/application/_hacks.scss | 14 - .../dashboard/public/application/app.tsx | 37 + .../public/application/application.ts | 167 --- .../components/dashboard_editor.tsx | 10 + .../components/dashboard_listing.tsx | 10 + .../components/dashboard_no_match.tsx | 10 + .../components/dashboard_top_nav.tsx | 10 + .../public/application/components/index.ts | 9 + .../public/application/dashboard_app.html | 9 - .../public/application/dashboard_app.tsx | 102 -- .../application/dashboard_app_controller.tsx | 1175 ----------------- .../application/dashboard_state_manager.ts | 657 --------- .../dashboard/public/application/index.ts | 33 - .../dashboard/public/application/index.tsx | 42 + .../public/application/legacy_app.js | 324 ----- .../listing/dashboard_listing_ng_wrapper.html | 14 - src/plugins/dashboard/public/plugin.tsx | 28 +- src/plugins/dashboard/public/types.ts | 58 +- 18 files changed, 204 insertions(+), 2505 deletions(-) delete mode 100644 src/plugins/dashboard/public/application/_hacks.scss create mode 100644 src/plugins/dashboard/public/application/app.tsx delete mode 100644 src/plugins/dashboard/public/application/application.ts create mode 100644 src/plugins/dashboard/public/application/components/dashboard_editor.tsx create mode 100644 src/plugins/dashboard/public/application/components/dashboard_listing.tsx create mode 100644 src/plugins/dashboard/public/application/components/dashboard_no_match.tsx create mode 100644 src/plugins/dashboard/public/application/components/dashboard_top_nav.tsx create mode 100644 src/plugins/dashboard/public/application/components/index.ts delete mode 100644 src/plugins/dashboard/public/application/dashboard_app.html delete mode 100644 src/plugins/dashboard/public/application/dashboard_app.tsx delete mode 100644 src/plugins/dashboard/public/application/dashboard_app_controller.tsx delete mode 100644 src/plugins/dashboard/public/application/dashboard_state_manager.ts delete mode 100644 src/plugins/dashboard/public/application/index.ts create mode 100644 src/plugins/dashboard/public/application/index.tsx delete mode 100644 src/plugins/dashboard/public/application/legacy_app.js delete mode 100644 src/plugins/dashboard/public/application/listing/dashboard_listing_ng_wrapper.html diff --git a/src/plugins/dashboard/public/application/_hacks.scss b/src/plugins/dashboard/public/application/_hacks.scss deleted file mode 100644 index d3a98dc3fd7c..000000000000 --- a/src/plugins/dashboard/public/application/_hacks.scss +++ /dev/null @@ -1,14 +0,0 @@ -// ANGULAR SELECTOR HACKS - -/** - * Needs to correspond with the react root nested inside angular. - */ -#dashboardViewport { - flex: 1; - display: flex; - flex-direction: column; - - [data-reactroot] { - flex: 1; - } -} diff --git a/src/plugins/dashboard/public/application/app.tsx b/src/plugins/dashboard/public/application/app.tsx new file mode 100644 index 000000000000..439806bb8de3 --- /dev/null +++ b/src/plugins/dashboard/public/application/app.tsx @@ -0,0 +1,37 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import { AppMountParameters } from 'opensearch-dashboards/public'; +import React from 'react'; +import { Route, Switch } from 'react-router-dom'; +import { DashboardConstants, createDashboardEditUrl } from '../dashboard_constants'; +import { DashboardEditor, DashboardListing, DashboardNoMatch } from './components'; + +export interface DashboardAppProps { + onAppLeave: AppMountParameters['onAppLeave']; +} + +export const DashboardApp = ({ onAppLeave }: DashboardAppProps) => { + return ( + + + + + + + + + + ); +}; diff --git a/src/plugins/dashboard/public/application/application.ts b/src/plugins/dashboard/public/application/application.ts deleted file mode 100644 index 35899cddf69d..000000000000 --- a/src/plugins/dashboard/public/application/application.ts +++ /dev/null @@ -1,167 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Any modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * 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 './index.scss'; - -import { EuiIcon } from '@elastic/eui'; -import angular, { IModule } from 'angular'; -// required for `ngSanitize` angular module -import 'angular-sanitize'; -import { i18nDirective, i18nFilter, I18nProvider } from '@osd/i18n/angular'; -import { - ChromeStart, - ToastsStart, - IUiSettingsClient, - CoreStart, - SavedObjectsClientContract, - PluginInitializerContext, - ScopedHistory, - AppMountParameters, -} from 'opensearch-dashboards/public'; -import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; -import { DashboardProvider } from 'src/plugins/dashboard/public/types'; -import { Storage } from '../../../opensearch_dashboards_utils/public'; -// @ts-ignore -import { initDashboardApp } from './legacy_app'; -import { EmbeddableStart } from '../../../embeddable/public'; -import { NavigationPublicPluginStart as NavigationStart } from '../../../navigation/public'; -import { DataPublicPluginStart } from '../../../data/public'; -import { SharePluginStart } from '../../../share/public'; -import { - OpenSearchDashboardsLegacyStart, - configureAppAngularModule, -} from '../../../opensearch_dashboards_legacy/public'; -import { UrlForwardingStart } from '../../../url_forwarding/public'; -import { SavedObjectLoader, SavedObjectsStart } from '../../../saved_objects/public'; - -// required for i18nIdDirective -import 'angular-sanitize'; -// required for ngRoute -import 'angular-route'; - -export interface RenderDeps { - pluginInitializerContext: PluginInitializerContext; - core: CoreStart; - data: DataPublicPluginStart; - navigation: NavigationStart; - savedObjectsClient: SavedObjectsClientContract; - savedDashboards: SavedObjectLoader; - dashboardProviders: () => { [key: string]: DashboardProvider }; - dashboardConfig: OpenSearchDashboardsLegacyStart['dashboardConfig']; - dashboardCapabilities: any; - embeddableCapabilities: { - visualizeCapabilities: any; - mapsCapabilities: any; - }; - uiSettings: IUiSettingsClient; - chrome: ChromeStart; - addBasePath: (path: string) => string; - savedQueryService: DataPublicPluginStart['query']['savedQueries']; - embeddable: EmbeddableStart; - localStorage: Storage; - share?: SharePluginStart; - usageCollection?: UsageCollectionSetup; - navigateToDefaultApp: UrlForwardingStart['navigateToDefaultApp']; - navigateToLegacyOpenSearchDashboardsUrl: UrlForwardingStart['navigateToLegacyOpenSearchDashboardsUrl']; - scopedHistory: () => ScopedHistory; - setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; - savedObjects: SavedObjectsStart; - restorePreviousUrl: () => void; - toastNotifications: ToastsStart; -} - -let angularModuleInstance: IModule | null = null; - -export const renderApp = (element: HTMLElement, appBasePath: string, deps: RenderDeps) => { - if (!angularModuleInstance) { - angularModuleInstance = createLocalAngularModule(); - // global routing stuff - configureAppAngularModule( - angularModuleInstance, - { core: deps.core, env: deps.pluginInitializerContext.env }, - true, - deps.scopedHistory - ); - initDashboardApp(angularModuleInstance, deps); - } - - const $injector = mountDashboardApp(appBasePath, element); - - return () => { - ($injector.get('osdUrlStateStorage') as any).cancel(); - $injector.get('$rootScope').$destroy(); - }; -}; - -const mainTemplate = (basePath: string) => `
- -
`; - -const moduleName = 'app/dashboard'; - -const thirdPartyAngularDependencies = ['ngSanitize', 'ngRoute', 'react']; - -function mountDashboardApp(appBasePath: string, element: HTMLElement) { - const mountpoint = document.createElement('div'); - mountpoint.setAttribute('class', 'dshAppContainer'); - // eslint-disable-next-line no-unsanitized/property - mountpoint.innerHTML = mainTemplate(appBasePath); - // bootstrap angular into detached element and attach it later to - // make angular-within-angular possible - const $injector = angular.bootstrap(mountpoint, [moduleName]); - // initialize global state handler - element.appendChild(mountpoint); - return $injector; -} - -function createLocalAngularModule() { - createLocalI18nModule(); - createLocalIconModule(); - - return angular.module(moduleName, [ - ...thirdPartyAngularDependencies, - 'app/dashboard/I18n', - 'app/dashboard/icon', - ]); -} - -function createLocalIconModule() { - angular - .module('app/dashboard/icon', ['react']) - .directive('icon', (reactDirective) => reactDirective(EuiIcon)); -} - -function createLocalI18nModule() { - angular - .module('app/dashboard/I18n', []) - .provider('i18n', I18nProvider) - .filter('i18n', i18nFilter) - .directive('i18nId', i18nDirective); -} diff --git a/src/plugins/dashboard/public/application/components/dashboard_editor.tsx b/src/plugins/dashboard/public/application/components/dashboard_editor.tsx new file mode 100644 index 000000000000..d3d0814f0c4a --- /dev/null +++ b/src/plugins/dashboard/public/application/components/dashboard_editor.tsx @@ -0,0 +1,10 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; + +export const DashboardEditor = () => { + return
Dashboard Editor
; +}; diff --git a/src/plugins/dashboard/public/application/components/dashboard_listing.tsx b/src/plugins/dashboard/public/application/components/dashboard_listing.tsx new file mode 100644 index 000000000000..1ee5cd3de88b --- /dev/null +++ b/src/plugins/dashboard/public/application/components/dashboard_listing.tsx @@ -0,0 +1,10 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; + +export const DashboardListing = () => { + return
Dashboard Listing
; +}; diff --git a/src/plugins/dashboard/public/application/components/dashboard_no_match.tsx b/src/plugins/dashboard/public/application/components/dashboard_no_match.tsx new file mode 100644 index 000000000000..bd3c40dcf1c4 --- /dev/null +++ b/src/plugins/dashboard/public/application/components/dashboard_no_match.tsx @@ -0,0 +1,10 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; + +export const DashboardNoMatch = () => { + return
Dashboard No Match
; +}; diff --git a/src/plugins/dashboard/public/application/components/dashboard_top_nav.tsx b/src/plugins/dashboard/public/application/components/dashboard_top_nav.tsx new file mode 100644 index 000000000000..1603fb89dad5 --- /dev/null +++ b/src/plugins/dashboard/public/application/components/dashboard_top_nav.tsx @@ -0,0 +1,10 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; + +export const DashboardTopNav = () => { + return
Dashboard Top Nav
; +}; diff --git a/src/plugins/dashboard/public/application/components/index.ts b/src/plugins/dashboard/public/application/components/index.ts new file mode 100644 index 000000000000..27f7e46ad74e --- /dev/null +++ b/src/plugins/dashboard/public/application/components/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { DashboardListing } from './dashboard_listing'; +export { DashboardEditor } from './dashboard_editor'; +export { DashboardNoMatch } from './dashboard_no_match'; +export { DashboardTopNav } from './dashboard_top_nav'; diff --git a/src/plugins/dashboard/public/application/dashboard_app.html b/src/plugins/dashboard/public/application/dashboard_app.html deleted file mode 100644 index 87a5728ac205..000000000000 --- a/src/plugins/dashboard/public/application/dashboard_app.html +++ /dev/null @@ -1,9 +0,0 @@ - -
-

{{screenTitle}}

-
- -
diff --git a/src/plugins/dashboard/public/application/dashboard_app.tsx b/src/plugins/dashboard/public/application/dashboard_app.tsx deleted file mode 100644 index 6141329c5a53..000000000000 --- a/src/plugins/dashboard/public/application/dashboard_app.tsx +++ /dev/null @@ -1,102 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Any modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * 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 moment from 'moment'; -import { Subscription } from 'rxjs'; -import { History } from 'history'; - -import { ViewMode } from 'src/plugins/embeddable/public'; -import { IIndexPattern, TimeRange, Query, Filter, SavedQuery } from 'src/plugins/data/public'; -import { IOsdUrlStateStorage } from 'src/plugins/opensearch_dashboards_utils/public'; - -import { DashboardAppState, SavedDashboardPanel } from '../types'; -import { DashboardAppController } from './dashboard_app_controller'; -import { RenderDeps } from './application'; -import { SavedObjectDashboard } from '../saved_dashboards'; - -export interface DashboardAppScope extends ng.IScope { - dash: SavedObjectDashboard; - appState: DashboardAppState; - model: { - query: Query; - filters: Filter[]; - timeRestore: boolean; - title: string; - description: string; - timeRange: - | TimeRange - | { to: string | moment.Moment | undefined; from: string | moment.Moment | undefined }; - refreshInterval: any; - }; - savedQuery?: SavedQuery; - refreshInterval: any; - panels: SavedDashboardPanel[]; - indexPatterns: IIndexPattern[]; - dashboardViewMode: ViewMode; - expandedPanel?: string; - getShouldShowEditHelp: () => boolean; - getShouldShowViewHelp: () => boolean; - handleRefresh: ( - { query, dateRange }: { query?: Query; dateRange: TimeRange }, - isUpdate?: boolean - ) => void; - topNavMenu: any; - showAddPanel: any; - showSaveQuery: boolean; - osdTopNav: any; - enterEditMode: () => void; - timefilterSubscriptions$: Subscription; - isVisible: boolean; -} - -export function initDashboardAppDirective(app: any, deps: RenderDeps) { - app.directive('dashboardApp', () => ({ - restrict: 'E', - controllerAs: 'dashboardApp', - controller: ( - $scope: DashboardAppScope, - $route: any, - $routeParams: { - id?: string; - }, - osdUrlStateStorage: IOsdUrlStateStorage, - history: History - ) => - new DashboardAppController({ - $route, - $scope, - $routeParams, - indexPatterns: deps.data.indexPatterns, - osdUrlStateStorage, - history, - ...deps, - }), - })); -} diff --git a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx b/src/plugins/dashboard/public/application/dashboard_app_controller.tsx deleted file mode 100644 index 414860e348a6..000000000000 --- a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx +++ /dev/null @@ -1,1175 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Any modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * 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 '@osd/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, EMPTY } from 'rxjs'; -import { - filter, - map, - debounceTime, - mapTo, - startWith, - switchMap, - distinctUntilChanged, - catchError, -} 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, - opensearchFilters, - 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 { IOsdUrlStateStorage, unhashUrl } from '../../../opensearch_dashboards_utils/public'; -import { - addFatalError, - AngularHttpError, - OpenSearchDashboardsLegacyStart, - subscribeWithScope, -} from '../../../opensearch_dashboards_legacy/public'; -import { migrateLegacyQuery } from './lib/migrate_legacy_query'; - -export interface DashboardAppControllerDependencies extends RenderDeps { - $scope: DashboardAppScope; - $route: any; - $routeParams: any; - indexPatterns: IndexPatternsContract; - dashboardConfig: OpenSearchDashboardsLegacyStart['dashboardConfig']; - history: History; - osdUrlStateStorage: IOsdUrlStateStorage; - 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 }, - core: { - notifications, - overlays, - chrome, - injectedMetadata, - fatalErrors, - uiSettings, - savedObjects, - http, - i18n: i18nStart, - }, - history, - setHeaderActionMenu, - osdUrlStateStorage, - 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(), - opensearchDashboardsVersion: pluginInitializerContext.env.packageInfo.version, - osdUrlStateStorage, - 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: opensearchFilters.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 = osdUrlStateStorage.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, - osdUrlStateStorage - ); - - // 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) { - dashboardFactory - .create(getDashboardInput()) - .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(catchError(() => EMPTY)) - ) - ) - ) - ) - ) - .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 ( - !opensearchFilters.compareFilters( - container.getInput().filters, - filterManager.getFilters(), - opensearchFilters.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() - ); - - timefilter.disableTimeRangeSelector(); - timefilter.disableAutoRefreshSelector(); - - 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 ( - !opensearchFilters.compareFilters( - containerInput.filters, - appStateDashboardInput.filters, - opensearchFilters.COMPARE_ALL_OPTIONS - ) - ) { - differences.filters = appStateDashboardInput.filters; - } - - Object.keys(_.omit(containerInput, ['filters'])).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) { - dashboardContainer.updateInput(changes); - } - }; - - $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 ? 'osdTopNavMenu-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 osdUrl.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 osdUrl.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); - } - await factory.create({} as EmbeddableInput, dashboardContainer); - }; - - 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: 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); - if (dashboardContainer) { - dashboardContainer.updateInput({ - filters: $scope.model.filters, - query: $scope.model.query, - }); - } - }, - }); - - 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(); - } - }); - } -} diff --git a/src/plugins/dashboard/public/application/dashboard_state_manager.ts b/src/plugins/dashboard/public/application/dashboard_state_manager.ts deleted file mode 100644 index ff8d7664f917..000000000000 --- a/src/plugins/dashboard/public/application/dashboard_state_manager.ts +++ /dev/null @@ -1,657 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Any modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * 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 { i18n } from '@osd/i18n'; -import _ from 'lodash'; -import { Observable, Subscription } from 'rxjs'; -import { Moment } from 'moment'; -import { History } from 'history'; - -import { Filter, Query, TimefilterContract as Timefilter } from 'src/plugins/data/public'; -import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; -import { migrateLegacyQuery } from './lib/migrate_legacy_query'; - -import { ViewMode } from '../embeddable_plugin'; -import { getAppStateDefaults, migrateAppState, getDashboardIdFromUrl } from './lib'; -import { convertPanelStateToSavedDashboardPanel } from './lib/embeddable_saved_object_converters'; -import { FilterUtils } from './lib/filter_utils'; -import { - DashboardAppState, - DashboardAppStateDefaults, - DashboardAppStateInUrl, - DashboardAppStateTransitions, - SavedDashboardPanel, -} from '../types'; -import { - createStateContainer, - IOsdUrlStateStorage, - ISyncStateRef, - ReduxLikeStateContainer, - syncState, -} from '../../../opensearch_dashboards_utils/public'; -import { SavedObjectDashboard } from '../saved_dashboards'; -import { DashboardContainer } from './embeddable'; - -/** - * 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: SavedObjectDashboard; - public lastSavedDashboardFilters: { - timeTo?: string | Moment; - timeFrom?: string | Moment; - filterBars: Filter[]; - query: Query; - }; - private stateDefaults: DashboardAppStateDefaults; - private hideWriteControls: boolean; - private opensearchDashboardsVersion: string; - public isDirty: boolean; - private changeListeners: Array<(status: { dirty: boolean }) => void>; - - public get appState(): DashboardAppState { - return this.stateContainer.get(); - } - - public get appState$(): Observable { - return this.stateContainer.state$; - } - - private readonly stateContainer: ReduxLikeStateContainer< - DashboardAppState, - DashboardAppStateTransitions - >; - private readonly stateContainerChangeSub: Subscription; - private readonly STATE_STORAGE_KEY = '_a'; - private readonly osdUrlStateStorage: IOsdUrlStateStorage; - private readonly stateSyncRef: ISyncStateRef; - private readonly history: History; - private readonly usageCollection: UsageCollectionSetup | undefined; - - /** - * - * @param savedDashboard - * @param hideWriteControls true if write controls should be hidden. - * @param opensearchDashboardsVersion current opensearchDashboardsVersion - * @param - */ - constructor({ - savedDashboard, - hideWriteControls, - opensearchDashboardsVersion, - osdUrlStateStorage, - history, - usageCollection, - }: { - savedDashboard: SavedObjectDashboard; - hideWriteControls: boolean; - opensearchDashboardsVersion: string; - osdUrlStateStorage: IOsdUrlStateStorage; - history: History; - usageCollection?: UsageCollectionSetup; - }) { - this.history = history; - this.opensearchDashboardsVersion = opensearchDashboardsVersion; - this.savedDashboard = savedDashboard; - this.hideWriteControls = hideWriteControls; - this.usageCollection = usageCollection; - - // get state defaults from saved dashboard, make sure it is migrated - this.stateDefaults = migrateAppState( - getAppStateDefaults(this.savedDashboard, this.hideWriteControls), - opensearchDashboardsVersion, - usageCollection - ); - - this.osdUrlStateStorage = osdUrlStateStorage; - - // setup initial state by merging defaults with state from url - // also run migration, as state in url could be of older version - const initialState = migrateAppState( - { - ...this.stateDefaults, - ...this.osdUrlStateStorage.get(this.STATE_STORAGE_KEY), - }, - opensearchDashboardsVersion, - usageCollection - ); - - // setup state container using initial state both from defaults and from url - this.stateContainer = createStateContainer( - initialState, - { - set: (state) => (prop, value) => ({ ...state, [prop]: value }), - setOption: (state) => (option, value) => ({ - ...state, - options: { - ...state.options, - [option]: value, - }, - }), - } - ); - - this.isDirty = false; - - // 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 `this.STATE_STORAGE_KEY` query param - this.stateSyncRef = syncState({ - storageKey: this.STATE_STORAGE_KEY, - stateContainer: { - ...this.stateContainer, - get: () => this.toUrlState(this.stateContainer.get()), - set: (state: DashboardAppStateInUrl | null) => { - // sync state required state container to be able to handle null - // overriding set() so it could handle null coming from url - if (state) { - // 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; - - this.stateContainer.set({ - ...this.stateDefaults, - ...state, - }); - } 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.osdUrlStateStorage, - }); - } - - 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 } = {}; - - Object.values(input.panels).forEach((panelState) => { - if (savedDashboardPanelMap[panelState.explicitInput.id] === undefined) { - dirty = true; - } - - convertedPanelStateMap[panelState.explicitInput.id] = convertPanelStateToSavedDashboardPanel( - panelState, - this.opensearchDashboardsVersion - ); - - 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) { - this.saveState({ replace: true }); - } - } - - if (input.isFullScreenMode !== this.getFullScreenMode()) { - this.setFullScreenMode(input.isFullScreenMode); - } - - if (input.expandedPanelId !== this.getExpandedPanelId()) { - this.setExpandedPanelId(input.expandedPanelId); - } - - 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! - this.stateDefaults = migrateAppState( - getAppStateDefaults(this.savedDashboard, this.hideWriteControls), - this.opensearchDashboardsVersion, - 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 setDescription(description: string) { - this.stateContainer.transitions.set('description', description); - } - - public setTitle(title: string) { - this.savedDashboard.title = title; - this.stateContainer.transitions.set('title', title); - } - - 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 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() { - return this.hideWriteControls ? ViewMode.VIEW : this.appState.viewMode; - } - - 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.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.osdUrlStateStorage.set( - this.STATE_STORAGE_KEY, - this.toUrlState(this.stateContainer.get()) - ); - // immediately forces scheduled updates and changes location - return this.osdUrlStateStorage.flush({ replace }); - } - - // TODO: find nicer solution for this - // this function helps to make just 1 browser history update, when we imperatively changing the dashboard url - // It could be that there is pending *dashboardStateManager* updates, which aren't flushed yet to the url. - // So to prevent 2 browser updates: - // 1. Force flush any pending state updates (syncing state to query) - // 2. If url was updated, then apply path change with replace - public changeDashboardUrl(pathname: string) { - // synchronously persist current state to url with push() - const updated = this.saveState({ replace: false }); - // change pathname - this.history[updated ? 'replace' : 'push']({ - ...this.history.location, - pathname, - }); - } - - 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); - } - - /** - * 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(); - } - } - - 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 = ['viewMode', 'filters', 'query']; - - const initial = _.omit(this.stateDefaults, propsToIgnore); - const current = _.omit(this.stateContainer.get(), propsToIgnore); - return !_.isEqual(initial, current); - } - - private toUrlState(state: DashboardAppState): DashboardAppStateInUrl { - if (state.viewMode === ViewMode.VIEW) { - const { panels, ...stateWithoutPanels } = state; - return stateWithoutPanels; - } - - return state; - } -} diff --git a/src/plugins/dashboard/public/application/index.ts b/src/plugins/dashboard/public/application/index.ts deleted file mode 100644 index 131a8a1e9c10..000000000000 --- a/src/plugins/dashboard/public/application/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Any modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * 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. - */ - -export * from './embeddable'; -export * from './actions'; -export type { RenderDeps } from './application'; diff --git a/src/plugins/dashboard/public/application/index.tsx b/src/plugins/dashboard/public/application/index.tsx new file mode 100644 index 000000000000..6ef2e641b62d --- /dev/null +++ b/src/plugins/dashboard/public/application/index.tsx @@ -0,0 +1,42 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Router } from 'react-router-dom'; +import { AppMountParameters } from 'opensearch-dashboards/public'; +import { OpenSearchDashboardsContextProvider } from '../../../opensearch_dashboards_react/public'; +import { addHelpMenuToAppChrome } from './help_menu/help_menu_util'; +import { DashboardApp } from './app'; +import { DashboardServices } from '../types'; +export * from './embeddable'; +export * from './actions'; + +export const renderApp = ( + { element, appBasePath, onAppLeave }: AppMountParameters, + services: DashboardServices +) => { + addHelpMenuToAppChrome(services.chrome, services.docLinks); + + const app = ( + + + + + + + + ); + + ReactDOM.render(app, element); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/src/plugins/dashboard/public/application/legacy_app.js b/src/plugins/dashboard/public/application/legacy_app.js deleted file mode 100644 index 0c10653d7f41..000000000000 --- a/src/plugins/dashboard/public/application/legacy_app.js +++ /dev/null @@ -1,324 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Any modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * 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 { i18n } from '@osd/i18n'; -import { parse } from 'query-string'; - -import dashboardTemplate from './dashboard_app.html'; -import dashboardListingTemplate from './listing/dashboard_listing_ng_wrapper.html'; -import { createHashHistory } from 'history'; - -import { initDashboardAppDirective } from './dashboard_app'; -import { createDashboardEditUrl, DashboardConstants } from '../dashboard_constants'; -import { - createOsdUrlStateStorage, - redirectWhenMissing, - SavedObjectNotFound, - withNotifyOnErrors, -} from '../../../opensearch_dashboards_utils/public'; -import { DashboardListing, EMPTY_FILTER } from './listing/dashboard_listing'; -import { addHelpMenuToAppChrome } from './help_menu/help_menu_util'; -import { syncQueryStateWithUrl } from '../../../data/public'; - -export function initDashboardApp(app, deps) { - initDashboardAppDirective(app, deps); - - app.directive('dashboardListing', function (reactDirective) { - return reactDirective(DashboardListing, [ - ['core', { watchDepth: 'reference' }], - ['dashboardProviders', { watchDepth: 'reference' }], - ['createItem', { watchDepth: 'reference' }], - ['editItem', { watchDepth: 'reference' }], - ['viewItem', { watchDepth: 'reference' }], - ['findItems', { watchDepth: 'reference' }], - ['deleteItems', { watchDepth: 'reference' }], - ['listingLimit', { watchDepth: 'reference' }], - ['hideWriteControls', { watchDepth: 'reference' }], - ['initialFilter', { watchDepth: 'reference' }], - ['initialPageSize', { watchDepth: 'reference' }], - ]); - }); - - function createNewDashboardCtrl($scope) { - $scope.visitVisualizeAppLinkText = i18n.translate('dashboard.visitVisualizeAppLinkText', { - defaultMessage: 'visit the Visualize app', - }); - addHelpMenuToAppChrome(deps.chrome, deps.core.docLinks); - } - - app.factory('history', () => createHashHistory()); - app.factory('osdUrlStateStorage', (history) => - createOsdUrlStateStorage({ - history, - useHash: deps.uiSettings.get('state:storeInSessionStorage'), - ...withNotifyOnErrors(deps.core.notifications.toasts), - }) - ); - - app.config(function ($routeProvider) { - const defaults = { - reloadOnSearch: false, - requireUICapability: 'dashboard.show', - badge: () => { - if (deps.dashboardCapabilities.showWriteControls) { - return undefined; - } - - return { - text: i18n.translate('dashboard.badge.readOnly.text', { - defaultMessage: 'Read only', - }), - tooltip: i18n.translate('dashboard.badge.readOnly.tooltip', { - defaultMessage: 'Unable to save dashboards', - }), - iconType: 'glasses', - }; - }, - }; - - $routeProvider - .when('/', { - redirectTo: DashboardConstants.LANDING_PAGE_PATH, - }) - .when(DashboardConstants.LANDING_PAGE_PATH, { - ...defaults, - template: dashboardListingTemplate, - controller: function ($scope, osdUrlStateStorage, history) { - deps.core.chrome.docTitle.change( - i18n.translate('dashboard.dashboardPageTitle', { defaultMessage: 'Dashboards' }) - ); - const dashboardConfig = deps.dashboardConfig; - - // syncs `_g` portion of url with query services - const { stop: stopSyncingQueryServiceStateWithUrl } = syncQueryStateWithUrl( - deps.data.query, - osdUrlStateStorage - ); - - $scope.listingLimit = deps.savedObjects.settings.getListingLimit(); - $scope.initialPageSize = deps.savedObjects.settings.getPerPage(); - $scope.create = () => { - history.push(DashboardConstants.CREATE_NEW_DASHBOARD_URL); - }; - $scope.dashboardProviders = deps.dashboardProviders() || []; - $scope.dashboardListTypes = Object.keys($scope.dashboardProviders); - - const mapListAttributesToDashboardProvider = (obj) => { - const provider = $scope.dashboardProviders[obj.type]; - return { - id: obj.id, - appId: provider.appId, - type: provider.savedObjectsName, - ...obj.attributes, - updated_at: obj.updated_at, - viewUrl: provider.viewUrlPathFn(obj), - editUrl: provider.editUrlPathFn(obj), - }; - }; - - $scope.find = async (search) => { - const savedObjectsClient = deps.savedObjectsClient; - - const res = await savedObjectsClient.find({ - type: $scope.dashboardListTypes, - search: search ? `${search}*` : undefined, - fields: ['title', 'type', 'description', 'updated_at'], - perPage: $scope.listingLimit, - page: 1, - searchFields: ['title^3', 'type', 'description'], - defaultSearchOperator: 'AND', - }); - const list = res.savedObjects?.map(mapListAttributesToDashboardProvider) || []; - - return { - total: list.length, - hits: list, - }; - }; - - $scope.editItem = ({ appId, editUrl }) => { - if (appId === 'dashboard') { - history.push(editUrl); - } else { - deps.core.application.navigateToUrl(editUrl); - } - }; - $scope.viewItem = ({ appId, viewUrl }) => { - if (appId === 'dashboard') { - history.push(viewUrl); - } else { - deps.core.application.navigateToUrl(viewUrl); - } - }; - $scope.delete = (dashboards) => { - const ids = dashboards.map((d) => ({ id: d.id, appId: d.appId })); - return Promise.all( - ids.map(({ id, appId }) => { - return deps.savedObjectsClient.delete(appId, id); - }) - ).catch((error) => { - deps.toastNotifications.addError(error, { - title: i18n.translate('dashboard.dashboardListingDeleteErrorTitle', { - defaultMessage: 'Error deleting dashboard', - }), - }); - }); - }; - $scope.hideWriteControls = dashboardConfig.getHideWriteControls(); - $scope.initialFilter = parse(history.location.search).filter || EMPTY_FILTER; - deps.chrome.setBreadcrumbs([ - { - text: i18n.translate('dashboard.dashboardBreadcrumbsTitle', { - defaultMessage: 'Dashboards', - }), - }, - ]); - addHelpMenuToAppChrome(deps.chrome, deps.core.docLinks); - $scope.core = deps.core; - - $scope.$on('$destroy', () => { - stopSyncingQueryServiceStateWithUrl(); - }); - }, - resolve: { - dash: function ($route, history) { - return deps.data.indexPatterns.ensureDefaultIndexPattern(history).then(() => { - const savedObjectsClient = deps.savedObjectsClient; - const title = $route.current.params.title; - if (title) { - return savedObjectsClient - .find({ - search: `"${title}"`, - search_fields: 'title', - type: 'dashboard', - }) - .then((results) => { - // The search isn't an exact match, lets see if we can find a single exact match to use - const matchingDashboards = results.savedObjects.filter( - (dashboard) => - dashboard.attributes.title.toLowerCase() === title.toLowerCase() - ); - if (matchingDashboards.length === 1) { - history.replace(createDashboardEditUrl(matchingDashboards[0].id)); - } else { - history.replace(`${DashboardConstants.LANDING_PAGE_PATH}?filter="${title}"`); - $route.reload(); - } - return new Promise(() => {}); - }); - } - }); - }, - }, - }) - .when(DashboardConstants.CREATE_NEW_DASHBOARD_URL, { - ...defaults, - template: dashboardTemplate, - controller: createNewDashboardCtrl, - requireUICapability: 'dashboard.createNew', - resolve: { - dash: (history) => - deps.data.indexPatterns - .ensureDefaultIndexPattern(history) - .then(() => deps.savedDashboards.get()) - .catch( - redirectWhenMissing({ - history, - navigateToApp: deps.core.application.navigateToApp, - mapping: { - dashboard: DashboardConstants.LANDING_PAGE_PATH, - }, - toastNotifications: deps.core.notifications.toasts, - }) - ), - }, - }) - .when(createDashboardEditUrl(':id'), { - ...defaults, - template: dashboardTemplate, - controller: createNewDashboardCtrl, - resolve: { - dash: function ($route, history) { - const id = $route.current.params.id; - - return deps.data.indexPatterns - .ensureDefaultIndexPattern(history) - .then(() => deps.savedDashboards.get(id)) - .then((savedDashboard) => { - deps.chrome.recentlyAccessed.add( - savedDashboard.getFullPath(), - savedDashboard.title, - id - ); - return savedDashboard; - }) - .catch((error) => { - // Preserve BWC of v5.3.0 links for new, unsaved dashboards. - // See https://github.com/elastic/kibana/issues/10951 for more context. - if (error instanceof SavedObjectNotFound && id === 'create') { - // Note preserve querystring part is necessary so the state is preserved through the redirect. - history.replace({ - ...history.location, // preserve query, - pathname: DashboardConstants.CREATE_NEW_DASHBOARD_URL, - }); - - deps.core.notifications.toasts.addWarning( - i18n.translate('dashboard.urlWasRemovedInSixZeroWarningMessage', { - defaultMessage: - 'The url "dashboard/create" was removed in 6.0. Please update your bookmarks.', - }) - ); - return new Promise(() => {}); - } else { - // E.g. a corrupt or deleted dashboard - deps.core.notifications.toasts.addDanger(error.message); - history.push(DashboardConstants.LANDING_PAGE_PATH); - return new Promise(() => {}); - } - }); - }, - }, - }) - .otherwise({ - resolveRedirectTo: function ($rootScope) { - const path = window.location.hash.substr(1); - deps.restorePreviousUrl(); - $rootScope.$applyAsync(() => { - const { navigated } = deps.navigateToLegacyOpenSearchDashboardsUrl(path); - if (!navigated) { - deps.navigateToDefaultApp(); - } - }); - // prevent angular from completing the navigation - return new Promise(() => {}); - }, - }); - }); -} diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing_ng_wrapper.html b/src/plugins/dashboard/public/application/listing/dashboard_listing_ng_wrapper.html deleted file mode 100644 index 5e19fbfe678b..000000000000 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing_ng_wrapper.html +++ /dev/null @@ -1,14 +0,0 @@ - diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index 87f934925899..5a5df77c1310 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -47,6 +47,7 @@ import { } from 'src/core/public'; import { UrlForwardingSetup, UrlForwardingStart } from 'src/plugins/url_forwarding/public'; import { isEmpty } from 'lodash'; +import { createHashHistory } from 'history'; import { UsageCollectionSetup } from '../../usage_collection/public'; import { CONTEXT_MENU_TRIGGER, @@ -72,7 +73,12 @@ import { ExitFullScreenButton as ExitFullScreenButtonUi, ExitFullScreenButtonProps, } from '../../opensearch_dashboards_react/public'; -import { createOsdUrlTracker, Storage } from '../../opensearch_dashboards_utils/public'; +import { + createOsdUrlTracker, + Storage, + createOsdUrlStateStorage, + withNotifyOnErrors, +} from '../../opensearch_dashboards_utils/public'; import { initAngularBootstrap, OpenSearchDashboardsLegacySetup, @@ -93,7 +99,6 @@ import { DashboardContainerFactoryDefinition, ExpandPanelAction, ExpandPanelActionContext, - RenderDeps, ReplacePanelAction, ReplacePanelActionContext, ACTION_UNLINK_FROM_LIBRARY, @@ -121,7 +126,7 @@ import { AttributeServiceOptions, ATTRIBUTE_SERVICE_KEY, } from './attribute_service/attribute_service'; -import { DashboardProvider } from './types'; +import { DashboardProvider, DashboardServices } from './types'; declare module '../../share/public' { export interface UrlGeneratorStateMapping { @@ -380,8 +385,17 @@ export class DashboardPlugin savedObjects, } = pluginsStart; - const deps: RenderDeps = { + const history = createHashHistory(); // need more research + const services: DashboardServices = { + ...coreStart, pluginInitializerContext: this.initializerContext, + opensearchDashboardsVersion: this.initializerContext.env.packageInfo.version, + history, + osdUrlStateStorage: createOsdUrlStateStorage({ + history, + useHash: coreStart.uiSettings.get('state:storeInSessionStorage'), + ...withNotifyOnErrors(coreStart.notifications.toasts), + }), core: coreStart, dashboardConfig, navigateToDefaultApp, @@ -406,14 +420,14 @@ export class DashboardPlugin usageCollection, scopedHistory: () => this.currentHistory!, setHeaderActionMenu: params.setHeaderActionMenu, - savedObjects, + savedObjectsPublic: savedObjects, restorePreviousUrl, }; // make sure the index pattern list is up to date await dataStart.indexPatterns.clearCache(); - const { renderApp } = await import('./application/application'); params.element.classList.add('dshAppContainer'); - const unmount = renderApp(params.element, params.appBasePath, deps); + const { renderApp } = await import('./application'); + const unmount = renderApp(params, services); return () => { unmount(); appUnMounted(); diff --git a/src/plugins/dashboard/public/types.ts b/src/plugins/dashboard/public/types.ts index 42a07b40ef1c..4de26fdebadf 100644 --- a/src/plugins/dashboard/public/types.ts +++ b/src/plugins/dashboard/public/types.ts @@ -28,10 +28,29 @@ * under the License. */ -import { Query, Filter } from 'src/plugins/data/public'; -import { SavedObject as SavedObjectType, SavedObjectAttributes } from 'src/core/public'; +import { Query, Filter, DataPublicPluginStart } from 'src/plugins/data/public'; +import { + SavedObject as SavedObjectType, + SavedObjectAttributes, + CoreStart, + PluginInitializerContext, + SavedObjectsClientContract, + IUiSettingsClient, + ChromeStart, + ScopedHistory, + AppMountParameters, + SavedObjectsStart, +} from 'src/core/public'; +import { IOsdUrlStateStorage } from 'src/plugins/opensearch_dashboards_utils/public'; +import { SavedObjectLoader } from 'src/plugins/saved_objects/public'; +import { OpenSearchDashboardsLegacyStart } from 'src/plugins/opensearch_dashboards_legacy/public'; +import { SharePluginStart } from 'src/plugins/share/public'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; +import { UrlForwardingStart } from 'src/plugins/url_forwarding/public'; +import { History } from 'history'; +import { NavigationPublicPluginStart as NavigationStart } from '../../navigation/public'; +import { EmbeddableStart, ViewMode } from './embeddable_plugin'; import { SavedDashboardPanel730ToLatest } from '../common'; -import { ViewMode } from './embeddable_plugin'; export interface DashboardCapabilities { showWriteControls: boolean; @@ -212,3 +231,36 @@ export interface DashboardProvider { // "http://../app/myplugin#/edit/abc123" editUrlPathFn: (obj: SavedObjectType) => string; } + +export interface DashboardServices extends CoreStart { + pluginInitializerContext: PluginInitializerContext; + opensearchDashboardsVersion: string; + history: History; + osdUrlStateStorage: IOsdUrlStateStorage; + core: CoreStart; + data: DataPublicPluginStart; + navigation: NavigationStart; + savedObjectsClient: SavedObjectsClientContract; + savedDashboards: SavedObjectLoader; + dashboardProviders: () => { [key: string]: DashboardProvider }; + dashboardConfig: OpenSearchDashboardsLegacyStart['dashboardConfig']; + dashboardCapabilities: any; + embeddableCapabilities: { + visualizeCapabilities: any; + mapsCapabilities: any; + }; + uiSettings: IUiSettingsClient; + chrome: ChromeStart; + savedQueryService: DataPublicPluginStart['query']['savedQueries']; + embeddable: EmbeddableStart; + localStorage: Storage; + share?: SharePluginStart; + usageCollection?: UsageCollectionSetup; + navigateToDefaultApp: UrlForwardingStart['navigateToDefaultApp']; + navigateToLegacyOpenSearchDashboardsUrl: UrlForwardingStart['navigateToLegacyOpenSearchDashboardsUrl']; + scopedHistory: () => ScopedHistory; + setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; + savedObjectsPublic: SavedObjectsStart; + restorePreviousUrl: () => void; + addBasePath?: (url: string) => string; +}