From 93c3deb0355b3d1d11c3c97b7ac0517dfc924779 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Tue, 10 Dec 2024 08:34:04 -0700 Subject: [PATCH] Remove dashboard embeddable (#194892) Closes https://github.com/elastic/kibana/issues/197281 PR replaces `DashboardContainer`, which implements legacy Container and Embeddable interfaces, with plain old javascript object implementation returned from `getDashboardApi`. The following are out of scope for this PR and will be accomplished at a later time: 1) re-factoring dashboard folder structure 2) removing all uses of Embeddable and EmbeddableInput types 3) removing legacy types like DashboardContainerInput --------- Co-authored-by: Elastic Machine Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Hannah Mudge Co-authored-by: Devon Thomson --- .../presentation_containers/index.ts | 1 - .../interfaces/performance_trackers.ts | 7 - .../interfaces/tracks_overlays.ts | 2 +- .../presentation_publishing/index.ts | 1 + .../interfaces/fetch/publishes_reload.ts | 4 +- .../interfaces/titles/titles_api.ts | 12 +- .../are_panel_layouts_equal.ts} | 27 +- .../dashboard_api/data_loading_manager.ts | 61 ++ .../dashboard_api/data_views_manager.ts | 72 ++ .../public/dashboard_api/get_dashboard_api.ts | 283 ++++- .../dashboard_api/load_dashboard_api.ts | 140 +++ .../public/dashboard_api/open_save_modal.tsx | 171 +++ .../public/dashboard_api/panels_manager.ts | 475 +++++++++ .../dashboard_api/search_session_manager.ts | 60 ++ .../public/dashboard_api/settings_manager.ts | 140 +++ .../dashboard_api/track_contentful_render.ts | 25 + .../public/dashboard_api/track_panel.ts | 5 +- .../dashboard/public/dashboard_api/types.ts | 104 +- .../dashboard_api/unified_search_manager.ts | 371 +++++++ .../public/dashboard_api/unsaved_changes.ts | 35 - .../dashboard_api/unsaved_changes_manager.ts | 137 +++ .../use_dashboard_internal_api.ts | 21 + .../public/dashboard_api/view_mode_manager.ts | 63 ++ .../dashboard_app/dashboard_app.test.tsx | 23 +- .../public/dashboard_app/dashboard_app.tsx | 42 +- .../public/dashboard_app/dashboard_router.tsx | 1 + .../no_data/dashboard_app_no_data.tsx | 8 - .../top_nav/dashboard_editing_toolbar.tsx | 3 +- .../top_nav/editor_menu.test.tsx | 10 +- .../top_nav/use_dashboard_menu_items.tsx | 54 +- .../url/search_sessions_integration.ts | 2 +- .../dashboard_empty_screen.test.tsx | 20 +- .../empty_screen/dashboard_empty_screen.tsx | 3 +- .../component/grid/dashboard_grid.test.tsx | 21 +- .../component/grid/dashboard_grid.tsx | 10 +- .../grid/dashboard_grid_item.test.tsx | 35 +- .../component/grid/dashboard_grid_item.tsx | 39 +- .../component/viewport/dashboard_viewport.tsx | 15 +- .../embeddable/api/add_panel_from_library.ts | 13 +- .../api/duplicate_dashboard_panel.test.tsx | 330 ------ .../api/duplicate_dashboard_panel.ts | 165 --- .../embeddable/api/index.ts | 2 - .../embeddable/api/panel_management.ts | 40 - .../embeddable/api/run_save_functions.tsx | 334 ------ ...ashboard_control_group_integration.test.ts | 88 -- .../dashboard_control_group_integration.ts | 101 -- .../create/create_dashboard.test.ts | 521 ---------- .../embeddable/create/create_dashboard.ts | 522 ---------- .../data_views/sync_dashboard_data_views.ts | 53 - .../query_performance_tracking.test.ts | 49 +- .../performance/query_performance_tracking.ts | 26 +- .../create/search_sessions/new_session.ts | 11 +- ...rt_dashboard_search_session_integration.ts | 41 +- .../sync_dashboard_unified_search_state.ts | 155 --- .../embeddable/dashboard_container.test.tsx | 274 ----- .../embeddable/dashboard_container.tsx | 979 ------------------ .../dashboard_container_factory.tsx | 88 -- .../external_api/dashboard_renderer.test.tsx | 305 ------ .../external_api/dashboard_renderer.tsx | 156 ++- .../public/dashboard_container/index.ts | 3 - .../panel_placement/index.ts | 2 - .../panel_placement/place_panel.test.ts | 168 --- .../panel_placement/place_panel.ts | 65 -- .../state/dashboard_container_reducers.ts | 173 ---- .../diffing/dashboard_diffing_functions.ts | 135 --- .../diffing/dashboard_diffing_integration.ts | 198 ---- .../public/dashboard_container/types.ts | 2 +- .../internal_dashboard_top_nav.test.tsx | 19 +- .../internal_dashboard_top_nav.tsx | 18 +- src/plugins/dashboard/public/mocks.tsx | 66 +- .../services/dashboard_backup_service.ts | 18 +- .../lib/load_dashboard_state.test.ts | 7 +- .../lib/migrate_dashboard_input.test.ts | 10 +- .../lib/save_dashboard_state.test.ts | 12 +- .../lib/save_dashboard_state.ts | 2 +- .../types.ts | 3 +- src/plugins/dashboard/tsconfig.json | 3 +- .../group6/dashboard_esql_no_data.ts | 4 +- .../apps/dashboard/group6/view_edit.ts | 12 +- .../translations/translations/fr-FR.json | 2 - .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 82 files changed, 2470 insertions(+), 5212 deletions(-) rename src/plugins/dashboard/public/{dashboard_container/state/diffing/dashboard_diffing_utils.ts => dashboard_api/are_panel_layouts_equal.ts} (69%) create mode 100644 src/plugins/dashboard/public/dashboard_api/data_loading_manager.ts create mode 100644 src/plugins/dashboard/public/dashboard_api/data_views_manager.ts create mode 100644 src/plugins/dashboard/public/dashboard_api/load_dashboard_api.ts create mode 100644 src/plugins/dashboard/public/dashboard_api/open_save_modal.tsx create mode 100644 src/plugins/dashboard/public/dashboard_api/panels_manager.ts create mode 100644 src/plugins/dashboard/public/dashboard_api/search_session_manager.ts create mode 100644 src/plugins/dashboard/public/dashboard_api/settings_manager.ts create mode 100644 src/plugins/dashboard/public/dashboard_api/track_contentful_render.ts create mode 100644 src/plugins/dashboard/public/dashboard_api/unified_search_manager.ts delete mode 100644 src/plugins/dashboard/public/dashboard_api/unsaved_changes.ts create mode 100644 src/plugins/dashboard/public/dashboard_api/unsaved_changes_manager.ts create mode 100644 src/plugins/dashboard/public/dashboard_api/use_dashboard_internal_api.ts create mode 100644 src/plugins/dashboard/public/dashboard_api/view_mode_manager.ts delete mode 100644 src/plugins/dashboard/public/dashboard_container/embeddable/api/duplicate_dashboard_panel.test.tsx delete mode 100644 src/plugins/dashboard/public/dashboard_container/embeddable/api/duplicate_dashboard_panel.ts delete mode 100644 src/plugins/dashboard/public/dashboard_container/embeddable/api/panel_management.ts delete mode 100644 src/plugins/dashboard/public/dashboard_container/embeddable/api/run_save_functions.tsx delete mode 100644 src/plugins/dashboard/public/dashboard_container/embeddable/create/controls/dashboard_control_group_integration.test.ts delete mode 100644 src/plugins/dashboard/public/dashboard_container/embeddable/create/controls/dashboard_control_group_integration.ts delete mode 100644 src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.test.ts delete mode 100644 src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts delete mode 100644 src/plugins/dashboard/public/dashboard_container/embeddable/create/data_views/sync_dashboard_data_views.ts delete mode 100644 src/plugins/dashboard/public/dashboard_container/embeddable/create/unified_search/sync_dashboard_unified_search_state.ts delete mode 100644 src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.test.tsx delete mode 100644 src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx delete mode 100644 src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container_factory.tsx delete mode 100644 src/plugins/dashboard/public/dashboard_container/external_api/dashboard_renderer.test.tsx delete mode 100644 src/plugins/dashboard/public/dashboard_container/panel_placement/place_panel.test.ts delete mode 100644 src/plugins/dashboard/public/dashboard_container/panel_placement/place_panel.ts delete mode 100644 src/plugins/dashboard/public/dashboard_container/state/dashboard_container_reducers.ts delete mode 100644 src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_functions.ts delete mode 100644 src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts diff --git a/packages/presentation/presentation_containers/index.ts b/packages/presentation/presentation_containers/index.ts index e339b0ace3890..833e8f5b4a0c2 100644 --- a/packages/presentation/presentation_containers/index.ts +++ b/packages/presentation/presentation_containers/index.ts @@ -29,7 +29,6 @@ export { export { canTrackContentfulRender, type TrackContentfulRender, - type TracksQueryPerformance, } from './interfaces/performance_trackers'; export { apiIsPresentationContainer, diff --git a/packages/presentation/presentation_containers/interfaces/performance_trackers.ts b/packages/presentation/presentation_containers/interfaces/performance_trackers.ts index ef8cfeb9caf98..ec2b2e363d309 100644 --- a/packages/presentation/presentation_containers/interfaces/performance_trackers.ts +++ b/packages/presentation/presentation_containers/interfaces/performance_trackers.ts @@ -17,10 +17,3 @@ export interface TrackContentfulRender { export const canTrackContentfulRender = (root: unknown): root is TrackContentfulRender => { return root !== null && typeof root === 'object' && 'trackContentfulRender' in root; }; - -export interface TracksQueryPerformance { - firstLoad: boolean; - creationStartTime?: number; - creationEndTime?: number; - lastLoadStartTime?: number; -} diff --git a/packages/presentation/presentation_containers/interfaces/tracks_overlays.ts b/packages/presentation/presentation_containers/interfaces/tracks_overlays.ts index 76e18a17436e9..b46d5ad312c27 100644 --- a/packages/presentation/presentation_containers/interfaces/tracks_overlays.ts +++ b/packages/presentation/presentation_containers/interfaces/tracks_overlays.ts @@ -9,7 +9,7 @@ import { OverlayRef } from '@kbn/core-mount-utils-browser'; -interface TracksOverlaysOptions { +export interface TracksOverlaysOptions { /** * If present, the panel with this ID will be focused when the overlay is opened. This can be used in tandem with a push * flyout to edit a panel's settings in context diff --git a/packages/presentation/presentation_publishing/index.ts b/packages/presentation/presentation_publishing/index.ts index b290d49a8434f..09e6453586665 100644 --- a/packages/presentation/presentation_publishing/index.ts +++ b/packages/presentation/presentation_publishing/index.ts @@ -39,6 +39,7 @@ export { initializeTimeRange, type SerializedTimeRange, } from './interfaces/fetch/initialize_time_range'; +export { apiPublishesReload, type PublishesReload } from './interfaces/fetch/publishes_reload'; export { apiPublishesFilters, apiPublishesPartialUnifiedSearch, diff --git a/packages/presentation/presentation_publishing/interfaces/fetch/publishes_reload.ts b/packages/presentation/presentation_publishing/interfaces/fetch/publishes_reload.ts index 3e5459e5d8aec..5fa99938fdc88 100644 --- a/packages/presentation/presentation_publishing/interfaces/fetch/publishes_reload.ts +++ b/packages/presentation/presentation_publishing/interfaces/fetch/publishes_reload.ts @@ -7,10 +7,10 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { PublishingSubject } from '../../publishing_subject'; +import { Observable } from 'rxjs'; export interface PublishesReload { - reload$: PublishingSubject; + reload$: Omit, 'next'>; } export const apiPublishesReload = (unknownApi: null | unknown): unknownApi is PublishesReload => { diff --git a/packages/presentation/presentation_publishing/interfaces/titles/titles_api.ts b/packages/presentation/presentation_publishing/interfaces/titles/titles_api.ts index 68c3e1c854410..34723605e4813 100644 --- a/packages/presentation/presentation_publishing/interfaces/titles/titles_api.ts +++ b/packages/presentation/presentation_publishing/interfaces/titles/titles_api.ts @@ -39,9 +39,15 @@ export const initializeTitles = ( const panelDescription = new BehaviorSubject(rawState.description); const hidePanelTitle = new BehaviorSubject(rawState.hidePanelTitles); - const setPanelTitle = (value: string | undefined) => panelTitle.next(value); - const setHidePanelTitle = (value: boolean | undefined) => hidePanelTitle.next(value); - const setPanelDescription = (value: string | undefined) => panelDescription.next(value); + const setPanelTitle = (value: string | undefined) => { + if (value !== panelTitle.value) panelTitle.next(value); + }; + const setHidePanelTitle = (value: boolean | undefined) => { + if (value !== hidePanelTitle.value) hidePanelTitle.next(value); + }; + const setPanelDescription = (value: string | undefined) => { + if (value !== panelDescription.value) panelDescription.next(value); + }; const titleComparators: StateComparators = { title: [panelTitle, setPanelTitle], diff --git a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_utils.ts b/src/plugins/dashboard/public/dashboard_api/are_panel_layouts_equal.ts similarity index 69% rename from src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_utils.ts rename to src/plugins/dashboard/public/dashboard_api/are_panel_layouts_equal.ts index 5b13239da174c..3af80356bc734 100644 --- a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_utils.ts +++ b/src/plugins/dashboard/public/dashboard_api/are_panel_layouts_equal.ts @@ -8,35 +8,14 @@ */ import { isEmpty, xor } from 'lodash'; -import moment, { Moment } from 'moment'; import fastIsEqual from 'fast-deep-equal'; - -import { DashboardPanelMap } from '../../../../common'; - -const convertTimeToUTCString = (time?: string | Moment): undefined | string => { - if (moment(time).isValid()) { - return moment(time).utc().format('YYYY-MM-DDTHH:mm:ss.SSS[Z]'); - } else { - // If it's not a valid moment date, then it should be a string representing a relative time - // like 'now' or 'now-15m'. - return time as string; - } -}; - -export const areTimesEqual = ( - timeA?: string | Moment | undefined, - timeB?: string | Moment | undefined -) => { - return convertTimeToUTCString(timeA) === convertTimeToUTCString(timeB); -}; - -export const defaultDiffFunction = (a: unknown, b: unknown) => fastIsEqual(a, b); +import { DashboardPanelMap } from '../../common'; /** * Checks whether the panel maps have the same keys, and if they do, whether all of the other keys inside each panel * are equal. Skips explicit input as that needs to be handled asynchronously. */ -export const getPanelLayoutsAreEqual = ( +export const arePanelLayoutsEqual = ( originalPanels: DashboardPanelMap, newPanels: DashboardPanelMap ) => { @@ -57,7 +36,7 @@ export const getPanelLayoutsAreEqual = ( ]; for (const key of keys) { if (key === undefined) continue; - if (!defaultDiffFunction(originalObj[key], newObj[key])) differences[key] = newObj[key]; + if (!fastIsEqual(originalObj[key], newObj[key])) differences[key] = newObj[key]; } return differences; }; diff --git a/src/plugins/dashboard/public/dashboard_api/data_loading_manager.ts b/src/plugins/dashboard/public/dashboard_api/data_loading_manager.ts new file mode 100644 index 0000000000000..064ea20672d63 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_api/data_loading_manager.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { BehaviorSubject, debounceTime, first, map } from 'rxjs'; +import { + PublishesDataLoading, + PublishingSubject, + apiPublishesDataLoading, +} from '@kbn/presentation-publishing'; +import { combineCompatibleChildrenApis } from '@kbn/presentation-containers'; + +export function initializeDataLoadingManager( + children$: PublishingSubject<{ [key: string]: unknown }> +) { + const dataLoading$ = new BehaviorSubject(undefined); + + const dataLoadingSubscription = combineCompatibleChildrenApis< + PublishesDataLoading, + boolean | undefined + >( + { children$ }, + 'dataLoading', + apiPublishesDataLoading, + undefined, + // flatten method + (values) => { + return values.some((isLoading) => isLoading); + } + ).subscribe((isAtLeastOneChildLoading) => { + dataLoading$.next(isAtLeastOneChildLoading); + }); + + return { + api: { + dataLoading: dataLoading$, + }, + internalApi: { + waitForPanelsToLoad$: dataLoading$.pipe( + // debounce to give time for panels to start loading if they are going to load + debounceTime(300), + first((isLoading: boolean | undefined) => { + return !isLoading; + }), + map(() => { + // Observable notifies subscriber when loading is finished + // Return void to not expose internal implementation details of observable + return; + }) + ), + }, + cleanup: () => { + dataLoadingSubscription.unsubscribe(); + }, + }; +} diff --git a/src/plugins/dashboard/public/dashboard_api/data_views_manager.ts b/src/plugins/dashboard/public/dashboard_api/data_views_manager.ts new file mode 100644 index 0000000000000..000c1e815b2b1 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_api/data_views_manager.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { uniqBy } from 'lodash'; +import { BehaviorSubject, combineLatest, Observable, of, switchMap } from 'rxjs'; + +import { DataView } from '@kbn/data-views-plugin/common'; +import { combineCompatibleChildrenApis } from '@kbn/presentation-containers'; +import { + apiPublishesDataViews, + PublishesDataViews, + PublishingSubject, +} from '@kbn/presentation-publishing'; + +import { ControlGroupApi } from '@kbn/controls-plugin/public'; +import { dataService } from '../services/kibana_services'; + +export function initializeDataViewsManager( + controlGroupApi$: PublishingSubject, + children$: PublishingSubject<{ [key: string]: unknown }> +) { + const dataViews = new BehaviorSubject([]); + + const controlGroupDataViewsPipe: Observable = controlGroupApi$.pipe( + switchMap((controlGroupApi) => { + return controlGroupApi ? controlGroupApi.dataViews : of([]); + }) + ); + + const childDataViewsPipe = combineCompatibleChildrenApis( + { children$ }, + 'dataViews', + apiPublishesDataViews, + [] + ); + + const dataViewsSubscription = combineLatest([controlGroupDataViewsPipe, childDataViewsPipe]) + .pipe( + switchMap(async ([controlGroupDataViews, childDataViews]) => { + const allDataViews = [...(controlGroupDataViews ?? []), ...childDataViews]; + if (allDataViews.length === 0) { + try { + const defaultDataView = await dataService.dataViews.getDefaultDataView(); + if (defaultDataView) { + allDataViews.push(defaultDataView); + } + } catch (error) { + // ignore error getting default data view + } + } + return uniqBy(allDataViews, 'id'); + }) + ) + .subscribe((newDataViews) => { + dataViews.next(newDataViews); + }); + + return { + api: { + dataViews, + }, + cleanup: () => { + dataViewsSubscription.unsubscribe(); + }, + }; +} diff --git a/src/plugins/dashboard/public/dashboard_api/get_dashboard_api.ts b/src/plugins/dashboard/public/dashboard_api/get_dashboard_api.ts index 2af37846eecdb..02c29968e526d 100644 --- a/src/plugins/dashboard/public/dashboard_api/get_dashboard_api.ts +++ b/src/plugins/dashboard/public/dashboard_api/get_dashboard_api.ts @@ -7,47 +7,268 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { BehaviorSubject } from 'rxjs'; -import type { DashboardContainerInput } from '../../common'; +import { BehaviorSubject, debounceTime, merge } from 'rxjs'; +import { omit } from 'lodash'; +import { v4 } from 'uuid'; +import type { Reference } from '@kbn/content-management-utils'; +import { ControlGroupApi, ControlGroupSerializedState } from '@kbn/controls-plugin/public'; +import { EmbeddablePackageState } from '@kbn/embeddable-plugin/public'; +import { + getReferencesForControls, + getReferencesForPanelId, +} from '../../common/dashboard_container/persistable_state/dashboard_container_references'; import { initializeTrackPanel } from './track_panel'; import { initializeTrackOverlay } from './track_overlay'; -import { initializeUnsavedChanges } from './unsaved_changes'; - -export interface InitialComponentState { - anyMigrationRun: boolean; - isEmbeddedExternally: boolean; - lastSavedInput: DashboardContainerInput; - lastSavedId: string | undefined; - managed: boolean; - fullScreenMode: boolean; -} +import { initializeUnsavedChangesManager } from './unsaved_changes_manager'; +import { DASHBOARD_APP_ID, DEFAULT_DASHBOARD_INPUT } from '../dashboard_constants'; +import { LoadDashboardReturn } from '../services/dashboard_content_management_service/types'; +import { initializePanelsManager } from './panels_manager'; +import { + DASHBOARD_API_TYPE, + DashboardApi, + DashboardCreationOptions, + DashboardInternalApi, + DashboardState, +} from './types'; +import { initializeDataViewsManager } from './data_views_manager'; +import { initializeSettingsManager } from './settings_manager'; +import { initializeUnifiedSearchManager } from './unified_search_manager'; +import { initializeDataLoadingManager } from './data_loading_manager'; +import { PANELS_CONTROL_GROUP_KEY } from '../services/dashboard_backup_service'; +import { getDashboardContentManagementService } from '../services/dashboard_content_management_service'; +import { openSaveModal } from './open_save_modal'; +import { initializeSearchSessionManager } from './search_session_manager'; +import { initializeViewModeManager } from './view_mode_manager'; +import { UnsavedPanelState } from '../dashboard_container/types'; +import { initializeTrackContentfulRender } from './track_contentful_render'; -export function getDashboardApi( - initialComponentState: InitialComponentState, - untilEmbeddableLoaded: (id: string) => Promise -) { +export function getDashboardApi({ + creationOptions, + incomingEmbeddable, + initialState, + initialPanelsRuntimeState, + savedObjectResult, + savedObjectId, +}: { + creationOptions?: DashboardCreationOptions; + incomingEmbeddable?: EmbeddablePackageState | undefined; + initialState: DashboardState; + initialPanelsRuntimeState?: UnsavedPanelState; + savedObjectResult?: LoadDashboardReturn; + savedObjectId?: string; +}) { const animatePanelTransforms$ = new BehaviorSubject(false); // set panel transforms to false initially to avoid panels animating on initial render. - const fullScreenMode$ = new BehaviorSubject(initialComponentState.fullScreenMode); - const managed$ = new BehaviorSubject(initialComponentState.managed); - const savedObjectId$ = new BehaviorSubject(initialComponentState.lastSavedId); + const controlGroupApi$ = new BehaviorSubject(undefined); + const fullScreenMode$ = new BehaviorSubject(creationOptions?.fullScreenMode ?? false); + const isManaged = savedObjectResult?.managed ?? false; + let references: Reference[] = savedObjectResult?.references ?? []; + const savedObjectId$ = new BehaviorSubject(savedObjectId); - const trackPanel = initializeTrackPanel(untilEmbeddableLoaded); + const viewModeManager = initializeViewModeManager(incomingEmbeddable, savedObjectResult); + const trackPanel = initializeTrackPanel( + async (id: string) => await panelsManager.api.untilEmbeddableLoaded(id) + ); + function getPanelReferences(id: string) { + const panelReferences = getReferencesForPanelId(id, references); + // references from old installations may not be prefixed with panel id + // fall back to passing all references in these cases to preserve backwards compatability + return panelReferences.length > 0 ? panelReferences : references; + } + const panelsManager = initializePanelsManager( + incomingEmbeddable, + initialState.panels, + initialPanelsRuntimeState ?? {}, + trackPanel, + getPanelReferences, + (refs: Reference[]) => references.push(...refs) + ); + const dataLoadingManager = initializeDataLoadingManager(panelsManager.api.children$); + const dataViewsManager = initializeDataViewsManager( + controlGroupApi$, + panelsManager.api.children$ + ); + const unifiedSearchManager = initializeUnifiedSearchManager( + initialState, + controlGroupApi$, + dataLoadingManager.internalApi.waitForPanelsToLoad$, + () => unsavedChangesManager.internalApi.getLastSavedState(), + creationOptions + ); + const settingsManager = initializeSettingsManager({ + initialState, + setTimeRestore: unifiedSearchManager.internalApi.setTimeRestore, + timeRestore$: unifiedSearchManager.internalApi.timeRestore$, + }); + const unsavedChangesManager = initializeUnsavedChangesManager({ + anyMigrationRun: savedObjectResult?.anyMigrationRun ?? false, + creationOptions, + controlGroupApi$, + lastSavedState: omit(savedObjectResult?.dashboardInput, 'controlGroupInput') ?? { + ...DEFAULT_DASHBOARD_INPUT, + }, + panelsManager, + savedObjectId$, + settingsManager, + viewModeManager, + unifiedSearchManager, + }); + async function getState() { + const { panels, references: panelReferences } = await panelsManager.internalApi.getState(); + const dashboardState: DashboardState = { + ...settingsManager.internalApi.getState(), + ...unifiedSearchManager.internalApi.getState(), + panels, + viewMode: viewModeManager.api.viewMode.value, + }; - return { + const controlGroupApi = controlGroupApi$.value; + let controlGroupReferences: Reference[] | undefined; + if (controlGroupApi) { + const { rawState: controlGroupSerializedState, references: extractedReferences } = + await controlGroupApi.serializeState(); + controlGroupReferences = extractedReferences; + dashboardState.controlGroupInput = controlGroupSerializedState; + } + + return { + dashboardState, + controlGroupReferences, + panelReferences, + }; + } + + const trackOverlayApi = initializeTrackOverlay(trackPanel.setFocusedPanelId); + + // Start animating panel transforms 500 ms after dashboard is created. + setTimeout(() => animatePanelTransforms$.next(true), 500); + + const dashboardApi = { + ...viewModeManager.api, + ...dataLoadingManager.api, + ...dataViewsManager.api, + ...panelsManager.api, + ...settingsManager.api, ...trackPanel, - ...initializeTrackOverlay(trackPanel.setFocusedPanelId), - ...initializeUnsavedChanges( - initialComponentState.anyMigrationRun, - initialComponentState.lastSavedInput - ), - animatePanelTransforms$, + ...unifiedSearchManager.api, + ...unsavedChangesManager.api, + ...trackOverlayApi, + ...initializeTrackContentfulRender(), + controlGroupApi$, + executionContext: { + type: 'dashboard', + description: settingsManager.api.panelTitle.value, + }, fullScreenMode$, - isEmbeddedExternally: initialComponentState.isEmbeddedExternally, - managed$, + getAppContext: () => { + const embeddableAppContext = creationOptions?.getEmbeddableAppContext?.(savedObjectId$.value); + return { + ...embeddableAppContext, + currentAppId: embeddableAppContext?.currentAppId ?? DASHBOARD_APP_ID, + }; + }, + isEmbeddedExternally: Boolean(creationOptions?.isEmbeddedExternally), + isManaged, + reload$: merge( + unifiedSearchManager.internalApi.controlGroupReload$, + unifiedSearchManager.internalApi.panelsReload$ + ).pipe(debounceTime(0)), + runInteractiveSave: async () => { + trackOverlayApi.clearOverlays(); + const saveResult = await openSaveModal({ + isManaged, + lastSavedId: savedObjectId$.value, + viewMode: viewModeManager.api.viewMode.value, + ...(await getState()), + }); + + if (saveResult) { + unsavedChangesManager.internalApi.onSave(saveResult.savedState); + const settings = settingsManager.api.getSettings(); + settingsManager.api.setSettings({ + ...settings, + hidePanelTitles: settings.hidePanelTitles ?? false, + description: saveResult.savedState.description, + tags: saveResult.savedState.tags, + timeRestore: saveResult.savedState.timeRestore, + title: saveResult.savedState.title, + }); + savedObjectId$.next(saveResult.id); + + references = saveResult.references ?? []; + } + + return saveResult; + }, + runQuickSave: async () => { + if (isManaged) return; + const { controlGroupReferences, dashboardState, panelReferences } = await getState(); + const saveResult = await getDashboardContentManagementService().saveDashboardState({ + controlGroupReferences, + currentState: dashboardState, + panelReferences, + saveOptions: {}, + lastSavedId: savedObjectId$.value, + }); + + unsavedChangesManager.internalApi.onSave(dashboardState); + references = saveResult.references ?? []; + + return; + }, savedObjectId: savedObjectId$, - setAnimatePanelTransforms: (animate: boolean) => animatePanelTransforms$.next(animate), setFullScreenMode: (fullScreenMode: boolean) => fullScreenMode$.next(fullScreenMode), - setManaged: (managed: boolean) => managed$.next(managed), setSavedObjectId: (id: string | undefined) => savedObjectId$.next(id), + type: DASHBOARD_API_TYPE as 'dashboard', + uuid: v4(), + } as Omit; + + const searchSessionManager = initializeSearchSessionManager( + creationOptions?.searchSessionSettings, + incomingEmbeddable, + dashboardApi + ); + + return { + api: { + ...dashboardApi, + ...searchSessionManager.api, + }, + internalApi: { + ...panelsManager.internalApi, + ...unifiedSearchManager.internalApi, + animatePanelTransforms$, + getSerializedStateForControlGroup: () => { + return { + rawState: savedObjectResult?.dashboardInput?.controlGroupInput + ? savedObjectResult.dashboardInput.controlGroupInput + : ({ + autoApplySelections: true, + chainingSystem: 'HIERARCHICAL', + controls: [], + ignoreParentSettings: { + ignoreFilters: false, + ignoreQuery: false, + ignoreTimerange: false, + ignoreValidations: false, + }, + labelPosition: 'oneLine', + showApplySelections: false, + } as ControlGroupSerializedState), + references: getReferencesForControls(references), + }; + }, + getRuntimeStateForControlGroup: () => { + return panelsManager!.api.getRuntimeStateForChild(PANELS_CONTROL_GROUP_KEY); + }, + setControlGroupApi: (controlGroupApi: ControlGroupApi) => + controlGroupApi$.next(controlGroupApi), + } as DashboardInternalApi, + cleanup: () => { + dataLoadingManager.cleanup(); + dataViewsManager.cleanup(); + searchSessionManager.cleanup(); + unifiedSearchManager.cleanup(); + unsavedChangesManager.cleanup(); + }, }; } diff --git a/src/plugins/dashboard/public/dashboard_api/load_dashboard_api.ts b/src/plugins/dashboard/public/dashboard_api/load_dashboard_api.ts new file mode 100644 index 0000000000000..00fd32fd56ac1 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_api/load_dashboard_api.ts @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { ContentInsightsClient } from '@kbn/content-management-content-insights-public'; +import { DashboardPanelMap } from '../../common'; +import { getDashboardContentManagementService } from '../services/dashboard_content_management_service'; +import { DashboardCreationOptions, DashboardState } from './types'; +import { getDashboardApi } from './get_dashboard_api'; +import { startQueryPerformanceTracking } from '../dashboard_container/embeddable/create/performance/query_performance_tracking'; +import { coreServices } from '../services/kibana_services'; +import { + PANELS_CONTROL_GROUP_KEY, + getDashboardBackupService, +} from '../services/dashboard_backup_service'; +import { UnsavedPanelState } from '../dashboard_container/types'; +import { DEFAULT_DASHBOARD_INPUT } from '../dashboard_constants'; + +export async function loadDashboardApi({ + getCreationOptions, + savedObjectId, +}: { + getCreationOptions?: () => Promise; + savedObjectId?: string; +}) { + const creationStartTime = performance.now(); + const creationOptions = await getCreationOptions?.(); + const incomingEmbeddable = creationOptions?.getIncomingEmbeddable?.(); + const savedObjectResult = await getDashboardContentManagementService().loadDashboardState({ + id: savedObjectId, + }); + + // -------------------------------------------------------------------------------------- + // Run validation. + // -------------------------------------------------------------------------------------- + const validationResult = + savedObjectResult && creationOptions?.validateLoadedSavedObject?.(savedObjectResult); + if (validationResult === 'invalid') { + // throw error to stop the rest of Dashboard loading and make the factory throw an Error + throw new Error('Dashboard failed saved object result validation'); + } else if (validationResult === 'redirected') { + return; + } + + // -------------------------------------------------------------------------------------- + // Combine saved object state and session storage state + // -------------------------------------------------------------------------------------- + const dashboardBackupState = getDashboardBackupService().getState(savedObjectResult.dashboardId); + const initialPanelsRuntimeState: UnsavedPanelState = creationOptions?.useSessionStorageIntegration + ? dashboardBackupState?.panels ?? {} + : {}; + + const sessionStorageInput = ((): Partial | undefined => { + if (!creationOptions?.useSessionStorageIntegration) return; + return dashboardBackupState?.dashboardState; + })(); + + const combinedSessionState: DashboardState = { + ...DEFAULT_DASHBOARD_INPUT, + ...(savedObjectResult?.dashboardInput ?? {}), + ...sessionStorageInput, + }; + + // -------------------------------------------------------------------------------------- + // Combine state with overrides. + // -------------------------------------------------------------------------------------- + const overrideState = creationOptions?.getInitialInput?.(); + if (overrideState?.panels) { + const overridePanels: DashboardPanelMap = {}; + for (const panel of Object.values(overrideState?.panels)) { + overridePanels[panel.explicitInput.id] = { + ...panel, + + /** + * here we need to keep the state of the panel that was already in the Dashboard if one exists. + * This is because this state will become the "last saved state" for this panel. + */ + ...(combinedSessionState.panels[panel.explicitInput.id] ?? []), + }; + /** + * We also need to add the state of this react embeddable into the runtime state to be restored. + */ + initialPanelsRuntimeState[panel.explicitInput.id] = panel.explicitInput; + } + overrideState.panels = overridePanels; + } + // Back up any view mode passed in explicitly. + if (overrideState?.viewMode) { + getDashboardBackupService().storeViewMode(overrideState?.viewMode); + } + if (overrideState?.controlGroupState) { + initialPanelsRuntimeState[PANELS_CONTROL_GROUP_KEY] = overrideState.controlGroupState; + } + + // -------------------------------------------------------------------------------------- + // get dashboard Api + // -------------------------------------------------------------------------------------- + const { api, cleanup, internalApi } = getDashboardApi({ + creationOptions, + incomingEmbeddable, + initialState: { + ...combinedSessionState, + ...overrideState, + }, + initialPanelsRuntimeState, + savedObjectResult, + savedObjectId, + }); + + const performanceSubscription = startQueryPerformanceTracking(api, { + firstLoad: true, + creationStartTime, + }); + + if (savedObjectId && !incomingEmbeddable) { + // We count a new view every time a user opens a dashboard, both in view or edit mode + // We don't count views when a user is editing a dashboard and is returning from an editor after saving + // however, there is an edge case that we now count a new view when a user is editing a dashboard and is returning from an editor by canceling + // TODO: this should be revisited by making embeddable transfer support canceling logic https://github.com/elastic/kibana/issues/190485 + const contentInsightsClient = new ContentInsightsClient( + { http: coreServices.http }, + { domainId: 'dashboard' } + ); + contentInsightsClient.track(savedObjectId, 'viewed'); + } + + return { + api, + cleanup: () => { + cleanup(); + performanceSubscription.unsubscribe(); + }, + internalApi, + }; +} diff --git a/src/plugins/dashboard/public/dashboard_api/open_save_modal.tsx b/src/plugins/dashboard/public/dashboard_api/open_save_modal.tsx new file mode 100644 index 0000000000000..e5b2676d7198f --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_api/open_save_modal.tsx @@ -0,0 +1,171 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { ViewMode } from '@kbn/presentation-publishing'; +import type { Reference } from '@kbn/content-management-utils'; +import { reportPerformanceMetricEvent } from '@kbn/ebt-tools'; +import { showSaveModal } from '@kbn/saved-objects-plugin/public'; +import { i18n } from '@kbn/i18n'; +import { SaveDashboardReturn } from '../services/dashboard_content_management_service/types'; +import { DashboardSaveOptions } from '../dashboard_container/types'; +import { coreServices, dataService, savedObjectsTaggingService } from '../services/kibana_services'; +import { getDashboardContentManagementService } from '../services/dashboard_content_management_service'; +import { DashboardState } from './types'; +import { DASHBOARD_CONTENT_ID, SAVED_OBJECT_POST_TIME } from '../dashboard_constants'; +import { extractTitleAndCount } from '../dashboard_container/embeddable/api/lib/extract_title_and_count'; +import { DashboardSaveModal } from '../dashboard_container/embeddable/api/overlays/save_modal'; + +/** + * @description exclusively for user directed dashboard save actions, also + * accounts for scenarios of cloning elastic managed dashboard into user managed dashboards + */ +export async function openSaveModal({ + controlGroupReferences, + dashboardState, + isManaged, + lastSavedId, + panelReferences, + viewMode, +}: { + controlGroupReferences?: Reference[]; + dashboardState: DashboardState; + isManaged: boolean; + lastSavedId: string | undefined; + panelReferences: Reference[]; + viewMode: ViewMode; +}) { + if (viewMode === 'edit' && isManaged) { + return undefined; + } + const dashboardContentManagementService = getDashboardContentManagementService(); + const saveAsTitle = lastSavedId + ? await getSaveAsTitle(dashboardState.title) + : dashboardState.title; + return new Promise<(SaveDashboardReturn & { savedState: DashboardState }) | undefined>( + (resolve, reject) => { + const onSaveAttempt = async ({ + newTags, + newTitle, + newDescription, + newCopyOnSave, + newTimeRestore, + onTitleDuplicate, + isTitleDuplicateConfirmed, + }: DashboardSaveOptions): Promise => { + const saveOptions = { + confirmOverwrite: false, + isTitleDuplicateConfirmed, + onTitleDuplicate, + saveAsCopy: lastSavedId ? true : newCopyOnSave, + }; + + try { + if ( + !(await dashboardContentManagementService.checkForDuplicateDashboardTitle({ + title: newTitle, + onTitleDuplicate, + lastSavedTitle: dashboardState.title, + copyOnSave: saveOptions.saveAsCopy, + isTitleDuplicateConfirmed, + })) + ) { + return {}; + } + + const dashboardStateToSave: DashboardState = { + ...dashboardState, + title: newTitle, + tags: savedObjectsTaggingService && newTags ? newTags : ([] as string[]), + description: newDescription, + timeRestore: newTimeRestore, + timeRange: newTimeRestore + ? dataService.query.timefilter.timefilter.getTime() + : undefined, + refreshInterval: newTimeRestore + ? dataService.query.timefilter.timefilter.getRefreshInterval() + : undefined, + }; + + // TODO If this is a managed dashboard - unlink all by reference embeddables on clone + // https://github.com/elastic/kibana/issues/190138 + + const beforeAddTime = window.performance.now(); + + const saveResult = await dashboardContentManagementService.saveDashboardState({ + controlGroupReferences, + panelReferences, + saveOptions, + currentState: dashboardStateToSave, + lastSavedId, + }); + + const addDuration = window.performance.now() - beforeAddTime; + + reportPerformanceMetricEvent(coreServices.analytics, { + eventName: SAVED_OBJECT_POST_TIME, + duration: addDuration, + meta: { + saved_object_type: DASHBOARD_CONTENT_ID, + }, + }); + + resolve({ ...saveResult, savedState: dashboardStateToSave }); + return saveResult; + } catch (error) { + reject(error); + return error; + } + }; + + showSaveModal( + resolve(undefined)} + timeRestore={dashboardState.timeRestore} + showStoreTimeOnSave={!lastSavedId} + description={dashboardState.description ?? ''} + showCopyOnSave={false} + onSave={onSaveAttempt} + customModalTitle={getCustomModalTitle(viewMode)} + /> + ); + } + ); +} + +function getCustomModalTitle(viewMode: ViewMode) { + if (viewMode === 'edit') + return i18n.translate('dashboard.topNav.editModeInteractiveSave.modalTitle', { + defaultMessage: 'Save as new dashboard', + }); + + if (viewMode === 'view') + return i18n.translate('dashboard.topNav.viewModeInteractiveSave.modalTitle', { + defaultMessage: 'Duplicate dashboard', + }); + return undefined; +} + +async function getSaveAsTitle(title: string) { + const [baseTitle, baseCount] = extractTitleAndCount(title); + let saveAsTitle = `${baseTitle} (${baseCount + 1})`; + await getDashboardContentManagementService().checkForDuplicateDashboardTitle({ + title: saveAsTitle, + lastSavedTitle: title, + copyOnSave: true, + isTitleDuplicateConfirmed: false, + onTitleDuplicate(speculativeSuggestion) { + saveAsTitle = speculativeSuggestion; + }, + }); + + return saveAsTitle; +} diff --git a/src/plugins/dashboard/public/dashboard_api/panels_manager.ts b/src/plugins/dashboard/public/dashboard_api/panels_manager.ts new file mode 100644 index 0000000000000..4f082d1c0484f --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_api/panels_manager.ts @@ -0,0 +1,475 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { BehaviorSubject, merge } from 'rxjs'; +import { filter, map, max } from 'lodash'; +import { v4 } from 'uuid'; +import { asyncForEach } from '@kbn/std'; +import type { Reference } from '@kbn/content-management-utils'; +import { METRIC_TYPE } from '@kbn/analytics'; +import { + PanelPackage, + SerializedPanelState, + apiHasSerializableState, +} from '@kbn/presentation-containers'; +import { + DefaultEmbeddableApi, + EmbeddablePackageState, + PanelNotFoundError, +} from '@kbn/embeddable-plugin/public'; +import { + StateComparators, + apiHasInPlaceLibraryTransforms, + apiHasLibraryTransforms, + apiPublishesPanelTitle, + apiPublishesUnsavedChanges, + getPanelTitle, + stateHasTitles, +} from '@kbn/presentation-publishing'; +import { cloneDeep } from 'lodash'; +import { apiHasSnapshottableState } from '@kbn/presentation-containers/interfaces/serialized_state'; +import { i18n } from '@kbn/i18n'; +import { coreServices, usageCollectionService } from '../services/kibana_services'; +import { DashboardPanelMap, DashboardPanelState, prefixReferencesFromPanel } from '../../common'; +import type { initializeTrackPanel } from './track_panel'; +import { getPanelAddedSuccessString } from '../dashboard_app/_dashboard_app_strings'; +import { runPanelPlacementStrategy } from '../dashboard_container/panel_placement/place_new_panel_strategies'; +import { + DASHBOARD_UI_METRIC_ID, + DEFAULT_PANEL_HEIGHT, + DEFAULT_PANEL_WIDTH, + PanelPlacementStrategy, +} from '../dashboard_constants'; +import { getDashboardPanelPlacementSetting } from '../dashboard_container/panel_placement/panel_placement_registry'; +import { UnsavedPanelState } from '../dashboard_container/types'; +import { DashboardState } from './types'; +import { arePanelLayoutsEqual } from './are_panel_layouts_equal'; +import { dashboardClonePanelActionStrings } from '../dashboard_actions/_dashboard_actions_strings'; +import { placeClonePanel } from '../dashboard_container/panel_placement'; + +export function initializePanelsManager( + incomingEmbeddable: EmbeddablePackageState | undefined, + initialPanels: DashboardPanelMap, + initialPanelsRuntimeState: UnsavedPanelState, + trackPanel: ReturnType, + getReferencesForPanelId: (id: string) => Reference[], + pushReferences: (references: Reference[]) => void +) { + const children$ = new BehaviorSubject<{ + [key: string]: unknown; + }>({}); + const panels$ = new BehaviorSubject(initialPanels); + function setPanels(panels: DashboardPanelMap) { + if (panels !== panels$.value) panels$.next(panels); + } + let restoredRuntimeState: UnsavedPanelState = initialPanelsRuntimeState; + + function setRuntimeStateForChild(childId: string, state: object) { + restoredRuntimeState[childId] = state; + } + + // -------------------------------------------------------------------------------------- + // Place the incoming embeddable if there is one + // -------------------------------------------------------------------------------------- + if (incomingEmbeddable) { + let incomingEmbeddablePanelState: DashboardPanelState; + if ( + incomingEmbeddable.embeddableId && + Boolean(panels$.value[incomingEmbeddable.embeddableId]) + ) { + // this embeddable already exists, just update the explicit input. + incomingEmbeddablePanelState = panels$.value[incomingEmbeddable.embeddableId]; + const sameType = incomingEmbeddablePanelState.type === incomingEmbeddable.type; + + incomingEmbeddablePanelState.type = incomingEmbeddable.type; + setRuntimeStateForChild(incomingEmbeddable.embeddableId, { + // if the incoming panel is the same type as what was there before we can safely spread the old panel's explicit input + ...(sameType ? incomingEmbeddablePanelState.explicitInput : {}), + + ...incomingEmbeddable.input, + id: incomingEmbeddable.embeddableId, + + // maintain hide panel titles setting. + hidePanelTitles: incomingEmbeddablePanelState.explicitInput.hidePanelTitles, + }); + incomingEmbeddablePanelState.explicitInput = { + id: incomingEmbeddablePanelState.explicitInput.id, + }; + } else { + // otherwise this incoming embeddable is brand new. + const embeddableId = incomingEmbeddable.embeddableId ?? v4(); + setRuntimeStateForChild(embeddableId, incomingEmbeddable.input); + const { newPanelPlacement } = runPanelPlacementStrategy( + PanelPlacementStrategy.findTopLeftMostOpenSpace, + { + width: incomingEmbeddable.size?.width ?? DEFAULT_PANEL_WIDTH, + height: incomingEmbeddable.size?.height ?? DEFAULT_PANEL_HEIGHT, + currentPanels: panels$.value, + } + ); + incomingEmbeddablePanelState = { + explicitInput: { id: embeddableId }, + type: incomingEmbeddable.type, + gridData: { + ...newPanelPlacement, + i: embeddableId, + }, + }; + } + + setPanels({ + ...panels$.value, + [incomingEmbeddablePanelState.explicitInput.id]: incomingEmbeddablePanelState, + }); + trackPanel.setScrollToPanelId(incomingEmbeddablePanelState.explicitInput.id); + trackPanel.setHighlightPanelId(incomingEmbeddablePanelState.explicitInput.id); + } + + async function untilEmbeddableLoaded(id: string): Promise { + if (!panels$.value[id]) { + throw new PanelNotFoundError(); + } + + if (children$.value[id]) { + return children$.value[id] as ApiType; + } + + return new Promise((resolve, reject) => { + const subscription = merge(children$, panels$).subscribe(() => { + if (children$.value[id]) { + subscription.unsubscribe(); + resolve(children$.value[id] as ApiType); + } + + // If we hit this, the panel was removed before the embeddable finished loading. + if (panels$.value[id] === undefined) { + subscription.unsubscribe(); + resolve(undefined); + } + }); + }); + } + + async function getDashboardPanelFromId(panelId: string) { + const panel = panels$.value[panelId]; + const child = children$.value[panelId]; + if (!child || !panel) throw new PanelNotFoundError(); + const serialized = apiHasSerializableState(child) + ? await child.serializeState() + : { rawState: {} }; + return { + type: panel.type, + explicitInput: { ...panel.explicitInput, ...serialized.rawState }, + gridData: panel.gridData, + references: serialized.references, + }; + } + + async function getPanelTitles(): Promise { + const titles: string[] = []; + await asyncForEach(Object.keys(panels$.value), async (id) => { + const childApi = await untilEmbeddableLoaded(id); + const title = apiPublishesPanelTitle(childApi) ? getPanelTitle(childApi) : ''; + if (title) titles.push(title); + }); + return titles; + } + + async function duplicateReactEmbeddableInput( + childApi: unknown, + panelToClone: DashboardPanelState, + panelTitles: string[] + ) { + const id = v4(); + const lastTitle = apiPublishesPanelTitle(childApi) ? getPanelTitle(childApi) ?? '' : ''; + const newTitle = getClonedPanelTitle(panelTitles, lastTitle); + + /** + * For react embeddables that have library transforms, we need to ensure + * to clone them with serialized state and references. + * + * TODO: remove this section once all by reference capable react embeddables + * use in-place library transforms + */ + if (apiHasLibraryTransforms(childApi)) { + const byValueSerializedState = await childApi.getByValueState(); + if (panelToClone.references) { + pushReferences(prefixReferencesFromPanel(id, panelToClone.references)); + } + return { + type: panelToClone.type, + explicitInput: { + ...byValueSerializedState, + title: newTitle, + id, + }, + }; + } + + const runtimeSnapshot = (() => { + if (apiHasInPlaceLibraryTransforms(childApi)) return childApi.getByValueRuntimeSnapshot(); + return apiHasSnapshottableState(childApi) ? childApi.snapshotRuntimeState() : {}; + })(); + if (stateHasTitles(runtimeSnapshot)) runtimeSnapshot.title = newTitle; + + setRuntimeStateForChild(id, runtimeSnapshot); + return { + type: panelToClone.type, + explicitInput: { + id, + }, + }; + } + + return { + api: { + addNewPanel: async ( + panelPackage: PanelPackage, + displaySuccessMessage?: boolean + ) => { + usageCollectionService?.reportUiCounter( + DASHBOARD_UI_METRIC_ID, + METRIC_TYPE.CLICK, + panelPackage.panelType + ); + + const newId = v4(); + + const getCustomPlacementSettingFunc = getDashboardPanelPlacementSetting( + panelPackage.panelType + ); + + const customPlacementSettings = getCustomPlacementSettingFunc + ? await getCustomPlacementSettingFunc(panelPackage.initialState) + : undefined; + + const { newPanelPlacement, otherPanels } = runPanelPlacementStrategy( + customPlacementSettings?.strategy ?? PanelPlacementStrategy.findTopLeftMostOpenSpace, + { + currentPanels: panels$.value, + height: customPlacementSettings?.height ?? DEFAULT_PANEL_HEIGHT, + width: customPlacementSettings?.width ?? DEFAULT_PANEL_WIDTH, + } + ); + const newPanel: DashboardPanelState = { + type: panelPackage.panelType, + gridData: { + ...newPanelPlacement, + i: newId, + }, + explicitInput: { + id: newId, + }, + }; + if (panelPackage.initialState) { + setRuntimeStateForChild(newId, panelPackage.initialState); + } + setPanels({ ...otherPanels, [newId]: newPanel }); + if (displaySuccessMessage) { + coreServices.notifications.toasts.addSuccess({ + title: getPanelAddedSuccessString(newPanel.explicitInput.title), + 'data-test-subj': 'addEmbeddableToDashboardSuccess', + }); + trackPanel.setScrollToPanelId(newId); + trackPanel.setHighlightPanelId(newId); + } + return await untilEmbeddableLoaded(newId); + }, + canRemovePanels: () => trackPanel.expandedPanelId.value === undefined, + children$, + duplicatePanel: async (idToDuplicate: string) => { + const panelToClone = await getDashboardPanelFromId(idToDuplicate); + + const duplicatedPanelState = await duplicateReactEmbeddableInput( + children$.value[idToDuplicate], + panelToClone, + await getPanelTitles() + ); + + coreServices.notifications.toasts.addSuccess({ + title: dashboardClonePanelActionStrings.getSuccessMessage(), + 'data-test-subj': 'addObjectToContainerSuccess', + }); + + const { newPanelPlacement, otherPanels } = placeClonePanel({ + width: panelToClone.gridData.w, + height: panelToClone.gridData.h, + currentPanels: panels$.value, + placeBesideId: panelToClone.explicitInput.id, + }); + + const newPanel = { + ...duplicatedPanelState, + gridData: { + ...newPanelPlacement, + i: duplicatedPanelState.explicitInput.id, + }, + }; + + setPanels({ + ...otherPanels, + [newPanel.explicitInput.id]: newPanel, + }); + }, + getDashboardPanelFromId, + getPanelCount: () => { + return Object.keys(panels$.value).length; + }, + getSerializedStateForChild: (childId: string) => { + const rawState = panels$.value[childId]?.explicitInput ?? { id: childId }; + const { id, ...serializedState } = rawState; + return Object.keys(serializedState).length === 0 + ? undefined + : { + rawState, + references: getReferencesForPanelId(childId), + }; + }, + getRuntimeStateForChild: (childId: string) => { + return restoredRuntimeState?.[childId]; + }, + panels$, + removePanel: (id: string) => { + const panels = { ...panels$.value }; + if (panels[id]) { + delete panels[id]; + setPanels(panels); + } + const children = { ...children$.value }; + if (children[id]) { + delete children[id]; + children$.next(children); + } + }, + replacePanel: async (idToRemove: string, { panelType, initialState }: PanelPackage) => { + const panels = { ...panels$.value }; + if (!panels[idToRemove]) { + throw new PanelNotFoundError(); + } + + const id = v4(); + const oldPanel = panels[idToRemove]; + delete panels[idToRemove]; + setPanels({ + ...panels, + [id]: { + ...oldPanel, + explicitInput: { ...initialState, id }, + type: panelType, + }, + }); + + const children = { ...children$.value }; + if (children[idToRemove]) { + delete children[idToRemove]; + children$.next(children); + } + + await untilEmbeddableLoaded(id); + return id; + }, + setPanels, + setRuntimeStateForChild, + untilEmbeddableLoaded, + }, + comparators: { + panels: [panels$, setPanels, arePanelLayoutsEqual], + } as StateComparators>, + internalApi: { + registerChildApi: (api: DefaultEmbeddableApi) => { + children$.next({ + ...children$.value, + [api.uuid]: api, + }); + }, + reset: (lastSavedState: DashboardState) => { + setPanels(lastSavedState.panels); + restoredRuntimeState = {}; + let resetChangedPanelCount = false; + const currentChildren = children$.value; + for (const panelId of Object.keys(currentChildren)) { + if (panels$.value[panelId]) { + const child = currentChildren[panelId]; + if (apiPublishesUnsavedChanges(child)) { + const success = child.resetUnsavedChanges(); + if (!success) { + coreServices.notifications.toasts.addWarning( + i18n.translate('dashboard.reset.panelError', { + defaultMessage: 'Unable to reset panel changes', + }) + ); + } + } + } else { + // if reset resulted in panel removal, we need to update the list of children + delete currentChildren[panelId]; + resetChangedPanelCount = true; + } + } + if (resetChangedPanelCount) children$.next(currentChildren); + }, + getState: async (): Promise<{ + panels: DashboardState['panels']; + references: Reference[]; + }> => { + const references: Reference[] = []; + const panels = cloneDeep(panels$.value); + + const serializePromises: Array< + Promise<{ uuid: string; serialized: SerializedPanelState }> + > = []; + for (const uuid of Object.keys(panels)) { + const api = children$.value[uuid]; + + if (apiHasSerializableState(api)) { + serializePromises.push( + (async () => { + const serialized = await api.serializeState(); + return { uuid, serialized }; + })() + ); + } + } + + const serializeResults = await Promise.all(serializePromises); + for (const result of serializeResults) { + panels[result.uuid].explicitInput = { ...result.serialized.rawState, id: result.uuid }; + references.push( + ...prefixReferencesFromPanel(result.uuid, result.serialized.references ?? []) + ); + } + + return { panels, references }; + }, + }, + }; +} + +function getClonedPanelTitle(panelTitles: string[], rawTitle: string) { + if (rawTitle === '') return ''; + + const clonedTag = dashboardClonePanelActionStrings.getClonedTag(); + const cloneRegex = new RegExp(`\\(${clonedTag}\\)`, 'g'); + const cloneNumberRegex = new RegExp(`\\(${clonedTag} [0-9]+\\)`, 'g'); + const baseTitle = rawTitle.replace(cloneNumberRegex, '').replace(cloneRegex, '').trim(); + const similarTitles = filter(panelTitles, (title: string) => { + return title.startsWith(baseTitle); + }); + + const cloneNumbers = map(similarTitles, (title: string) => { + if (title.match(cloneRegex)) return 0; + const cloneTag = title.match(cloneNumberRegex); + return cloneTag ? parseInt(cloneTag[0].replace(/[^0-9.]/g, ''), 10) : -1; + }); + const similarBaseTitlesCount = max(cloneNumbers) || 0; + + return similarBaseTitlesCount < 0 + ? baseTitle + ` (${clonedTag})` + : baseTitle + ` (${clonedTag} ${similarBaseTitlesCount + 1})`; +} diff --git a/src/plugins/dashboard/public/dashboard_api/search_session_manager.ts b/src/plugins/dashboard/public/dashboard_api/search_session_manager.ts new file mode 100644 index 0000000000000..dbca51f03ac19 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_api/search_session_manager.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { BehaviorSubject } from 'rxjs'; +import { EmbeddablePackageState } from '@kbn/embeddable-plugin/public'; +import { DashboardApi, DashboardCreationOptions } from './types'; +import { dataService } from '../services/kibana_services'; +import { startDashboardSearchSessionIntegration } from '../dashboard_container/embeddable/create/search_sessions/start_dashboard_search_session_integration'; + +export function initializeSearchSessionManager( + searchSessionSettings: DashboardCreationOptions['searchSessionSettings'], + incomingEmbeddable: EmbeddablePackageState | undefined, + dashboardApi: Omit +) { + const searchSessionId$ = new BehaviorSubject(undefined); + + let stopSearchSessionIntegration: (() => void) | undefined; + if (searchSessionSettings) { + const { sessionIdToRestore } = searchSessionSettings; + + // if this incoming embeddable has a session, continue it. + if (incomingEmbeddable?.searchSessionId) { + dataService.search.session.continue(incomingEmbeddable.searchSessionId); + } + if (sessionIdToRestore) { + dataService.search.session.restore(sessionIdToRestore); + } + const existingSession = dataService.search.session.getSessionId(); + + const initialSearchSessionId = + sessionIdToRestore ?? + (existingSession && incomingEmbeddable + ? existingSession + : dataService.search.session.start()); + searchSessionId$.next(initialSearchSessionId); + + stopSearchSessionIntegration = startDashboardSearchSessionIntegration( + { + ...dashboardApi, + searchSessionId$, + }, + searchSessionSettings, + (searchSessionId: string) => searchSessionId$.next(searchSessionId) + ); + } + return { + api: { + searchSessionId$, + }, + cleanup: () => { + stopSearchSessionIntegration?.(); + }, + }; +} diff --git a/src/plugins/dashboard/public/dashboard_api/settings_manager.ts b/src/plugins/dashboard/public/dashboard_api/settings_manager.ts new file mode 100644 index 0000000000000..0b4903a506a90 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_api/settings_manager.ts @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import fastIsEqual from 'fast-deep-equal'; +import { + PublishingSubject, + StateComparators, + initializeTitles, +} from '@kbn/presentation-publishing'; +import { BehaviorSubject } from 'rxjs'; +import { DashboardState } from './types'; +import { DEFAULT_DASHBOARD_INPUT } from '../dashboard_constants'; +import { DashboardStateFromSettingsFlyout } from '../dashboard_container/types'; + +export function initializeSettingsManager({ + initialState, + setTimeRestore, + timeRestore$, +}: { + initialState?: DashboardState; + setTimeRestore: (timeRestore: boolean) => void; + timeRestore$: PublishingSubject; +}) { + const syncColors$ = new BehaviorSubject( + initialState?.syncColors ?? DEFAULT_DASHBOARD_INPUT.syncColors + ); + function setSyncColors(syncColors: boolean) { + if (syncColors !== syncColors$.value) syncColors$.next(syncColors); + } + const syncCursor$ = new BehaviorSubject( + initialState?.syncCursor ?? DEFAULT_DASHBOARD_INPUT.syncCursor + ); + function setSyncCursor(syncCursor: boolean) { + if (syncCursor !== syncCursor$.value) syncCursor$.next(syncCursor); + } + const syncTooltips$ = new BehaviorSubject( + initialState?.syncTooltips ?? DEFAULT_DASHBOARD_INPUT.syncTooltips + ); + function setSyncTooltips(syncTooltips: boolean) { + if (syncTooltips !== syncTooltips$.value) syncTooltips$.next(syncTooltips); + } + const tags$ = new BehaviorSubject(initialState?.tags ?? DEFAULT_DASHBOARD_INPUT.tags); + function setTags(tags: string[]) { + if (!fastIsEqual(tags, tags$.value)) tags$.next(tags); + } + const titleManager = initializeTitles(initialState ?? {}); + const useMargins$ = new BehaviorSubject( + initialState?.useMargins ?? DEFAULT_DASHBOARD_INPUT.useMargins + ); + function setUseMargins(useMargins: boolean) { + if (useMargins !== useMargins$.value) useMargins$.next(useMargins); + } + + function getSettings() { + return { + ...titleManager.serializeTitles(), + syncColors: syncColors$.value, + syncCursor: syncCursor$.value, + syncTooltips: syncTooltips$.value, + tags: tags$.value, + timeRestore: timeRestore$.value, + useMargins: useMargins$.value, + }; + } + + function setSettings(settings: DashboardStateFromSettingsFlyout) { + setSyncColors(settings.syncColors); + setSyncCursor(settings.syncCursor); + setSyncTooltips(settings.syncTooltips); + setTags(settings.tags); + setTimeRestore(settings.timeRestore); + setUseMargins(settings.useMargins); + titleManager.titlesApi.setHidePanelTitle(settings.hidePanelTitles); + titleManager.titlesApi.setPanelDescription(settings.description); + titleManager.titlesApi.setPanelTitle(settings.title); + } + + return { + api: { + ...titleManager.titlesApi, + getSettings, + settings: { + syncColors$, + syncCursor$, + syncTooltips$, + useMargins$, + }, + setSettings, + setTags, + timeRestore$, + }, + comparators: { + ...titleManager.titleComparators, + syncColors: [syncColors$, setSyncColors], + syncCursor: [syncCursor$, setSyncCursor], + syncTooltips: [syncTooltips$, setSyncTooltips], + useMargins: [useMargins$, setUseMargins], + } as StateComparators< + Pick< + DashboardState, + | 'description' + | 'hidePanelTitles' + | 'syncColors' + | 'syncCursor' + | 'syncTooltips' + | 'title' + | 'useMargins' + > + >, + internalApi: { + getState: (): Pick< + DashboardState, + | 'description' + | 'hidePanelTitles' + | 'syncColors' + | 'syncCursor' + | 'syncTooltips' + | 'tags' + | 'title' + | 'useMargins' + > => { + const settings = getSettings(); + return { + ...settings, + title: settings.title ?? '', + hidePanelTitles: settings.hidePanelTitles ?? DEFAULT_DASHBOARD_INPUT.hidePanelTitles, + }; + }, + reset: (lastSavedState: DashboardState) => { + setSettings(lastSavedState); + }, + }, + }; +} diff --git a/src/plugins/dashboard/public/dashboard_api/track_contentful_render.ts b/src/plugins/dashboard/public/dashboard_api/track_contentful_render.ts new file mode 100644 index 0000000000000..359dadaa4261a --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_api/track_contentful_render.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { coreServices } from '../services/kibana_services'; + +// seperate from performance metrics +// reports when a dashboard renders with data +export function initializeTrackContentfulRender() { + let hadContentfulRender = false; + + return { + trackContentfulRender: () => { + if (!hadContentfulRender) { + coreServices.analytics.reportEvent('dashboard_loaded_with_data', {}); + } + hadContentfulRender = true; + }, + }; +} diff --git a/src/plugins/dashboard/public/dashboard_api/track_panel.ts b/src/plugins/dashboard/public/dashboard_api/track_panel.ts index b9f9b3218488b..07fe1134d4dcb 100644 --- a/src/plugins/dashboard/public/dashboard_api/track_panel.ts +++ b/src/plugins/dashboard/public/dashboard_api/track_panel.ts @@ -9,7 +9,7 @@ import { BehaviorSubject } from 'rxjs'; -export function initializeTrackPanel(untilEmbeddableLoaded: (id: string) => Promise) { +export function initializeTrackPanel(untilEmbeddableLoaded: (id: string) => Promise) { const expandedPanelId$ = new BehaviorSubject(undefined); const focusedPanelId$ = new BehaviorSubject(undefined); const highlightPanelId$ = new BehaviorSubject(undefined); @@ -27,7 +27,7 @@ export function initializeTrackPanel(untilEmbeddableLoaded: (id: string) => Prom return { expandedPanelId: expandedPanelId$, expandPanel: (panelId: string) => { - const isPanelExpanded = Boolean(expandedPanelId$.value); + const isPanelExpanded = panelId === expandedPanelId$.value; if (isPanelExpanded) { setExpandedPanelId(undefined); @@ -79,7 +79,6 @@ export function initializeTrackPanel(untilEmbeddableLoaded: (id: string) => Prom scrollToTop: () => { window.scroll(0, 0); }, - setExpandedPanelId, setFocusedPanelId: (id: string | undefined) => { if (focusedPanelId$.value !== id) focusedPanelId$.next(id); setScrollToPanelId(id); diff --git a/src/plugins/dashboard/public/dashboard_api/types.ts b/src/plugins/dashboard/public/dashboard_api/types.ts index ec89a93dcd66e..2cfe5aa615f3b 100644 --- a/src/plugins/dashboard/public/dashboard_api/types.ts +++ b/src/plugins/dashboard/public/dashboard_api/types.ts @@ -10,25 +10,36 @@ import { CanExpandPanels, HasRuntimeChildState, + HasSaveNotification, HasSerializedChildState, PresentationContainer, + PublishesSettings, SerializedPanelState, + TrackContentfulRender, TracksOverlays, } from '@kbn/presentation-containers'; import { EmbeddableAppContext, HasAppContext, + HasExecutionContext, HasType, + HasUniqueId, + PublishesDataLoading, PublishesDataViews, PublishesPanelDescription, PublishesPanelTitle, PublishesSavedObjectId, PublishesUnifiedSearch, PublishesViewMode, + PublishesWritableViewMode, PublishingSubject, ViewMode, } from '@kbn/presentation-publishing'; -import { ControlGroupApi, ControlGroupSerializedState } from '@kbn/controls-plugin/public'; +import { + ControlGroupApi, + ControlGroupRuntimeState, + ControlGroupSerializedState, +} from '@kbn/controls-plugin/public'; import { Filter, Query, TimeRange } from '@kbn/es-query'; import { DefaultEmbeddableApi, @@ -36,19 +47,27 @@ import { ErrorEmbeddable, IEmbeddable, } from '@kbn/embeddable-plugin/public'; -import { Observable } from 'rxjs'; -import { SearchSessionInfoProvider } from '@kbn/data-plugin/public'; +import { Observable, Subject } from 'rxjs'; +import { RefreshInterval, SearchSessionInfoProvider } from '@kbn/data-plugin/public'; import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; +import { PublishesReload } from '@kbn/presentation-publishing/interfaces/fetch/publishes_reload'; +import { PublishesSearchSession } from '@kbn/presentation-publishing/interfaces/fetch/publishes_search_session'; +import { LocatorPublic } from '@kbn/share-plugin/common'; import { DashboardPanelMap, DashboardPanelState } from '../../common'; +import type { DashboardOptions } from '../../server/content_management'; import { LoadDashboardReturn, SaveDashboardReturn, - SavedDashboardInput, } from '../services/dashboard_content_management_service/types'; -import { DashboardStateFromSettingsFlyout, UnsavedPanelState } from '../dashboard_container/types'; +import { + DashboardLocatorParams, + DashboardStateFromSettingsFlyout, +} from '../dashboard_container/types'; + +export const DASHBOARD_API_TYPE = 'dashboard'; export interface DashboardCreationOptions { - getInitialInput?: () => Partial; + getInitialInput?: () => Partial; getIncomingEmbeddable?: () => EmbeddablePackageState | undefined; @@ -74,28 +93,65 @@ export interface DashboardCreationOptions { getEmbeddableAppContext?: (dashboardId?: string) => EmbeddableAppContext; } +export interface DashboardState extends DashboardOptions { + // filter context to be passed to children + query: Query; + filters: Filter[]; + timeRestore: boolean; + timeRange?: TimeRange; + refreshInterval?: RefreshInterval; + + // dashboard meta info + title: string; + tags: string[]; + viewMode: ViewMode; + description?: string; + + // settings from DashboardOptions + + // dashboard contents + panels: DashboardPanelMap; + + /** + * Serialized control group state. + * Contains state loaded from dashboard saved object + */ + controlGroupInput?: ControlGroupSerializedState | undefined; + /** + * Runtime control group state. + * Contains state passed from dashboard locator + * Use runtime state when building input for portable dashboards + */ + controlGroupState?: Partial; +} + export type DashboardApi = CanExpandPanels & HasAppContext & + HasExecutionContext & HasRuntimeChildState & + HasSaveNotification & HasSerializedChildState & - HasType<'dashboard'> & + HasType & + HasUniqueId & PresentationContainer & + PublishesDataLoading & PublishesDataViews & PublishesPanelDescription & Pick & + PublishesReload & PublishesSavedObjectId & + PublishesSearchSession & + PublishesSettings & PublishesUnifiedSearch & PublishesViewMode & + PublishesWritableViewMode & + TrackContentfulRender & TracksOverlays & { - addFromLibrary: () => void; - animatePanelTransforms$: PublishingSubject; asyncResetToLastSavedState: () => Promise; controlGroupApi$: PublishingSubject; fullScreenMode$: PublishingSubject; focusedPanelId$: PublishingSubject; forceRefresh: () => void; - getRuntimeStateForControlGroup: () => UnsavedPanelState | undefined; - getSerializedStateForControlGroup: () => SerializedPanelState; getSettings: () => DashboardStateFromSettingsFlyout; getDashboardPanelFromId: (id: string) => Promise; hasOverlays$: PublishingSubject; @@ -104,27 +160,35 @@ export type DashboardApi = CanExpandPanels & highlightPanel: (panelRef: HTMLDivElement) => void; highlightPanelId$: PublishingSubject; isEmbeddedExternally: boolean; - managed$: PublishingSubject; + isManaged: boolean; + locator?: Pick, 'navigate' | 'getRedirectUrl'>; panels$: PublishingSubject; - registerChildApi: (api: DefaultEmbeddableApi) => void; - runInteractiveSave: (interactionMode: ViewMode) => Promise; + runInteractiveSave: () => Promise; runQuickSave: () => Promise; scrollToPanel: (panelRef: HTMLDivElement) => void; scrollToPanelId$: PublishingSubject; scrollToTop: () => void; - setControlGroupApi: (controlGroupApi: ControlGroupApi) => void; - setSettings: (settings: DashboardStateFromSettingsFlyout) => void; setFilters: (filters?: Filter[] | undefined) => void; setFullScreenMode: (fullScreenMode: boolean) => void; + setHighlightPanelId: (id: string | undefined) => void; setPanels: (panels: DashboardPanelMap) => void; setQuery: (query?: Query | undefined) => void; + setScrollToPanelId: (id: string | undefined) => void; + setSettings: (settings: DashboardStateFromSettingsFlyout) => void; setTags: (tags: string[]) => void; setTimeRange: (timeRange?: TimeRange | undefined) => void; - setViewMode: (viewMode: ViewMode) => void; - useMargins$: PublishingSubject; - // TODO replace with HasUniqueId once dashboard is refactored and navigateToDashboard is removed - uuid$: PublishingSubject; + unifiedSearchFilters$: PublishesUnifiedSearch['filters$']; // TODO remove types below this line - from legacy embeddable system untilEmbeddableLoaded: (id: string) => Promise; }; + +export interface DashboardInternalApi { + animatePanelTransforms$: PublishingSubject; + controlGroupReload$: Subject; + panelsReload$: Subject; + getRuntimeStateForControlGroup: () => object | undefined; + getSerializedStateForControlGroup: () => SerializedPanelState; + registerChildApi: (api: DefaultEmbeddableApi) => void; + setControlGroupApi: (controlGroupApi: ControlGroupApi) => void; +} diff --git a/src/plugins/dashboard/public/dashboard_api/unified_search_manager.ts b/src/plugins/dashboard/public/dashboard_api/unified_search_manager.ts new file mode 100644 index 0000000000000..e1f1d0a4e71d3 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_api/unified_search_manager.ts @@ -0,0 +1,371 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { + COMPARE_ALL_OPTIONS, + Filter, + Query, + TimeRange, + compareFilters, + isFilterPinned, +} from '@kbn/es-query'; +import { + BehaviorSubject, + Observable, + Subject, + Subscription, + combineLatest, + debounceTime, + distinctUntilChanged, + finalize, + map, + of, + switchMap, + tap, +} from 'rxjs'; +import fastIsEqual from 'fast-deep-equal'; +import { PublishingSubject, StateComparators } from '@kbn/presentation-publishing'; +import { ControlGroupApi } from '@kbn/controls-plugin/public'; +import { cloneDeep } from 'lodash'; +import { + GlobalQueryStateFromUrl, + RefreshInterval, + connectToQueryState, + syncGlobalQueryStateWithUrl, +} from '@kbn/data-plugin/public'; +import { cleanFiltersForSerialize } from '@kbn/presentation-util-plugin/public'; +import moment, { Moment } from 'moment'; +import { dataService } from '../services/kibana_services'; +import { DashboardCreationOptions, DashboardState } from './types'; +import { DEFAULT_DASHBOARD_INPUT, GLOBAL_STATE_STORAGE_KEY } from '../dashboard_constants'; + +export function initializeUnifiedSearchManager( + initialState: DashboardState, + controlGroupApi$: PublishingSubject, + waitForPanelsToLoad$: Observable, + getLastSavedState: () => DashboardState | undefined, + creationOptions?: DashboardCreationOptions +) { + const { + queryString, + filterManager, + timefilter: { timefilter: timefilterService }, + } = dataService.query; + + const controlGroupReload$ = new Subject(); + const filters$ = new BehaviorSubject(undefined); + const panelsReload$ = new Subject(); + const query$ = new BehaviorSubject(initialState.query); + // setAndSyncQuery method not needed since query synced with 2-way data binding + function setQuery(query: Query) { + if (!fastIsEqual(query, query$.value)) { + query$.next(query); + } + } + const refreshInterval$ = new BehaviorSubject( + initialState.refreshInterval + ); + function setRefreshInterval(refreshInterval: RefreshInterval) { + if (!fastIsEqual(refreshInterval, refreshInterval$.value)) { + refreshInterval$.next(refreshInterval); + } + } + function setAndSyncRefreshInterval(refreshInterval: RefreshInterval | undefined) { + const refreshIntervalOrDefault = + refreshInterval ?? timefilterService.getRefreshIntervalDefaults(); + setRefreshInterval(refreshIntervalOrDefault); + if (creationOptions?.useUnifiedSearchIntegration) { + timefilterService.setRefreshInterval(refreshIntervalOrDefault); + } + } + const timeRange$ = new BehaviorSubject(initialState.timeRange); + function setTimeRange(timeRange: TimeRange) { + if (!fastIsEqual(timeRange, timeRange$.value)) { + timeRange$.next(timeRange); + } + } + function setAndSyncTimeRange(timeRange: TimeRange | undefined) { + const timeRangeOrDefault = timeRange ?? timefilterService.getTimeDefaults(); + setTimeRange(timeRangeOrDefault); + if (creationOptions?.useUnifiedSearchIntegration) { + timefilterService.setTime(timeRangeOrDefault); + } + } + const timeRestore$ = new BehaviorSubject( + initialState?.timeRestore ?? DEFAULT_DASHBOARD_INPUT.timeRestore + ); + function setTimeRestore(timeRestore: boolean) { + if (timeRestore !== timeRestore$.value) timeRestore$.next(timeRestore); + } + const timeslice$ = new BehaviorSubject<[number, number] | undefined>(undefined); + const unifiedSearchFilters$ = new BehaviorSubject(initialState.filters); + // setAndSyncUnifiedSearchFilters method not needed since filters synced with 2-way data binding + function setUnifiedSearchFilters(unifiedSearchFilters: Filter[]) { + if (!fastIsEqual(unifiedSearchFilters, unifiedSearchFilters$.value)) { + unifiedSearchFilters$.next(unifiedSearchFilters); + } + } + + // -------------------------------------------------------------------------------------- + // Set up control group integration + // -------------------------------------------------------------------------------------- + const controlGroupSubscriptions: Subscription = new Subscription(); + const controlGroupFilters$ = controlGroupApi$.pipe( + switchMap((controlGroupApi) => (controlGroupApi ? controlGroupApi.filters$ : of(undefined))) + ); + const controlGroupTimeslice$ = controlGroupApi$.pipe( + switchMap((controlGroupApi) => (controlGroupApi ? controlGroupApi.timeslice$ : of(undefined))) + ); + controlGroupSubscriptions.add( + combineLatest([unifiedSearchFilters$, controlGroupFilters$]).subscribe( + ([unifiedSearchFilters, controlGroupFilters]) => { + filters$.next([...(unifiedSearchFilters ?? []), ...(controlGroupFilters ?? [])]); + } + ) + ); + controlGroupSubscriptions.add(controlGroupFilters$.subscribe(() => panelsReload$.next())); + controlGroupSubscriptions.add( + controlGroupTimeslice$.subscribe((timeslice) => { + if (timeslice !== timeslice$.value) timeslice$.next(timeslice); + }) + ); + + // -------------------------------------------------------------------------------------- + // Set up unified search integration. + // -------------------------------------------------------------------------------------- + const unifiedSearchSubscriptions: Subscription = new Subscription(); + let stopSyncingWithUrl: (() => void) | undefined; + let stopSyncingAppFilters: (() => void) | undefined; + if ( + creationOptions?.useUnifiedSearchIntegration && + creationOptions?.unifiedSearchSettings?.kbnUrlStateStorage + ) { + // apply filters and query to the query service + filterManager.setAppFilters(cloneDeep(unifiedSearchFilters$.value ?? [])); + queryString.setQuery(query$.value ?? queryString.getDefaultQuery()); + + /** + * Get initial time range, and set up dashboard time restore if applicable + */ + const initialTimeRange: TimeRange = (() => { + // if there is an explicit time range in the URL it always takes precedence. + const urlOverrideTimeRange = + creationOptions.unifiedSearchSettings.kbnUrlStateStorage.get( + GLOBAL_STATE_STORAGE_KEY + )?.time; + if (urlOverrideTimeRange) return urlOverrideTimeRange; + + // if this Dashboard has timeRestore return the time range that was saved with the dashboard. + if (timeRestore$.value && timeRange$.value) return timeRange$.value; + + // otherwise fall back to the time range from the timefilterService. + return timefilterService.getTime(); + })(); + setTimeRange(initialTimeRange); + if (timeRestore$.value) { + if (timeRange$.value) timefilterService.setTime(timeRange$.value); + if (refreshInterval$.value) timefilterService.setRefreshInterval(refreshInterval$.value); + } + + // start syncing global query state with the URL. + const { stop } = syncGlobalQueryStateWithUrl( + dataService.query, + creationOptions?.unifiedSearchSettings.kbnUrlStateStorage + ); + stopSyncingWithUrl = stop; + + stopSyncingAppFilters = connectToQueryState( + dataService.query, + { + get: () => ({ + filters: unifiedSearchFilters$.value ?? [], + query: query$.value ?? dataService.query.queryString.getDefaultQuery(), + }), + set: ({ filters: newFilters, query: newQuery }) => { + setUnifiedSearchFilters(cleanFiltersForSerialize(newFilters)); + setQuery(newQuery); + }, + state$: combineLatest([query$, unifiedSearchFilters$]).pipe( + debounceTime(0), + map(([query, unifiedSearchFilters]) => { + return { + query: query ?? dataService.query.queryString.getDefaultQuery(), + filters: unifiedSearchFilters ?? [], + }; + }), + distinctUntilChanged() + ), + }, + { + query: true, + filters: true, + } + ); + + unifiedSearchSubscriptions.add( + timefilterService.getTimeUpdate$().subscribe(() => { + const urlOverrideTimeRange = + creationOptions?.unifiedSearchSettings?.kbnUrlStateStorage.get( + GLOBAL_STATE_STORAGE_KEY + )?.time; + if (urlOverrideTimeRange) { + setTimeRange(urlOverrideTimeRange); + return; + } + + const lastSavedTimeRange = getLastSavedState()?.timeRange; + if (timeRestore$.value && lastSavedTimeRange) { + setAndSyncTimeRange(lastSavedTimeRange); + return; + } + + setTimeRange(timefilterService.getTime()); + }) + ); + unifiedSearchSubscriptions.add( + timefilterService.getRefreshIntervalUpdate$().subscribe(() => { + const urlOverrideRefreshInterval = + creationOptions?.unifiedSearchSettings?.kbnUrlStateStorage.get( + GLOBAL_STATE_STORAGE_KEY + )?.refreshInterval; + if (urlOverrideRefreshInterval) { + setRefreshInterval(urlOverrideRefreshInterval); + return; + } + + const lastSavedRefreshInterval = getLastSavedState()?.refreshInterval; + if (timeRestore$.value && lastSavedRefreshInterval) { + setAndSyncRefreshInterval(lastSavedRefreshInterval); + return; + } + + setRefreshInterval(timefilterService.getRefreshInterval()); + }) + ); + unifiedSearchSubscriptions.add( + timefilterService + .getAutoRefreshFetch$() + .pipe( + tap(() => { + controlGroupReload$.next(); + panelsReload$.next(); + }), + switchMap((done) => waitForPanelsToLoad$.pipe(finalize(done))) + ) + .subscribe() + ); + } + + return { + api: { + filters$, + forceRefresh: () => { + controlGroupReload$.next(); + panelsReload$.next(); + }, + query$, + refreshInterval$, + setFilters: setUnifiedSearchFilters, + setQuery, + setTimeRange: setAndSyncTimeRange, + timeRange$, + timeslice$, + unifiedSearchFilters$, + }, + comparators: { + filters: [ + unifiedSearchFilters$, + setUnifiedSearchFilters, + // exclude pinned filters from comparision because pinned filters are not part of application state + (a, b) => + compareFilters( + (a ?? []).filter((f) => !isFilterPinned(f)), + (b ?? []).filter((f) => !isFilterPinned(f)), + COMPARE_ALL_OPTIONS + ), + ], + query: [query$, setQuery, fastIsEqual], + refreshInterval: [ + refreshInterval$, + (refreshInterval: RefreshInterval | undefined) => { + if (timeRestore$.value) setAndSyncRefreshInterval(refreshInterval); + }, + (a: RefreshInterval | undefined, b: RefreshInterval | undefined) => + timeRestore$.value ? fastIsEqual(a, b) : true, + ], + timeRange: [ + timeRange$, + (timeRange: TimeRange | undefined) => { + if (timeRestore$.value) setAndSyncTimeRange(timeRange); + }, + (a: TimeRange | undefined, b: TimeRange | undefined) => { + if (!timeRestore$.value) return true; // if time restore is set to false, time range doesn't count as a change. + if (!areTimesEqual(a?.from, b?.from) || !areTimesEqual(a?.to, b?.to)) { + return false; + } + return true; + }, + ], + timeRestore: [timeRestore$, setTimeRestore], + } as StateComparators< + Pick + >, + internalApi: { + controlGroupReload$, + panelsReload$, + reset: (lastSavedState: DashboardState) => { + setUnifiedSearchFilters([ + ...(unifiedSearchFilters$.value ?? []).filter(isFilterPinned), + ...lastSavedState.filters, + ]); + setQuery(lastSavedState.query); + setTimeRestore(lastSavedState.timeRestore); + if (lastSavedState.timeRestore) { + setAndSyncRefreshInterval(lastSavedState.refreshInterval); + setAndSyncTimeRange(lastSavedState.timeRange); + } + }, + getState: (): Pick< + DashboardState, + 'filters' | 'query' | 'refreshInterval' | 'timeRange' | 'timeRestore' + > => ({ + filters: unifiedSearchFilters$.value ?? DEFAULT_DASHBOARD_INPUT.filters, + query: query$.value ?? DEFAULT_DASHBOARD_INPUT.query, + refreshInterval: refreshInterval$.value, + timeRange: timeRange$.value, + timeRestore: timeRestore$.value ?? DEFAULT_DASHBOARD_INPUT.timeRestore, + }), + setTimeRestore, + timeRestore$, + }, + cleanup: () => { + controlGroupSubscriptions.unsubscribe(); + unifiedSearchSubscriptions.unsubscribe(); + stopSyncingWithUrl?.(); + stopSyncingAppFilters?.(); + }, + }; +} + +const convertTimeToUTCString = (time?: string | Moment): undefined | string => { + if (moment(time).isValid()) { + return moment(time).utc().format('YYYY-MM-DDTHH:mm:ss.SSS[Z]'); + } else { + // If it's not a valid moment date, then it should be a string representing a relative time + // like 'now' or 'now-15m'. + return time as string; + } +}; + +export const areTimesEqual = ( + timeA?: string | Moment | undefined, + timeB?: string | Moment | undefined +) => { + return convertTimeToUTCString(timeA) === convertTimeToUTCString(timeB); +}; diff --git a/src/plugins/dashboard/public/dashboard_api/unsaved_changes.ts b/src/plugins/dashboard/public/dashboard_api/unsaved_changes.ts deleted file mode 100644 index af588081ecc87..0000000000000 --- a/src/plugins/dashboard/public/dashboard_api/unsaved_changes.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { BehaviorSubject } from 'rxjs'; -import type { DashboardContainerInput } from '../../common'; - -export function initializeUnsavedChanges( - anyMigrationRun: boolean, - lastSavedInput: DashboardContainerInput -) { - const hasRunMigrations$ = new BehaviorSubject(anyMigrationRun); - const hasUnsavedChanges$ = new BehaviorSubject(false); - const lastSavedInput$ = new BehaviorSubject(lastSavedInput); - - return { - hasRunMigrations$, - hasUnsavedChanges$, - lastSavedInput$, - setHasUnsavedChanges: (hasUnsavedChanges: boolean) => - hasUnsavedChanges$.next(hasUnsavedChanges), - setLastSavedInput: (input: DashboardContainerInput) => { - lastSavedInput$.next(input); - - // if we set the last saved input, it means we have saved this Dashboard - therefore clientside migrations have - // been serialized into the SO. - hasRunMigrations$.next(false); - }, - }; -} diff --git a/src/plugins/dashboard/public/dashboard_api/unsaved_changes_manager.ts b/src/plugins/dashboard/public/dashboard_api/unsaved_changes_manager.ts new file mode 100644 index 0000000000000..b5d2117fde98e --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_api/unsaved_changes_manager.ts @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { BehaviorSubject, Subject, combineLatest, debounceTime, skipWhile, switchMap } from 'rxjs'; +import { PublishesSavedObjectId, PublishingSubject } from '@kbn/presentation-publishing'; +import { ControlGroupApi } from '@kbn/controls-plugin/public'; +import { childrenUnsavedChanges$, initializeUnsavedChanges } from '@kbn/presentation-containers'; +import { omit } from 'lodash'; +import { DashboardCreationOptions, DashboardState } from './types'; +import { initializePanelsManager } from './panels_manager'; +import { initializeSettingsManager } from './settings_manager'; +import { initializeUnifiedSearchManager } from './unified_search_manager'; +import { + PANELS_CONTROL_GROUP_KEY, + getDashboardBackupService, +} from '../services/dashboard_backup_service'; +import { initializeViewModeManager } from './view_mode_manager'; + +export function initializeUnsavedChangesManager({ + anyMigrationRun, + creationOptions, + controlGroupApi$, + lastSavedState, + panelsManager, + savedObjectId$, + settingsManager, + viewModeManager, + unifiedSearchManager, +}: { + anyMigrationRun: boolean; + creationOptions?: DashboardCreationOptions; + controlGroupApi$: PublishingSubject; + lastSavedState: DashboardState; + panelsManager: ReturnType; + savedObjectId$: PublishesSavedObjectId['savedObjectId']; + settingsManager: ReturnType; + viewModeManager: ReturnType; + unifiedSearchManager: ReturnType; +}) { + const hasRunMigrations$ = new BehaviorSubject(anyMigrationRun); + const hasUnsavedChanges$ = new BehaviorSubject(false); + const lastSavedState$ = new BehaviorSubject(lastSavedState); + const saveNotification$ = new Subject(); + + const dashboardUnsavedChanges = initializeUnsavedChanges< + Omit + >( + lastSavedState, + { saveNotification$ }, + { + ...panelsManager.comparators, + ...settingsManager.comparators, + ...viewModeManager.comparators, + ...unifiedSearchManager.comparators, + } + ); + + const unsavedChangesSubscription = combineLatest([ + dashboardUnsavedChanges.api.unsavedChanges, + childrenUnsavedChanges$(panelsManager.api.children$), + controlGroupApi$.pipe( + skipWhile((controlGroupApi) => !controlGroupApi), + switchMap((controlGroupApi) => { + return controlGroupApi!.unsavedChanges; + }) + ), + ]) + .pipe(debounceTime(0)) + .subscribe(([dashboardChanges, unsavedPanelState, controlGroupChanges]) => { + // viewMode needs to be stored in session state because + // its used to exclude 'view' dashboards on the listing page + // However, viewMode should not trigger unsaved changes notification + // otherwise, opening a dashboard in edit mode will always show unsaved changes + const hasDashboardChanges = + Object.keys(omit(dashboardChanges ?? {}, ['viewMode'])).length > 0; + const hasUnsavedChanges = + hasDashboardChanges || unsavedPanelState !== undefined || controlGroupChanges !== undefined; + if (hasUnsavedChanges !== hasUnsavedChanges$.value) { + hasUnsavedChanges$.next(hasUnsavedChanges); + } + + // backup unsaved changes if configured to do so + if (creationOptions?.useSessionStorageIntegration) { + // Current behaviour expects time range not to be backed up. Revisit this? + const dashboardStateToBackup = omit(dashboardChanges ?? {}, [ + 'timeRange', + 'refreshInterval', + ]); + const reactEmbeddableChanges = unsavedPanelState ? { ...unsavedPanelState } : {}; + if (controlGroupChanges) { + reactEmbeddableChanges[PANELS_CONTROL_GROUP_KEY] = controlGroupChanges; + } + + getDashboardBackupService().setState( + savedObjectId$.value, + dashboardStateToBackup, + reactEmbeddableChanges + ); + } + }); + + return { + api: { + asyncResetToLastSavedState: async () => { + panelsManager.internalApi.reset(lastSavedState$.value); + settingsManager.internalApi.reset(lastSavedState$.value); + unifiedSearchManager.internalApi.reset(lastSavedState$.value); + await controlGroupApi$.value?.asyncResetUnsavedChanges(); + }, + hasRunMigrations$, + hasUnsavedChanges$, + saveNotification$, + }, + cleanup: () => { + dashboardUnsavedChanges.cleanup(); + unsavedChangesSubscription.unsubscribe(); + }, + internalApi: { + getLastSavedState: () => lastSavedState$.value, + onSave: (savedState: DashboardState) => { + lastSavedState$.next(savedState); + + // if we set the last saved input, it means we have saved this Dashboard - therefore clientside migrations have + // been serialized into the SO. + hasRunMigrations$.next(false); + + saveNotification$.next(); + }, + }, + }; +} diff --git a/src/plugins/dashboard/public/dashboard_api/use_dashboard_internal_api.ts b/src/plugins/dashboard/public/dashboard_api/use_dashboard_internal_api.ts new file mode 100644 index 0000000000000..bb9e25bd00650 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_api/use_dashboard_internal_api.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { createContext, useContext } from 'react'; +import { DashboardInternalApi } from './types'; + +export const DashboardInternalContext = createContext(undefined); + +export const useDashboardInternalApi = (): DashboardInternalApi => { + const internalApi = useContext(DashboardInternalContext); + if (!internalApi) { + throw new Error('useDashboardInternalApi must be used inside DashboardContext'); + } + return internalApi; +}; diff --git a/src/plugins/dashboard/public/dashboard_api/view_mode_manager.ts b/src/plugins/dashboard/public/dashboard_api/view_mode_manager.ts new file mode 100644 index 0000000000000..1ef1a19c9563e --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_api/view_mode_manager.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { EmbeddablePackageState } from '@kbn/embeddable-plugin/public'; +import { StateComparators, ViewMode } from '@kbn/presentation-publishing'; +import { BehaviorSubject } from 'rxjs'; +import { LoadDashboardReturn } from '../services/dashboard_content_management_service/types'; +import { getDashboardBackupService } from '../services/dashboard_backup_service'; +import { getDashboardCapabilities } from '../utils/get_dashboard_capabilities'; +import { DashboardState } from './types'; + +export function initializeViewModeManager( + incomingEmbeddable?: EmbeddablePackageState, + savedObjectResult?: LoadDashboardReturn +) { + const dashboardBackupService = getDashboardBackupService(); + function getInitialViewMode() { + if (savedObjectResult?.managed || !getDashboardCapabilities().showWriteControls) { + return 'view'; + } + + if ( + incomingEmbeddable || + savedObjectResult?.newDashboardCreated || + dashboardBackupService.dashboardHasUnsavedEdits(savedObjectResult?.dashboardId) + ) + return 'edit'; + + return dashboardBackupService.getViewMode(); + } + + const viewMode$ = new BehaviorSubject(getInitialViewMode()); + + function setViewMode(viewMode: ViewMode) { + // block the Dashboard from entering edit mode if this Dashboard is managed. + if (savedObjectResult?.managed && viewMode?.toLowerCase() === 'edit') { + return; + } + viewMode$.next(viewMode); + } + + return { + api: { + viewMode: viewMode$, + setViewMode, + }, + comparators: { + viewMode: [ + viewMode$, + setViewMode, + // When compared view mode is always considered unequal so that it gets backed up. + // view mode unsaved changes do not show unsaved badge + () => false, + ], + } as StateComparators>, + }; +} diff --git a/src/plugins/dashboard/public/dashboard_app/dashboard_app.test.tsx b/src/plugins/dashboard/public/dashboard_app/dashboard_app.test.tsx index 2bed3ce44ea61..ba6ec626f2348 100644 --- a/src/plugins/dashboard/public/dashboard_app/dashboard_app.test.tsx +++ b/src/plugins/dashboard/public/dashboard_app/dashboard_app.test.tsx @@ -12,11 +12,10 @@ import React, { useEffect } from 'react'; import { render, waitFor } from '@testing-library/react'; -import { DashboardApi } from '..'; import type { DashboardRendererProps } from '../dashboard_container/external_api/dashboard_renderer'; import { LazyDashboardRenderer } from '../dashboard_container/external_api/lazy_dashboard_renderer'; import { DashboardTopNav } from '../dashboard_top_nav'; -import { buildMockDashboard } from '../mocks'; +import { buildMockDashboardApi } from '../mocks'; import { dataService } from '../services/kibana_services'; import { DashboardApp } from './dashboard_app'; @@ -26,12 +25,12 @@ jest.mock('../dashboard_top_nav'); describe('Dashboard App', () => { dataService.query.filterManager.getFilters = jest.fn().mockImplementation(() => []); - const mockDashboard = buildMockDashboard(); + const { api: dashboardApi, cleanup } = buildMockDashboardApi(); let mockHistory: MemoryHistory; // this is in url_utils dashboardApi expandedPanel subscription let historySpy: jest.SpyInstance; // this is in the dashboard app for the renderer when provided an expanded panel id - const expandPanelSpy = jest.spyOn(mockDashboard, 'expandPanel'); + const expandPanelSpy = jest.spyOn(dashboardApi, 'expandPanel'); beforeAll(() => { mockHistory = createMemoryHistory(); @@ -46,7 +45,7 @@ describe('Dashboard App', () => { ({ onApiAvailable }: DashboardRendererProps) => { // we need overwrite the onApiAvailable prop to get access to the dashboard API in this test useEffect(() => { - onApiAvailable?.(mockDashboard as DashboardApi); + onApiAvailable?.(dashboardApi); }, [onApiAvailable]); return
Test renderer
; @@ -60,23 +59,27 @@ describe('Dashboard App', () => { historySpy.mockClear(); }); + afterAll(() => { + cleanup(); + }); + it('test the default behavior without an expandedPanel id passed as a prop to the DashboardApp', async () => { render(); await waitFor(() => { expect(expandPanelSpy).not.toHaveBeenCalled(); // this value should be undefined by default - expect(mockDashboard.expandedPanelId.getValue()).toBe(undefined); + expect(dashboardApi.expandedPanelId.getValue()).toBe(undefined); // history should not be called expect(historySpy).toHaveBeenCalledTimes(0); expect(mockHistory.location.pathname).toBe('/'); }); // simulate expanding a panel - mockDashboard.expandPanel('123'); + dashboardApi.expandPanel('123'); await waitFor(() => { - expect(mockDashboard.expandedPanelId.getValue()).toBe('123'); + expect(dashboardApi.expandedPanelId.getValue()).toBe('123'); expect(historySpy).toHaveBeenCalledTimes(1); expect(mockHistory.location.pathname).toBe('/create/123'); }); @@ -91,10 +94,10 @@ describe('Dashboard App', () => { }); // simulate minimizing a panel - mockDashboard.expandedPanelId.next(undefined); + dashboardApi.expandPanel('456'); await waitFor(() => { - expect(mockDashboard.expandedPanelId.getValue()).toBe(undefined); + expect(dashboardApi.expandedPanelId.getValue()).toBe(undefined); expect(historySpy).toHaveBeenCalledTimes(1); expect(mockHistory.location.pathname).toBe('/create'); }); diff --git a/src/plugins/dashboard/public/dashboard_app/dashboard_app.tsx b/src/plugins/dashboard/public/dashboard_app/dashboard_app.tsx index 400d56e97df09..06f56669630eb 100644 --- a/src/plugins/dashboard/public/dashboard_app/dashboard_app.tsx +++ b/src/plugins/dashboard/public/dashboard_app/dashboard_app.tsx @@ -9,7 +9,6 @@ import { History } from 'history'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import useMount from 'react-use/lib/useMount'; import useObservable from 'react-use/lib/useObservable'; import { debounceTime } from 'rxjs'; import { v4 as uuidv4 } from 'uuid'; @@ -70,10 +69,33 @@ export function DashboardApp({ }: DashboardAppProps) { const [showNoDataPage, setShowNoDataPage] = useState(false); const [regenerateId, setRegenerateId] = useState(uuidv4()); + const incomingEmbeddable = useMemo(() => { + return embeddableService + .getStateTransfer() + .getIncomingEmbeddablePackage(DASHBOARD_APP_ID, true); + }, []); - useMount(() => { - (async () => setShowNoDataPage(await isDashboardAppInNoDataState()))(); - }); + useEffect(() => { + let canceled = false; + // show dashboard when there is an incoming embeddable + if (incomingEmbeddable) { + return; + } + + isDashboardAppInNoDataState() + .then((isInNotDataState) => { + if (!canceled && isInNotDataState) { + setShowNoDataPage(true); + } + }) + .catch((error) => { + // show dashboard application if inNoDataState can not be determined + }); + + return () => { + canceled = true; + }; + }, [incomingEmbeddable]); const [dashboardApi, setDashboardApi] = useState(undefined); const showPlainSpinner = useObservable(coreServices.customBranding.hasCustomBranding$, false); @@ -138,8 +160,7 @@ export function DashboardApp({ }; return Promise.resolve({ - getIncomingEmbeddable: () => - embeddableService.getStateTransfer().getIncomingEmbeddablePackage(DASHBOARD_APP_ID, true), + getIncomingEmbeddable: () => incomingEmbeddable, // integrations useSessionStorageIntegration: true, @@ -166,7 +187,14 @@ export function DashboardApp({ getCurrentPath: () => `#${createDashboardEditUrl(dashboardId)}`, }), }); - }, [history, embedSettings, validateOutcome, getScopedHistory, kbnUrlStateStorage]); + }, [ + history, + embedSettings, + validateOutcome, + getScopedHistory, + kbnUrlStateStorage, + incomingEmbeddable, + ]); useEffect(() => { if (!dashboardApi) return; diff --git a/src/plugins/dashboard/public/dashboard_app/dashboard_router.tsx b/src/plugins/dashboard/public/dashboard_app/dashboard_router.tsx index 6ceaede806fed..f994d9aa20c8e 100644 --- a/src/plugins/dashboard/public/dashboard_app/dashboard_router.tsx +++ b/src/plugins/dashboard/public/dashboard_app/dashboard_router.tsx @@ -104,6 +104,7 @@ export async function mountApp({ } return ( { const hasUserDataView = await dataService.dataViews.hasData.hasUserDataView().catch(() => false); - if (hasUserDataView) return false; - // consider has data if there is an incoming embeddable - const hasIncomingEmbeddable = embeddableService - .getStateTransfer() - .getIncomingEmbeddablePackage(DASHBOARD_APP_ID, false); - if (hasIncomingEmbeddable) return false; - // consider has data if there is unsaved dashboard with edits if (getDashboardBackupService().dashboardHasUnsavedEdits()) return false; diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx index ddc629854affe..6f9e0b5892a89 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx @@ -27,6 +27,7 @@ import { import { getCreateVisualizationButtonTitle } from '../_dashboard_app_strings'; import { ControlsToolbarButton } from './controls_toolbar_button'; import { EditorMenu } from './editor_menu'; +import { addFromLibrary } from '../../dashboard_container/embeddable/api'; export function DashboardEditingToolbar({ isDisabled }: { isDisabled?: boolean }) { const { euiTheme } = useEuiTheme(); @@ -89,7 +90,7 @@ export function DashboardEditingToolbar({ isDisabled }: { isDisabled?: boolean } const extraButtons = [ , dashboardApi.addFromLibrary()} + onClick={() => addFromLibrary(dashboardApi)} size="s" data-test-subj="dashboardAddFromLibraryButton" isDisabled={isDisabled} diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/editor_menu.test.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/editor_menu.test.tsx index 7850c1e9ed745..e1bbef897d538 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/editor_menu.test.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/editor_menu.test.tsx @@ -9,10 +9,9 @@ import { render } from '@testing-library/react'; import React from 'react'; -import { buildMockDashboard } from '../../mocks'; +import { buildMockDashboardApi } from '../../mocks'; import { EditorMenu } from './editor_menu'; -import { DashboardApi } from '../../dashboard_api/types'; import { DashboardContext } from '../../dashboard_api/use_dashboard_api'; import { embeddableService, @@ -27,13 +26,10 @@ jest.spyOn(visualizationsService, 'getAliases').mockReturnValue([]); describe('editor menu', () => { it('renders without crashing', async () => { + const { api } = buildMockDashboardApi(); render(, { wrapper: ({ children }) => { - return ( - - {children} - - ); + return {children}; }, }); }); diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx index ca58d3c74bd3f..0a96d3d978968 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx @@ -8,7 +8,6 @@ */ import { Dispatch, SetStateAction, useCallback, useMemo, useState } from 'react'; -import { batch } from 'react-redux'; import { ViewMode } from '@kbn/embeddable-plugin/public'; import type { TopNavMenuData } from '@kbn/navigation-plugin/public'; @@ -42,28 +41,17 @@ export const useDashboardMenuItems = ({ const [isSaveInProgress, setIsSaveInProgress] = useState(false); - /** - * Unpack dashboard state from redux - */ const dashboardApi = useDashboardApi(); - const [ - dashboardTitle, - hasOverlays, - hasRunMigrations, - hasUnsavedChanges, - lastSavedId, - managed, - viewMode, - ] = useBatchedPublishingSubjects( - dashboardApi.panelTitle, - dashboardApi.hasOverlays$, - dashboardApi.hasRunMigrations$, - dashboardApi.hasUnsavedChanges$, - dashboardApi.savedObjectId, - dashboardApi.managed$, - dashboardApi.viewMode - ); + const [dashboardTitle, hasOverlays, hasRunMigrations, hasUnsavedChanges, lastSavedId, viewMode] = + useBatchedPublishingSubjects( + dashboardApi.panelTitle, + dashboardApi.hasOverlays$, + dashboardApi.hasRunMigrations$, + dashboardApi.hasUnsavedChanges$, + dashboardApi.savedObjectId, + dashboardApi.viewMode + ); const disableTopNav = isSaveInProgress || hasOverlays; /** @@ -96,8 +84,8 @@ export const useDashboardMenuItems = ({ * initiate interactive dashboard copy action */ const dashboardInteractiveSave = useCallback(() => { - dashboardApi.runInteractiveSave(viewMode).then((result) => maybeRedirect(result)); - }, [maybeRedirect, dashboardApi, viewMode]); + dashboardApi.runInteractiveSave().then((result) => maybeRedirect(result)); + }, [maybeRedirect, dashboardApi]); /** * Show the dashboard's "Confirm reset changes" modal. If confirmed: @@ -118,15 +106,13 @@ export const useDashboardMenuItems = ({ switchModes?.(); return; } - confirmDiscardUnsavedChanges(() => { - batch(async () => { - setIsResetting(true); - await dashboardApi.asyncResetToLastSavedState(); - if (isMounted()) { - setIsResetting(false); - switchModes?.(); - } - }); + confirmDiscardUnsavedChanges(async () => { + setIsResetting(true); + await dashboardApi.asyncResetToLastSavedState(); + if (isMounted()) { + setIsResetting(false); + switchModes?.(); + } }, viewMode as ViewMode); }, [dashboardApi, hasUnsavedChanges, viewMode, isMounted] @@ -273,7 +259,7 @@ export const useDashboardMenuItems = ({ const labsMenuItem = isLabsEnabled ? [menuItems.labs] : []; const shareMenuItem = shareService ? [menuItems.share] : []; const duplicateMenuItem = showWriteControls ? [menuItems.interactiveSave] : []; - const editMenuItem = showWriteControls && !managed ? [menuItems.edit] : []; + const editMenuItem = showWriteControls && !dashboardApi.isManaged ? [menuItems.edit] : []; const mayberesetChangesMenuItem = showResetChange ? [resetChangesMenuItem] : []; return [ @@ -284,7 +270,7 @@ export const useDashboardMenuItems = ({ ...mayberesetChangesMenuItem, ...editMenuItem, ]; - }, [isLabsEnabled, menuItems, managed, showResetChange, resetChangesMenuItem]); + }, [isLabsEnabled, menuItems, dashboardApi.isManaged, showResetChange, resetChangesMenuItem]); const editModeTopNavConfig = useMemo(() => { const labsMenuItem = isLabsEnabled ? [menuItems.labs] : []; diff --git a/src/plugins/dashboard/public/dashboard_app/url/search_sessions_integration.ts b/src/plugins/dashboard/public/dashboard_app/url/search_sessions_integration.ts index 0fc8ce7173e6f..37c9af6944f7a 100644 --- a/src/plugins/dashboard/public/dashboard_app/url/search_sessions_integration.ts +++ b/src/plugins/dashboard/public/dashboard_app/url/search_sessions_integration.ts @@ -51,7 +51,7 @@ export function createSessionRestorationDataProvider( ): SearchSessionInfoProvider { return { getName: async () => - dashboardApi.panelTitle.value ?? dashboardApi.savedObjectId.value ?? dashboardApi.uuid$.value, + dashboardApi.panelTitle.value ?? dashboardApi.savedObjectId.value ?? dashboardApi.uuid, getLocatorData: async () => ({ id: DASHBOARD_APP_LOCATOR, initialState: getLocatorParams({ dashboardApi, shouldRestoreSearchSession: false }), diff --git a/src/plugins/dashboard/public/dashboard_container/component/empty_screen/dashboard_empty_screen.test.tsx b/src/plugins/dashboard/public/dashboard_container/component/empty_screen/dashboard_empty_screen.test.tsx index b167f68e1595b..6a8da6aa9f218 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/empty_screen/dashboard_empty_screen.test.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/empty_screen/dashboard_empty_screen.test.tsx @@ -11,27 +11,29 @@ import { findTestSubject } from '@elastic/eui/lib/test'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import React from 'react'; -import { ViewMode } from '@kbn/embeddable-plugin/public'; -import { DashboardApi } from '../../../dashboard_api/types'; import { DashboardContext } from '../../../dashboard_api/use_dashboard_api'; -import { buildMockDashboard } from '../../../mocks'; +import { DashboardApi } from '../../../dashboard_api/types'; import { coreServices, visualizationsService } from '../../../services/kibana_services'; import { DashboardEmptyScreen } from './dashboard_empty_screen'; +import { ViewMode } from '@kbn/presentation-publishing'; +import { BehaviorSubject } from 'rxjs'; visualizationsService.getAliases = jest.fn().mockReturnValue([{ name: 'lens' }]); describe('DashboardEmptyScreen', () => { function mountComponent(viewMode: ViewMode) { - const dashboardApi = buildMockDashboard({ overrides: { viewMode } }) as DashboardApi; + const mockDashboardApi = { + viewMode: new BehaviorSubject(viewMode), + } as unknown as DashboardApi; return mountWithIntl( - + ); } test('renders correctly with view mode', () => { - const component = mountComponent(ViewMode.VIEW); + const component = mountComponent('view'); expect(component.render()).toMatchSnapshot(); const emptyReadWrite = findTestSubject(component, 'dashboardEmptyReadWrite'); @@ -43,7 +45,7 @@ describe('DashboardEmptyScreen', () => { }); test('renders correctly with edit mode', () => { - const component = mountComponent(ViewMode.EDIT); + const component = mountComponent('edit'); expect(component.render()).toMatchSnapshot(); const emptyReadWrite = findTestSubject(component, 'dashboardEmptyReadWrite'); @@ -57,7 +59,7 @@ describe('DashboardEmptyScreen', () => { test('renders correctly with readonly mode', () => { (coreServices.application.capabilities as any).dashboard.showWriteControls = false; - const component = mountComponent(ViewMode.VIEW); + const component = mountComponent('view'); expect(component.render()).toMatchSnapshot(); const emptyReadWrite = findTestSubject(component, 'dashboardEmptyReadWrite'); @@ -72,7 +74,7 @@ describe('DashboardEmptyScreen', () => { test('renders correctly with readonly and edit mode', () => { (coreServices.application.capabilities as any).dashboard.showWriteControls = false; - const component = mountComponent(ViewMode.EDIT); + const component = mountComponent('edit'); expect(component.render()).toMatchSnapshot(); const emptyReadWrite = findTestSubject(component, 'dashboardEmptyReadWrite'); diff --git a/src/plugins/dashboard/public/dashboard_container/component/empty_screen/dashboard_empty_screen.tsx b/src/plugins/dashboard/public/dashboard_container/component/empty_screen/dashboard_empty_screen.tsx index e4e82339e7c22..b7a4facde1c1e 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/empty_screen/dashboard_empty_screen.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/empty_screen/dashboard_empty_screen.tsx @@ -34,6 +34,7 @@ import { } from '../../../services/kibana_services'; import { getDashboardCapabilities } from '../../../utils/get_dashboard_capabilities'; import { emptyScreenStrings } from '../../_dashboard_container_strings'; +import { addFromLibrary } from '../../embeddable/api'; export function DashboardEmptyScreen() { const lensAlias = useMemo( @@ -120,7 +121,7 @@ export function DashboardEmptyScreen() { dashboardApi.addFromLibrary()} + onClick={() => addFromLibrary(dashboardApi)} > {emptyScreenStrings.getAddFromLibraryButtonTitle()} diff --git a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.test.tsx b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.test.tsx index 7f51a91379203..8700161711e17 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.test.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.test.tsx @@ -13,10 +13,10 @@ import { mountWithIntl } from '@kbn/test-jest-helpers'; import { CONTACT_CARD_EMBEDDABLE } from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables'; import { DashboardGrid } from './dashboard_grid'; -import { buildMockDashboard } from '../../../mocks'; +import { buildMockDashboardApi } from '../../../mocks'; import type { Props as DashboardGridItemProps } from './dashboard_grid_item'; import { DashboardContext } from '../../../dashboard_api/use_dashboard_api'; -import { DashboardApi } from '../../../dashboard_api/types'; +import { DashboardInternalContext } from '../../../dashboard_api/use_dashboard_internal_api'; import { DashboardPanelMap } from '../../../../common'; jest.mock('./dashboard_grid_item', () => { @@ -61,18 +61,19 @@ const PANELS = { }; const createAndMountDashboardGrid = async (panels: DashboardPanelMap = PANELS) => { - const dashboardContainer = buildMockDashboard({ + const { api, internalApi } = buildMockDashboardApi({ overrides: { panels, }, }); - await dashboardContainer.untilContainerInitialized(); const component = mountWithIntl( - - + + + + ); - return { dashboardApi: dashboardContainer, component }; + return { dashboardApi: api, component }; }; test('renders DashboardGrid', async () => { @@ -101,7 +102,8 @@ test('DashboardGrid removes panel when removed from container', async () => { test('DashboardGrid renders expanded panel', async () => { const { dashboardApi, component } = await createAndMountDashboardGrid(); - dashboardApi.setExpandedPanelId('1'); + // maximize panel + dashboardApi.expandPanel('1'); await new Promise((resolve) => setTimeout(resolve, 1)); component.update(); // Both panels should still exist in the dom, so nothing needs to be re-fetched once minimized. @@ -110,7 +112,8 @@ test('DashboardGrid renders expanded panel', async () => { expect(component.find('#mockDashboardGridItem_1').hasClass('expandedPanel')).toBe(true); expect(component.find('#mockDashboardGridItem_2').hasClass('hiddenPanel')).toBe(true); - dashboardApi.setExpandedPanelId(); + // minimize panel + dashboardApi.expandPanel('1'); await new Promise((resolve) => setTimeout(resolve, 1)); component.update(); expect(component.find('GridItem').length).toBe(2); diff --git a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.tsx b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.tsx index 76a545d1ea9fc..1f5e48dd7a5df 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.tsx @@ -23,7 +23,8 @@ import { DashboardPanelState } from '../../../../common'; import { DashboardGridItem } from './dashboard_grid_item'; import { useDashboardGridSettings } from './use_dashboard_grid_settings'; import { useDashboardApi } from '../../../dashboard_api/use_dashboard_api'; -import { getPanelLayoutsAreEqual } from '../../state/diffing/dashboard_diffing_utils'; +import { arePanelLayoutsEqual } from '../../../dashboard_api/are_panel_layouts_equal'; +import { useDashboardInternalApi } from '../../../dashboard_api/use_dashboard_internal_api'; import { DASHBOARD_GRID_HEIGHT, DASHBOARD_MARGIN_SIZE } from '../../../dashboard_constants'; export const DashboardGrid = ({ @@ -34,14 +35,15 @@ export const DashboardGrid = ({ viewportWidth: number; }) => { const dashboardApi = useDashboardApi(); + const dashboardInternalApi = useDashboardInternalApi(); const [animatePanelTransforms, expandedPanelId, focusedPanelId, panels, useMargins, viewMode] = useBatchedPublishingSubjects( - dashboardApi.animatePanelTransforms$, + dashboardInternalApi.animatePanelTransforms$, dashboardApi.expandedPanelId, dashboardApi.focusedPanelId$, dashboardApi.panels$, - dashboardApi.useMargins$, + dashboardApi.settings.useMargins$, dashboardApi.viewMode ); @@ -116,7 +118,7 @@ export const DashboardGrid = ({ }, {} as { [key: string]: DashboardPanelState } ); - if (!getPanelLayoutsAreEqual(panels, updatedPanels)) { + if (!arePanelLayoutsEqual(panels, updatedPanels)) { dashboardApi.setPanels(updatedPanels); } }, diff --git a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.test.tsx b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.test.tsx index 416448f6d8132..268c352e91ad7 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.test.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.test.tsx @@ -10,19 +10,18 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test-jest-helpers'; -import { CONTACT_CARD_EMBEDDABLE } from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables'; -import { buildMockDashboard } from '../../../mocks'; +import { buildMockDashboardApi } from '../../../mocks'; import { Item, Props as DashboardGridItemProps } from './dashboard_grid_item'; import { DashboardContext } from '../../../dashboard_api/use_dashboard_api'; -import { DashboardApi } from '../../../dashboard_api/types'; +import { DashboardInternalContext } from '../../../dashboard_api/use_dashboard_internal_api'; jest.mock('@kbn/embeddable-plugin/public', () => { const original = jest.requireActual('@kbn/embeddable-plugin/public'); return { ...original, - EmbeddablePanel: (props: DashboardGridItemProps) => { + ReactEmbeddableRenderer: (props: DashboardGridItemProps) => { return (
mockEmbeddablePanel @@ -32,34 +31,40 @@ jest.mock('@kbn/embeddable-plugin/public', () => { }; }); +// Value of panel type does not effect test output +// since test mocks ReactEmbeddableRenderer to render static content regardless of embeddable type +const TEST_EMBEDDABLE = 'TEST_EMBEDDABLE'; + const createAndMountDashboardGridItem = (props: DashboardGridItemProps) => { const panels = { '1': { gridData: { x: 0, y: 0, w: 6, h: 6, i: '1' }, - type: CONTACT_CARD_EMBEDDABLE, + type: TEST_EMBEDDABLE, explicitInput: { id: '1' }, }, '2': { gridData: { x: 6, y: 6, w: 6, h: 6, i: '2' }, - type: CONTACT_CARD_EMBEDDABLE, + type: TEST_EMBEDDABLE, explicitInput: { id: '2' }, }, }; - const dashboardApi = buildMockDashboard({ overrides: { panels } }) as DashboardApi; + const { api, internalApi } = buildMockDashboardApi({ overrides: { panels } }); const component = mountWithIntl( - - + + + + ); - return { dashboardApi, component }; + return { dashboardApi: api, component }; }; test('renders Item', async () => { const { component } = createAndMountDashboardGridItem({ id: '1', key: '1', - type: CONTACT_CARD_EMBEDDABLE, + type: TEST_EMBEDDABLE, }); const panelElements = component.find('.embedPanel'); expect(panelElements.length).toBe(1); @@ -75,7 +80,7 @@ test('renders expanded panel', async () => { const { component } = createAndMountDashboardGridItem({ id: '1', key: '1', - type: CONTACT_CARD_EMBEDDABLE, + type: TEST_EMBEDDABLE, expandedPanelId: '1', }); expect(component.find('#panel-1').hasClass('dshDashboardGrid__item--expanded')).toBe(true); @@ -86,7 +91,7 @@ test('renders hidden panel', async () => { const { component } = createAndMountDashboardGridItem({ id: '1', key: '1', - type: CONTACT_CARD_EMBEDDABLE, + type: TEST_EMBEDDABLE, expandedPanelId: '2', }); expect(component.find('#panel-1').hasClass('dshDashboardGrid__item--expanded')).toBe(false); @@ -97,7 +102,7 @@ test('renders focused panel', async () => { const { component } = createAndMountDashboardGridItem({ id: '1', key: '1', - type: CONTACT_CARD_EMBEDDABLE, + type: TEST_EMBEDDABLE, focusedPanelId: '1', }); @@ -109,7 +114,7 @@ test('renders blurred panel', async () => { const { component } = createAndMountDashboardGridItem({ id: '1', key: '1', - type: CONTACT_CARD_EMBEDDABLE, + type: TEST_EMBEDDABLE, focusedPanelId: '2', }); diff --git a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.tsx b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.tsx index 5ad1363e6f8af..ded3cc7095407 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.tsx @@ -12,13 +12,14 @@ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 're import { EuiLoadingChart } from '@elastic/eui'; import { css } from '@emotion/react'; -import { EmbeddablePanel, ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public'; +import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public'; import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; import { DASHBOARD_MARGIN_SIZE } from '../../../dashboard_constants'; +import { useDashboardInternalApi } from '../../../dashboard_api/use_dashboard_internal_api'; import { DashboardPanelState } from '../../../../common'; import { useDashboardApi } from '../../../dashboard_api/use_dashboard_api'; -import { embeddableService, presentationUtilService } from '../../../services/kibana_services'; +import { presentationUtilService } from '../../../services/kibana_services'; type DivProps = Pick, 'className' | 'style' | 'children'>; @@ -54,10 +55,11 @@ export const Item = React.forwardRef( ref ) => { const dashboardApi = useDashboardApi(); + const dashboardInternalApi = useDashboardInternalApi(); const [highlightPanelId, scrollToPanelId, useMargins, viewMode] = useBatchedPublishingSubjects( dashboardApi.highlightPanelId$, dashboardApi.scrollToPanelId$, - dashboardApi.useMargins$, + dashboardApi.settings.useMargins$, dashboardApi.viewMode ); @@ -118,29 +120,20 @@ export const Item = React.forwardRef( showShadow: false, }; - // render React embeddable - if (embeddableService.reactEmbeddableRegistryHasKey(type)) { - return ( - dashboardApi} - key={`${type}_${id}`} - panelProps={panelProps} - onApiAvailable={(api) => dashboardApi.registerChildApi(api)} - /> - ); - } - // render legacy embeddable return ( - dashboardApi.untilEmbeddableLoaded(id)} - {...panelProps} + ({ + ...dashboardApi, + reload$: dashboardInternalApi.panelsReload$, + })} + key={`${type}_${id}`} + panelProps={panelProps} + onApiAvailable={(api) => dashboardInternalApi.registerChildApi(api)} /> ); - }, [id, dashboardApi, type, index, useMargins]); + }, [id, dashboardApi, dashboardInternalApi, type, useMargins]); return (
{ @@ -43,6 +44,7 @@ export const useDebouncedWidthObserver = (skipDebounce = false, wait = 100) => { export const DashboardViewport = ({ dashboardContainer }: { dashboardContainer?: HTMLElement }) => { const dashboardApi = useDashboardApi(); + const dashboardInternalApi = useDashboardInternalApi(); const [hasControls, setHasControls] = useState(false); const [ controlGroupApi, @@ -53,7 +55,6 @@ export const DashboardViewport = ({ dashboardContainer }: { dashboardContainer?: panels, viewMode, useMargins, - uuid, fullScreenMode, ] = useBatchedPublishingSubjects( dashboardApi.controlGroupApi$, @@ -63,8 +64,7 @@ export const DashboardViewport = ({ dashboardContainer }: { dashboardContainer?: dashboardApi.focusedPanelId$, dashboardApi.panels$, dashboardApi.viewMode, - dashboardApi.useMargins$, - dashboardApi.uuid$, + dashboardApi.settings.useMargins$, dashboardApi.fullScreenMode$ ); const onExit = useCallback(() => { @@ -126,7 +126,7 @@ export const DashboardViewport = ({ dashboardContainer }: { dashboardContainer?: ControlGroupRuntimeState, ControlGroupApi > - key={uuid} + key={dashboardApi.uuid} hidePanelChrome={true} panelProps={{ hideLoader: true }} type={CONTROL_GROUP_TYPE} @@ -134,11 +134,12 @@ export const DashboardViewport = ({ dashboardContainer }: { dashboardContainer?: getParentApi={() => { return { ...dashboardApi, - getSerializedStateForChild: dashboardApi.getSerializedStateForControlGroup, - getRuntimeStateForChild: dashboardApi.getRuntimeStateForControlGroup, + reload$: dashboardInternalApi.controlGroupReload$, + getSerializedStateForChild: dashboardInternalApi.getSerializedStateForControlGroup, + getRuntimeStateForChild: dashboardInternalApi.getRuntimeStateForControlGroup, }; }} - onApiAvailable={(api) => dashboardApi.setControlGroupApi(api)} + onApiAvailable={(api) => dashboardInternalApi.setControlGroupApi(api)} />
) : null} diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/api/add_panel_from_library.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/api/add_panel_from_library.ts index f5e76cff53b08..70122182305ca 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/api/add_panel_from_library.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/api/add_panel_from_library.ts @@ -7,16 +7,15 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { isErrorEmbeddable, openAddFromLibraryFlyout } from '@kbn/embeddable-plugin/public'; -import { DashboardContainer } from '../dashboard_container'; +import { openAddFromLibraryFlyout } from '@kbn/embeddable-plugin/public'; +import { DashboardApi } from '../../../dashboard_api/types'; -export function addFromLibrary(this: DashboardContainer) { - if (isErrorEmbeddable(this)) return; - this.openOverlay( +export function addFromLibrary(dashboardApi: DashboardApi) { + dashboardApi.openOverlay( openAddFromLibraryFlyout({ - container: this, + container: dashboardApi, onClose: () => { - this.clearOverlays(); + dashboardApi.clearOverlays(); }, }) ); diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/api/duplicate_dashboard_panel.test.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/api/duplicate_dashboard_panel.test.tsx deleted file mode 100644 index 47732d52ad40c..0000000000000 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/api/duplicate_dashboard_panel.test.tsx +++ /dev/null @@ -1,330 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { CoreStart } from '@kbn/core/public'; -import { coreMock } from '@kbn/core/public/mocks'; -import { - isErrorEmbeddable, - ReactEmbeddableFactory, - ReferenceOrValueEmbeddable, -} from '@kbn/embeddable-plugin/public'; -import { - CONTACT_CARD_EMBEDDABLE, - ContactCardEmbeddable, - ContactCardEmbeddableFactory, - ContactCardEmbeddableInput, - ContactCardEmbeddableOutput, -} from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables'; -import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks'; -import { - DefaultEmbeddableApi, - ReactEmbeddableRenderer, - registerReactEmbeddableFactory, -} from '@kbn/embeddable-plugin/public/react_embeddable_system'; -import { BuildReactEmbeddableApiRegistration } from '@kbn/embeddable-plugin/public/react_embeddable_system/types'; -import { HasSnapshottableState, SerializedPanelState } from '@kbn/presentation-containers'; -import { HasInPlaceLibraryTransforms, HasLibraryTransforms } from '@kbn/presentation-publishing'; -import { render } from '@testing-library/react'; -import React from 'react'; -import { BehaviorSubject, lastValueFrom, Subject } from 'rxjs'; -import { buildMockDashboard, getSampleDashboardPanel } from '../../../mocks'; -import { embeddableService } from '../../../services/kibana_services'; -import { DashboardContainer } from '../dashboard_container'; -import { duplicateDashboardPanel, incrementPanelTitle } from './duplicate_dashboard_panel'; - -describe('Legacy embeddables', () => { - let container: DashboardContainer; - let genericEmbeddable: ContactCardEmbeddable; - let byRefOrValEmbeddable: ContactCardEmbeddable & ReferenceOrValueEmbeddable; - let coreStart: CoreStart; - beforeEach(async () => { - coreStart = coreMock.createStart(); - coreStart.savedObjects.client = { - ...coreStart.savedObjects.client, - get: jest.fn().mockImplementation(() => ({ attributes: { title: 'Holy moly' } })), - find: jest.fn().mockImplementation(() => ({ total: 15 })), - create: jest.fn().mockImplementation(() => ({ id: 'brandNewSavedObject' })), - }; - - const mockEmbeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any); - - embeddableService.getEmbeddableFactory = jest.fn().mockReturnValue(mockEmbeddableFactory); - container = buildMockDashboard({ - overrides: { - panels: { - '123': getSampleDashboardPanel({ - explicitInput: { firstName: 'Kibanana', id: '123' }, - type: CONTACT_CARD_EMBEDDABLE, - }), - }, - }, - }); - - const refOrValContactCardEmbeddable = await container.addNewEmbeddable< - ContactCardEmbeddableInput, - ContactCardEmbeddableOutput, - ContactCardEmbeddable - >(CONTACT_CARD_EMBEDDABLE, { - firstName: 'RefOrValEmbeddable', - }); - - const nonRefOrValueContactCard = await container.addNewEmbeddable< - ContactCardEmbeddableInput, - ContactCardEmbeddableOutput, - ContactCardEmbeddable - >(CONTACT_CARD_EMBEDDABLE, { - firstName: 'Not a refOrValEmbeddable', - }); - - if ( - isErrorEmbeddable(refOrValContactCardEmbeddable) || - isErrorEmbeddable(nonRefOrValueContactCard) - ) { - throw new Error('Failed to create embeddables'); - } else { - genericEmbeddable = nonRefOrValueContactCard; - byRefOrValEmbeddable = embeddablePluginMock.mockRefOrValEmbeddable< - ContactCardEmbeddable, - ContactCardEmbeddableInput - >(refOrValContactCardEmbeddable, { - mockedByReferenceInput: { - savedObjectId: 'testSavedObjectId', - id: refOrValContactCardEmbeddable.id, - }, - mockedByValueInput: { - firstName: 'RefOrValEmbeddable', - id: refOrValContactCardEmbeddable.id, - }, - }); - jest.spyOn(byRefOrValEmbeddable, 'getInputAsValueType'); - } - }); - test('Duplication adds a new embeddable', async () => { - const originalPanelCount = Object.keys(container.getInput().panels).length; - const originalPanelKeySet = new Set(Object.keys(container.getInput().panels)); - await duplicateDashboardPanel.bind(container)(byRefOrValEmbeddable.id); - - expect(Object.keys(container.getInput().panels).length).toEqual(originalPanelCount + 1); - const newPanelId = Object.keys(container.getInput().panels).find( - (key) => !originalPanelKeySet.has(key) - ); - expect(newPanelId).toBeDefined(); - const newPanel = container.getInput().panels[newPanelId!]; - expect(newPanel.type).toEqual(byRefOrValEmbeddable.type); - }); - - test('Duplicates a RefOrVal embeddable by value', async () => { - const originalPanelKeySet = new Set(Object.keys(container.getInput().panels)); - await duplicateDashboardPanel.bind(container)(byRefOrValEmbeddable.id); - const newPanelId = Object.keys(container.getInput().panels).find( - (key) => !originalPanelKeySet.has(key) - ); - - const originalFirstName = ( - container.getInput().panels[byRefOrValEmbeddable.id] - .explicitInput as ContactCardEmbeddableInput - ).firstName; - - const newFirstName = ( - container.getInput().panels[newPanelId!].explicitInput as ContactCardEmbeddableInput - ).firstName; - - expect(byRefOrValEmbeddable.getInputAsValueType).toHaveBeenCalled(); - - expect(originalFirstName).toEqual(newFirstName); - expect(container.getInput().panels[newPanelId!].type).toEqual(byRefOrValEmbeddable.type); - }); - - test('Duplicates a non RefOrVal embeddable by value', async () => { - const originalPanelKeySet = new Set(Object.keys(container.getInput().panels)); - await duplicateDashboardPanel.bind(container)(genericEmbeddable.id); - const newPanelId = Object.keys(container.getInput().panels).find( - (key) => !originalPanelKeySet.has(key) - ); - - const originalFirstName = ( - container.getInput().panels[genericEmbeddable.id].explicitInput as ContactCardEmbeddableInput - ).firstName; - - const newFirstName = ( - container.getInput().panels[newPanelId!].explicitInput as ContactCardEmbeddableInput - ).firstName; - - expect(originalFirstName).toEqual(newFirstName); - expect(container.getInput().panels[newPanelId!].type).toEqual(genericEmbeddable.type); - }); - - test('Gets a unique title from the dashboard', async () => { - expect(await incrementPanelTitle(container, '')).toEqual(''); - - container.getPanelTitles = jest.fn().mockImplementation(() => { - return ['testDuplicateTitle', 'testDuplicateTitle (copy)', 'testUniqueTitle']; - }); - expect(await incrementPanelTitle(container, 'testUniqueTitle')).toEqual( - 'testUniqueTitle (copy)' - ); - expect(await incrementPanelTitle(container, 'testDuplicateTitle')).toEqual( - 'testDuplicateTitle (copy 1)' - ); - - container.getPanelTitles = jest.fn().mockImplementation(() => { - return ['testDuplicateTitle', 'testDuplicateTitle (copy)'].concat( - Array.from([...Array(39)], (_, index) => `testDuplicateTitle (copy ${index + 1})`) - ); - }); - expect(await incrementPanelTitle(container, 'testDuplicateTitle')).toEqual( - 'testDuplicateTitle (copy 40)' - ); - expect(await incrementPanelTitle(container, 'testDuplicateTitle (copy 100)')).toEqual( - 'testDuplicateTitle (copy 40)' - ); - - container.getPanelTitles = jest.fn().mockImplementation(() => { - return ['testDuplicateTitle (copy 100)']; - }); - expect(await incrementPanelTitle(container, 'testDuplicateTitle')).toEqual( - 'testDuplicateTitle (copy 101)' - ); - expect(await incrementPanelTitle(container, 'testDuplicateTitle (copy 100)')).toEqual( - 'testDuplicateTitle (copy 101)' - ); - }); -}); - -describe('React embeddables', () => { - const testId = '1234'; - const buildDashboardWithReactEmbeddable = async ( - testType: string, - mockApi: BuildReactEmbeddableApiRegistration<{}, {}, Api> - ) => { - const fullApi$ = new Subject>(); - const reactEmbeddableFactory: ReactEmbeddableFactory<{}, {}, Api> = { - type: testType, - deserializeState: jest.fn().mockImplementation((state) => state.rawState), - buildEmbeddable: async (state, registerApi) => { - const fullApi = registerApi( - { - ...mockApi, - }, - {} - ); - return { - Component: () =>
TEST DUPLICATE
, - api: fullApi, - }; - }, - }; - registerReactEmbeddableFactory(testType, async () => reactEmbeddableFactory); - const dashboard = buildMockDashboard({ - overrides: { - panels: { - [testId]: getSampleDashboardPanel({ - explicitInput: { id: testId }, - type: testType, - }), - }, - }, - }); - - // render a fake Dashboard to initialize react embeddables - const FakeDashboard = () => { - return ( -
- {Object.keys(dashboard.getInput().panels).map((panelId) => { - const panel = dashboard.getInput().panels[panelId]; - return ( -
- { - fullApi$.next(api as Api & HasSnapshottableState<{}>); - fullApi$.complete(); - dashboard.children$.next({ [panelId]: api }); - }} - getParentApi={() => ({ - getSerializedStateForChild: () => - panel.explicitInput as unknown as SerializedPanelState | undefined, - })} - /> - - ); - })} - - ); - }; - render(); - - return { dashboard, apiPromise: lastValueFrom(fullApi$) }; - }; - - it('Duplicates child without library transforms', async () => { - const mockApi = { - serializeState: jest.fn().mockImplementation(() => ({ rawState: {} })), - }; - const { dashboard, apiPromise } = await buildDashboardWithReactEmbeddable( - 'byValueOnly', - mockApi - ); - const api = await apiPromise; - - const snapshotSpy = jest.spyOn(api, 'snapshotRuntimeState'); - - await duplicateDashboardPanel.bind(dashboard)(testId); - - expect(snapshotSpy).toHaveBeenCalled(); - expect(Object.keys(dashboard.getInput().panels).length).toBe(2); - }); - - it('Duplicates child with library transforms', async () => { - const libraryTransformsMockApi: BuildReactEmbeddableApiRegistration< - {}, - {}, - DefaultEmbeddableApi & HasLibraryTransforms - > = { - serializeState: jest.fn().mockImplementation(() => ({ rawState: {} })), - saveToLibrary: jest.fn(), - getByReferenceState: jest.fn(), - getByValueState: jest.fn(), - canLinkToLibrary: jest.fn(), - canUnlinkFromLibrary: jest.fn(), - checkForDuplicateTitle: jest.fn(), - }; - const { dashboard, apiPromise } = await buildDashboardWithReactEmbeddable( - 'libraryTransforms', - libraryTransformsMockApi - ); - await apiPromise; - - await duplicateDashboardPanel.bind(dashboard)(testId); - expect(libraryTransformsMockApi.getByValueState).toHaveBeenCalled(); - }); - - it('Duplicates a child with in place library transforms', async () => { - const inPlaceLibraryTransformsMockApi: BuildReactEmbeddableApiRegistration< - {}, - {}, - DefaultEmbeddableApi & HasInPlaceLibraryTransforms - > = { - unlinkFromLibrary: jest.fn(), - saveToLibrary: jest.fn(), - checkForDuplicateTitle: jest.fn(), - libraryId$: new BehaviorSubject(''), - getByValueRuntimeSnapshot: jest.fn(), - serializeState: jest.fn().mockImplementation(() => ({ rawState: {} })), - }; - const { dashboard, apiPromise } = await buildDashboardWithReactEmbeddable( - 'inPlaceLibraryTransforms', - inPlaceLibraryTransformsMockApi - ); - await apiPromise; - - await duplicateDashboardPanel.bind(dashboard)(testId); - expect(inPlaceLibraryTransformsMockApi.getByValueRuntimeSnapshot).toHaveBeenCalled(); - }); -}); diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/api/duplicate_dashboard_panel.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/api/duplicate_dashboard_panel.ts deleted file mode 100644 index d62bb78b3b645..0000000000000 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/api/duplicate_dashboard_panel.ts +++ /dev/null @@ -1,165 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { filter, map, max } from 'lodash'; -import { v4 as uuidv4 } from 'uuid'; - -import { isReferenceOrValueEmbeddable, PanelNotFoundError } from '@kbn/embeddable-plugin/public'; -import { apiHasSnapshottableState } from '@kbn/presentation-containers/interfaces/serialized_state'; -import { - apiHasInPlaceLibraryTransforms, - apiHasLibraryTransforms, - apiPublishesPanelTitle, - getPanelTitle, - stateHasTitles, -} from '@kbn/presentation-publishing'; - -import { DashboardPanelState, prefixReferencesFromPanel } from '../../../../common'; -import { dashboardClonePanelActionStrings } from '../../../dashboard_actions/_dashboard_actions_strings'; -import { coreServices, embeddableService } from '../../../services/kibana_services'; -import { placeClonePanel } from '../../panel_placement'; -import { DashboardContainer } from '../dashboard_container'; - -const duplicateLegacyInput = async ( - dashboard: DashboardContainer, - panelToClone: DashboardPanelState, - idToDuplicate: string -) => { - const embeddable = dashboard.getChild(idToDuplicate); - if (!panelToClone || !embeddable) throw new PanelNotFoundError(); - - const newTitle = await incrementPanelTitle(dashboard, embeddable.getTitle() || ''); - const id = uuidv4(); - if (isReferenceOrValueEmbeddable(embeddable)) { - return { - type: embeddable.type, - explicitInput: { - ...(await embeddable.getInputAsValueType()), - hidePanelTitles: panelToClone.explicitInput.hidePanelTitles, - ...(newTitle ? { title: newTitle } : {}), - id, - }, - }; - } - return { - type: embeddable.type, - explicitInput: { - ...panelToClone.explicitInput, - title: newTitle, - id, - }, - }; -}; - -const duplicateReactEmbeddableInput = async ( - dashboard: DashboardContainer, - panelToClone: DashboardPanelState, - idToDuplicate: string -) => { - const id = uuidv4(); - const child = dashboard.children$.value[idToDuplicate]; - const lastTitle = apiPublishesPanelTitle(child) ? getPanelTitle(child) ?? '' : ''; - const newTitle = await incrementPanelTitle(dashboard, lastTitle); - - /** - * For react embeddables that have library transforms, we need to ensure - * to clone them with serialized state and references. - * - * TODO: remove this section once all by reference capable react embeddables - * use in-place library transforms - */ - if (apiHasLibraryTransforms(child)) { - const byValueSerializedState = await child.getByValueState(); - if (panelToClone.references) { - dashboard.savedObjectReferences.push( - ...prefixReferencesFromPanel(id, panelToClone.references) - ); - } - return { - type: panelToClone.type, - explicitInput: { - ...byValueSerializedState, - title: newTitle, - id, - }, - }; - } - - const runtimeSnapshot = (() => { - if (apiHasInPlaceLibraryTransforms(child)) return child.getByValueRuntimeSnapshot(); - return apiHasSnapshottableState(child) ? child.snapshotRuntimeState() : {}; - })(); - if (stateHasTitles(runtimeSnapshot)) runtimeSnapshot.title = newTitle; - - dashboard.setRuntimeStateForChild(id, runtimeSnapshot); - return { - type: panelToClone.type, - explicitInput: { - id, - }, - }; -}; - -export async function duplicateDashboardPanel(this: DashboardContainer, idToDuplicate: string) { - const panelToClone = await this.getDashboardPanelFromId(idToDuplicate); - - const duplicatedPanelState = embeddableService.reactEmbeddableRegistryHasKey(panelToClone.type) - ? await duplicateReactEmbeddableInput(this, panelToClone, idToDuplicate) - : await duplicateLegacyInput(this, panelToClone, idToDuplicate); - - coreServices.notifications.toasts.addSuccess({ - title: dashboardClonePanelActionStrings.getSuccessMessage(), - 'data-test-subj': 'addObjectToContainerSuccess', - }); - - const { newPanelPlacement, otherPanels } = placeClonePanel({ - width: panelToClone.gridData.w, - height: panelToClone.gridData.h, - currentPanels: this.getInput().panels, - placeBesideId: panelToClone.explicitInput.id, - }); - - const newPanel = { - ...duplicatedPanelState, - gridData: { - ...newPanelPlacement, - i: duplicatedPanelState.explicitInput.id, - }, - }; - - this.updateInput({ - panels: { - ...otherPanels, - [newPanel.explicitInput.id]: newPanel, - }, - }); -} - -export const incrementPanelTitle = async (dashboard: DashboardContainer, rawTitle: string) => { - if (rawTitle === '') return ''; - - const clonedTag = dashboardClonePanelActionStrings.getClonedTag(); - const cloneRegex = new RegExp(`\\(${clonedTag}\\)`, 'g'); - const cloneNumberRegex = new RegExp(`\\(${clonedTag} [0-9]+\\)`, 'g'); - const baseTitle = rawTitle.replace(cloneNumberRegex, '').replace(cloneRegex, '').trim(); - const similarTitles = filter(await dashboard.getPanelTitles(), (title: string) => { - return title.startsWith(baseTitle); - }); - - const cloneNumbers = map(similarTitles, (title: string) => { - if (title.match(cloneRegex)) return 0; - const cloneTag = title.match(cloneNumberRegex); - return cloneTag ? parseInt(cloneTag[0].replace(/[^0-9.]/g, ''), 10) : -1; - }); - const similarBaseTitlesCount = max(cloneNumbers) || 0; - - return similarBaseTitlesCount < 0 - ? baseTitle + ` (${clonedTag})` - : baseTitle + ` (${clonedTag} ${similarBaseTitlesCount + 1})`; -}; diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/api/index.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/api/index.ts index 4829a7b61318e..e8f10192a0e8b 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/api/index.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/api/index.ts @@ -9,5 +9,3 @@ export { openSettingsFlyout } from './open_settings_flyout'; export { addFromLibrary } from './add_panel_from_library'; -export { addOrUpdateEmbeddable } from './panel_management'; -export { runQuickSave, runInteractiveSave } from './run_save_functions'; diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/api/panel_management.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/api/panel_management.ts deleted file mode 100644 index c8a909b682215..0000000000000 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/api/panel_management.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from '@kbn/embeddable-plugin/public'; -import { DashboardContainer } from '../dashboard_container'; - -export async function addOrUpdateEmbeddable< - EEI extends EmbeddableInput = EmbeddableInput, - EEO extends EmbeddableOutput = EmbeddableOutput, - E extends IEmbeddable = IEmbeddable ->(this: DashboardContainer, type: string, explicitInput: Partial, embeddableId?: string) { - const idToReplace = embeddableId || explicitInput.id; - if (idToReplace && this.input.panels[idToReplace]) { - const previousPanelState = this.input.panels[idToReplace]; - const newPanelState = { - type, - explicitInput: { - ...explicitInput, - id: idToReplace, - }, - }; - const panelId = await this.replaceEmbeddable( - previousPanelState.explicitInput.id, - { - ...newPanelState.explicitInput, - id: previousPanelState.explicitInput.id, - }, - newPanelState.type, - true - ); - return panelId; - } - return this.addNewEmbeddable(type, explicitInput); -} diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/api/run_save_functions.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/api/run_save_functions.tsx deleted file mode 100644 index e5355bdb2988c..0000000000000 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/api/run_save_functions.tsx +++ /dev/null @@ -1,334 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { cloneDeep } from 'lodash'; -import React from 'react'; -import { batch } from 'react-redux'; - -import type { Reference } from '@kbn/content-management-utils'; -import { reportPerformanceMetricEvent } from '@kbn/ebt-tools'; -import { - EmbeddableInput, - isReferenceOrValueEmbeddable, - ViewMode, -} from '@kbn/embeddable-plugin/public'; -import { i18n } from '@kbn/i18n'; -import { apiHasSerializableState, SerializedPanelState } from '@kbn/presentation-containers'; -import { showSaveModal } from '@kbn/saved-objects-plugin/public'; - -import { - DashboardContainerInput, - DashboardPanelMap, - prefixReferencesFromPanel, -} from '../../../../common'; -import type { DashboardAttributes } from '../../../../server/content_management'; -import { DASHBOARD_CONTENT_ID, SAVED_OBJECT_POST_TIME } from '../../../dashboard_constants'; -import { - SaveDashboardReturn, - SavedDashboardInput, -} from '../../../services/dashboard_content_management_service/types'; -import { getDashboardContentManagementService } from '../../../services/dashboard_content_management_service'; -import { - coreServices, - dataService, - embeddableService, - savedObjectsTaggingService, -} from '../../../services/kibana_services'; -import { DashboardSaveOptions, DashboardStateFromSaveModal } from '../../types'; -import { DashboardContainer } from '../dashboard_container'; -import { extractTitleAndCount } from './lib/extract_title_and_count'; -import { DashboardSaveModal } from './overlays/save_modal'; - -const serializeAllPanelState = async ( - dashboard: DashboardContainer -): Promise<{ panels: DashboardContainerInput['panels']; references: Reference[] }> => { - const references: Reference[] = []; - const panels = cloneDeep(dashboard.getInput().panels); - - const serializePromises: Array< - Promise<{ uuid: string; serialized: SerializedPanelState }> - > = []; - for (const [uuid, panel] of Object.entries(panels)) { - if (!embeddableService.reactEmbeddableRegistryHasKey(panel.type)) continue; - const api = dashboard.children$.value[uuid]; - - if (api && apiHasSerializableState(api)) { - serializePromises.push( - (async () => { - const serialized = await api.serializeState(); - return { uuid, serialized }; - })() - ); - } - } - - const serializeResults = await Promise.all(serializePromises); - for (const result of serializeResults) { - panels[result.uuid].explicitInput = { ...result.serialized.rawState, id: result.uuid }; - references.push(...prefixReferencesFromPanel(result.uuid, result.serialized.references ?? [])); - } - - return { panels, references }; -}; - -/** - * Save the current state of this dashboard to a saved object without showing any save modal. - */ -export async function runQuickSave(this: DashboardContainer) { - const { explicitInput: currentState } = this.getState(); - - const lastSavedId = this.savedObjectId.value; - - if (this.managed$.value) return; - - const { panels: nextPanels, references } = await serializeAllPanelState(this); - const dashboardStateToSave: DashboardContainerInput = { ...currentState, panels: nextPanels }; - let stateToSave: SavedDashboardInput = dashboardStateToSave; - const controlGroupApi = this.controlGroupApi$.value; - let controlGroupReferences: Reference[] | undefined; - if (controlGroupApi) { - const { rawState: controlGroupSerializedState, references: extractedReferences } = - await controlGroupApi.serializeState(); - controlGroupReferences = extractedReferences; - stateToSave = { - ...stateToSave, - controlGroupInput: - controlGroupSerializedState as unknown as DashboardAttributes['controlGroupInput'], - }; - } - - const saveResult = await getDashboardContentManagementService().saveDashboardState({ - controlGroupReferences, - panelReferences: references, - currentState: stateToSave, - saveOptions: {}, - lastSavedId, - }); - - this.savedObjectReferences = saveResult.references ?? []; - this.setLastSavedInput(dashboardStateToSave); - this.saveNotification$.next(); - - return saveResult; -} - -/** - * @description exclusively for user directed dashboard save actions, also - * accounts for scenarios of cloning elastic managed dashboard into user managed dashboards - */ -export async function runInteractiveSave(this: DashboardContainer, interactionMode: ViewMode) { - const { explicitInput: currentState } = this.getState(); - const dashboardContentManagementService = getDashboardContentManagementService(); - const lastSavedId = this.savedObjectId.value; - const managed = this.managed$.value; - - return new Promise((resolve, reject) => { - if (interactionMode === ViewMode.EDIT && managed) { - resolve(undefined); - } - - const onSaveAttempt = async ({ - newTags, - newTitle, - newDescription, - newCopyOnSave, - newTimeRestore, - onTitleDuplicate, - isTitleDuplicateConfirmed, - }: DashboardSaveOptions): Promise => { - const saveOptions = { - confirmOverwrite: false, - isTitleDuplicateConfirmed, - onTitleDuplicate, - saveAsCopy: lastSavedId ? true : newCopyOnSave, - }; - - try { - if ( - !(await dashboardContentManagementService.checkForDuplicateDashboardTitle({ - title: newTitle, - onTitleDuplicate, - lastSavedTitle: currentState.title, - copyOnSave: saveOptions.saveAsCopy, - isTitleDuplicateConfirmed, - })) - ) { - return {}; - } - - const stateFromSaveModal: DashboardStateFromSaveModal = { - title: newTitle, - tags: [] as string[], - description: newDescription, - timeRestore: newTimeRestore, - timeRange: newTimeRestore ? dataService.query.timefilter.timefilter.getTime() : undefined, - refreshInterval: newTimeRestore - ? dataService.query.timefilter.timefilter.getRefreshInterval() - : undefined, - }; - - if (savedObjectsTaggingService && newTags) { - // remove `hasSavedObjectsTagging` once the savedObjectsTagging service is optional - stateFromSaveModal.tags = newTags; - } - - let dashboardStateToSave: SavedDashboardInput = { - ...currentState, - ...stateFromSaveModal, - }; - - const controlGroupApi = this.controlGroupApi$.value; - let controlGroupReferences: Reference[] | undefined; - if (controlGroupApi) { - const { rawState: controlGroupSerializedState, references } = - await controlGroupApi.serializeState(); - controlGroupReferences = references; - dashboardStateToSave = { - ...dashboardStateToSave, - controlGroupInput: - controlGroupSerializedState as unknown as DashboardAttributes['controlGroupInput'], - }; - } - - const { panels: nextPanels, references } = await serializeAllPanelState(this); - - const newPanels = await (async () => { - if (!managed) return nextPanels; - - // this is a managed dashboard - unlink all by reference embeddables on clone - const unlinkedPanels: DashboardPanelMap = {}; - for (const [panelId, panel] of Object.entries(nextPanels)) { - const child = this.getChild(panelId); - if ( - child && - isReferenceOrValueEmbeddable(child) && - child.inputIsRefType(child.getInput() as EmbeddableInput) - ) { - const valueTypeInput = await child.getInputAsValueType(); - unlinkedPanels[panelId] = { - ...panel, - explicitInput: valueTypeInput, - }; - continue; - } - unlinkedPanels[panelId] = panel; - } - return unlinkedPanels; - })(); - - const beforeAddTime = window.performance.now(); - - const saveResult = await dashboardContentManagementService.saveDashboardState({ - controlGroupReferences, - panelReferences: references, - saveOptions, - currentState: { - ...dashboardStateToSave, - panels: newPanels, - title: newTitle, - }, - lastSavedId, - }); - - const addDuration = window.performance.now() - beforeAddTime; - - reportPerformanceMetricEvent(coreServices.analytics, { - eventName: SAVED_OBJECT_POST_TIME, - duration: addDuration, - meta: { - saved_object_type: DASHBOARD_CONTENT_ID, - }, - }); - - if (saveResult.id) { - batch(() => { - this.dispatch.setStateFromSaveModal(stateFromSaveModal); - this.setSavedObjectId(saveResult.id); - this.setLastSavedInput(dashboardStateToSave); - }); - } - - this.savedObjectReferences = saveResult.references ?? []; - this.saveNotification$.next(); - - resolve(saveResult); - - return saveResult; - } catch (error) { - reject(error); - return error; - } - }; - - (async () => { - try { - let customModalTitle; - let newTitle = currentState.title; - - if (lastSavedId) { - const [baseTitle, baseCount] = extractTitleAndCount(newTitle); - - newTitle = `${baseTitle} (${baseCount + 1})`; - - await dashboardContentManagementService.checkForDuplicateDashboardTitle({ - title: newTitle, - lastSavedTitle: currentState.title, - copyOnSave: true, - isTitleDuplicateConfirmed: false, - onTitleDuplicate(speculativeSuggestion) { - newTitle = speculativeSuggestion; - }, - }); - - switch (interactionMode) { - case ViewMode.EDIT: { - customModalTitle = i18n.translate( - 'dashboard.topNav.editModeInteractiveSave.modalTitle', - { - defaultMessage: 'Save as new dashboard', - } - ); - break; - } - case ViewMode.VIEW: { - customModalTitle = i18n.translate( - 'dashboard.topNav.viewModeInteractiveSave.modalTitle', - { - defaultMessage: 'Duplicate dashboard', - } - ); - break; - } - default: { - customModalTitle = undefined; - } - } - } - - const dashboardDuplicateModal = ( - resolve(undefined)} - timeRestore={currentState.timeRestore} - showStoreTimeOnSave={!lastSavedId} - description={currentState.description ?? ''} - showCopyOnSave={false} - onSave={onSaveAttempt} - customModalTitle={customModalTitle} - /> - ); - this.clearOverlays(); - showSaveModal(dashboardDuplicateModal); - } catch (error) { - reject(error); - } - })(); - }); -} diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/controls/dashboard_control_group_integration.test.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/controls/dashboard_control_group_integration.test.ts deleted file mode 100644 index 3a18acd242e4f..0000000000000 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/controls/dashboard_control_group_integration.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { Filter } from '@kbn/es-query'; -import { combineDashboardFiltersWithControlGroupFilters } from './dashboard_control_group_integration'; -import { BehaviorSubject } from 'rxjs'; - -const testFilter1: Filter = { - meta: { - key: 'testfield', - alias: null, - disabled: false, - negate: false, - }, - query: { match_phrase: { testfield: 'hello' } }, -}; - -const testFilter2: Filter = { - meta: { - key: 'testfield', - alias: null, - disabled: false, - negate: false, - }, - query: { match_phrase: { testfield: 'guten tag' } }, -}; - -const testFilter3: Filter = { - meta: { - key: 'testfield', - alias: null, - disabled: false, - negate: false, - }, - query: { - bool: { - should: { - 0: { match_phrase: { testfield: 'hola' } }, - 1: { match_phrase: { testfield: 'bonjour' } }, - }, - }, - }, -}; - -describe('combineDashboardFiltersWithControlGroupFilters', () => { - it('Combined filter pills do not get overwritten', async () => { - const dashboardFilterPills = [testFilter1, testFilter2]; - const mockControlGroupApi = { - filters$: new BehaviorSubject([]), - }; - const combinedFilters = combineDashboardFiltersWithControlGroupFilters( - dashboardFilterPills, - mockControlGroupApi - ); - expect(combinedFilters).toEqual(dashboardFilterPills); - }); - - it('Combined control filters do not get overwritten', async () => { - const controlGroupFilters = [testFilter1, testFilter2]; - const mockControlGroupApi = { - filters$: new BehaviorSubject(controlGroupFilters), - }; - const combinedFilters = combineDashboardFiltersWithControlGroupFilters( - [] as Filter[], - mockControlGroupApi - ); - expect(combinedFilters).toEqual(controlGroupFilters); - }); - - it('Combined dashboard filter pills and control filters do not get overwritten', async () => { - const dashboardFilterPills = [testFilter1, testFilter2]; - const controlGroupFilters = [testFilter3]; - const mockControlGroupApi = { - filters$: new BehaviorSubject(controlGroupFilters), - }; - const combinedFilters = combineDashboardFiltersWithControlGroupFilters( - dashboardFilterPills, - mockControlGroupApi - ); - expect(combinedFilters).toEqual(dashboardFilterPills.concat(controlGroupFilters)); - }); -}); diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/controls/dashboard_control_group_integration.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/controls/dashboard_control_group_integration.ts deleted file mode 100644 index 299b8111e37e5..0000000000000 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/controls/dashboard_control_group_integration.ts +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { COMPARE_ALL_OPTIONS, compareFilters, type Filter } from '@kbn/es-query'; -import { - BehaviorSubject, - combineLatest, - distinctUntilChanged, - map, - of, - skip, - startWith, - switchMap, -} from 'rxjs'; -import { PublishesFilters, PublishingSubject } from '@kbn/presentation-publishing'; -import { DashboardContainer } from '../../dashboard_container'; - -export function startSyncingDashboardControlGroup(dashboard: DashboardContainer) { - const controlGroupFilters$ = dashboard.controlGroupApi$.pipe( - switchMap((controlGroupApi) => (controlGroupApi ? controlGroupApi.filters$ : of(undefined))) - ); - const controlGroupTimeslice$ = dashboard.controlGroupApi$.pipe( - switchMap((controlGroupApi) => (controlGroupApi ? controlGroupApi.timeslice$ : of(undefined))) - ); - - // -------------------------------------------------------------------------------------- - // dashboard.unifiedSearchFilters$ - // -------------------------------------------------------------------------------------- - const unifiedSearchFilters$ = new BehaviorSubject( - dashboard.getInput().filters - ); - dashboard.unifiedSearchFilters$ = unifiedSearchFilters$ as PublishingSubject< - Filter[] | undefined - >; - dashboard.publishingSubscription.add( - dashboard - .getInput$() - .pipe( - startWith(dashboard.getInput()), - map((input) => input.filters), - distinctUntilChanged((previous, current) => { - return compareFilters(previous ?? [], current ?? [], COMPARE_ALL_OPTIONS); - }) - ) - .subscribe((unifiedSearchFilters) => { - unifiedSearchFilters$.next(unifiedSearchFilters); - }) - ); - - // -------------------------------------------------------------------------------------- - // Set dashboard.filters$ to include unified search filters and control group filters - // -------------------------------------------------------------------------------------- - function getCombinedFilters() { - return combineDashboardFiltersWithControlGroupFilters( - dashboard.getInput().filters ?? [], - dashboard.controlGroupApi$.value - ); - } - - const filters$ = new BehaviorSubject(getCombinedFilters()); - dashboard.filters$ = filters$; - - dashboard.publishingSubscription.add( - combineLatest([dashboard.unifiedSearchFilters$, controlGroupFilters$]).subscribe(() => { - filters$.next(getCombinedFilters()); - }) - ); - - // -------------------------------------------------------------------------------------- - // when control group outputs filters, force a refresh! - // -------------------------------------------------------------------------------------- - dashboard.publishingSubscription.add( - controlGroupFilters$ - .pipe( - skip(1) // skip first filter output because it will have been applied in initialize - ) - .subscribe(() => dashboard.forceRefresh(false)) // we should not reload the control group when the control group output changes - otherwise, performance is severely impacted - ); - - // -------------------------------------------------------------------------------------- - // when control group outputs timeslice, dispatch timeslice - // -------------------------------------------------------------------------------------- - dashboard.publishingSubscription.add( - controlGroupTimeslice$.subscribe((timeslice) => { - dashboard.dispatch.setTimeslice(timeslice); - }) - ); -} - -export const combineDashboardFiltersWithControlGroupFilters = ( - dashboardFilters: Filter[], - controlGroupApi?: PublishesFilters -): Filter[] => { - return [...dashboardFilters, ...(controlGroupApi?.filters$.value ?? [])]; -}; diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.test.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.test.ts deleted file mode 100644 index ddcdeff25ea4e..0000000000000 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.test.ts +++ /dev/null @@ -1,521 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { EmbeddablePackageState, ViewMode } from '@kbn/embeddable-plugin/public'; -import { - CONTACT_CARD_EMBEDDABLE, - ContactCardEmbeddable, - ContactCardEmbeddableFactory, - ContactCardEmbeddableInput, - ContactCardEmbeddableOutput, -} from '@kbn/embeddable-plugin/public/lib/test_samples'; -import { Filter } from '@kbn/es-query'; -import { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; - -import { DEFAULT_DASHBOARD_INPUT } from '../../../dashboard_constants'; -import { getSampleDashboardPanel, mockControlGroupApi } from '../../../mocks'; -import { dataService, embeddableService } from '../../../services/kibana_services'; -import { DashboardCreationOptions } from '../../..'; -import { createDashboard } from './create_dashboard'; -import { getDashboardContentManagementService } from '../../../services/dashboard_content_management_service'; -import { getDashboardBackupService } from '../../../services/dashboard_backup_service'; - -const dashboardBackupService = getDashboardBackupService(); -const dashboardContentManagementService = getDashboardContentManagementService(); - -test("doesn't throw error when no data views are available", async () => { - dataService.dataViews.defaultDataViewExists = jest.fn().mockReturnValue(false); - expect(await createDashboard()).toBeDefined(); - - // reset get default data view - dataService.dataViews.defaultDataViewExists = jest.fn().mockResolvedValue(true); -}); - -test('throws error when provided validation function returns invalid', async () => { - const creationOptions: DashboardCreationOptions = { - validateLoadedSavedObject: jest.fn().mockImplementation(() => 'invalid'), - }; - await expect(async () => { - await createDashboard(creationOptions, 0, 'test-id'); - }).rejects.toThrow('Dashboard failed saved object result validation'); -}); - -test('returns undefined when provided validation function returns redirected', async () => { - const creationOptions: DashboardCreationOptions = { - validateLoadedSavedObject: jest.fn().mockImplementation(() => 'redirected'), - }; - const dashboard = await createDashboard(creationOptions, 0, 'test-id'); - expect(dashboard).toBeUndefined(); -}); - -/** - * Because the getInitialInput function may have side effects, we only want to call it once we are certain that the - * the loaded saved object passes validation. - * - * This is especially relevant in the Dashboard App case where calling the getInitialInput function removes the _a - * param from the URL. In alais match situations this caused a bug where the state from the URL wasn't properly applied - * after the redirect. - */ -test('does not get initial input when provided validation function returns redirected', async () => { - const creationOptions: DashboardCreationOptions = { - validateLoadedSavedObject: jest.fn().mockImplementation(() => 'redirected'), - getInitialInput: jest.fn(), - }; - const dashboard = await createDashboard(creationOptions, 0, 'test-id'); - expect(dashboard).toBeUndefined(); - expect(creationOptions.getInitialInput).not.toHaveBeenCalled(); -}); - -test('pulls state from dashboard saved object when given a saved object id', async () => { - dashboardContentManagementService.loadDashboardState = jest.fn().mockResolvedValue({ - dashboardInput: { - ...DEFAULT_DASHBOARD_INPUT, - description: `wow would you look at that? Wow.`, - }, - }); - const dashboard = await createDashboard({}, 0, 'wow-such-id'); - expect(dashboardContentManagementService.loadDashboardState).toHaveBeenCalledWith({ - id: 'wow-such-id', - }); - expect(dashboard).toBeDefined(); - expect(dashboard!.getState().explicitInput.description).toBe(`wow would you look at that? Wow.`); -}); - -test('passes managed state from the saved object into the Dashboard component state', async () => { - dashboardContentManagementService.loadDashboardState = jest.fn().mockResolvedValue({ - dashboardInput: { - ...DEFAULT_DASHBOARD_INPUT, - description: 'wow this description is okay', - }, - managed: true, - }); - const dashboard = await createDashboard({}, 0, 'what-an-id'); - expect(dashboard).toBeDefined(); - expect(dashboard!.managed$.value).toBe(true); -}); - -test('pulls view mode from dashboard backup', async () => { - dashboardContentManagementService.loadDashboardState = jest.fn().mockResolvedValue({ - dashboardInput: DEFAULT_DASHBOARD_INPUT, - }); - dashboardBackupService.getViewMode = jest.fn().mockReturnValue(ViewMode.EDIT); - const dashboard = await createDashboard({ useSessionStorageIntegration: true }, 0, 'what-an-id'); - expect(dashboard).toBeDefined(); - expect(dashboard!.getState().explicitInput.viewMode).toBe(ViewMode.EDIT); -}); - -test('new dashboards start in edit mode', async () => { - dashboardBackupService.getViewMode = jest.fn().mockReturnValue(ViewMode.VIEW); - dashboardContentManagementService.loadDashboardState = jest.fn().mockResolvedValue({ - newDashboardCreated: true, - dashboardInput: { - ...DEFAULT_DASHBOARD_INPUT, - description: 'wow this description is okay', - }, - }); - const dashboard = await createDashboard({ useSessionStorageIntegration: true }, 0, 'wow-such-id'); - expect(dashboard).toBeDefined(); - expect(dashboard!.getState().explicitInput.viewMode).toBe(ViewMode.EDIT); -}); - -test('managed dashboards start in view mode', async () => { - dashboardBackupService.getViewMode = jest.fn().mockReturnValue(ViewMode.EDIT); - dashboardContentManagementService.loadDashboardState = jest.fn().mockResolvedValue({ - dashboardInput: DEFAULT_DASHBOARD_INPUT, - managed: true, - }); - const dashboard = await createDashboard({}, 0, 'what-an-id'); - expect(dashboard).toBeDefined(); - expect(dashboard!.managed$.value).toBe(true); - expect(dashboard!.getState().explicitInput.viewMode).toBe(ViewMode.VIEW); -}); - -test('pulls state from backup which overrides state from saved object', async () => { - dashboardContentManagementService.loadDashboardState = jest.fn().mockResolvedValue({ - dashboardInput: { - ...DEFAULT_DASHBOARD_INPUT, - description: 'wow this description is okay', - }, - }); - dashboardBackupService.getState = jest - .fn() - .mockReturnValue({ dashboardState: { description: 'wow this description marginally better' } }); - const dashboard = await createDashboard({ useSessionStorageIntegration: true }, 0, 'wow-such-id'); - expect(dashboard).toBeDefined(); - expect(dashboard!.getState().explicitInput.description).toBe( - 'wow this description marginally better' - ); -}); - -test('pulls state from override input which overrides all other state sources', async () => { - dashboardContentManagementService.loadDashboardState = jest.fn().mockResolvedValue({ - dashboardInput: { - ...DEFAULT_DASHBOARD_INPUT, - description: 'wow this description is okay', - }, - }); - dashboardBackupService.getState = jest - .fn() - .mockReturnValue({ description: 'wow this description marginally better' }); - const dashboard = await createDashboard( - { - useSessionStorageIntegration: true, - getInitialInput: () => ({ description: 'wow this description is a masterpiece' }), - }, - 0, - 'wow-such-id' - ); - expect(dashboard).toBeDefined(); - expect(dashboard!.getState().explicitInput.description).toBe( - 'wow this description is a masterpiece' - ); -}); - -test('pulls panels from override input', async () => { - embeddableService.reactEmbeddableRegistryHasKey = jest - .fn() - .mockImplementation((type: string) => type === 'reactEmbeddable'); - dashboardContentManagementService.loadDashboardState = jest.fn().mockResolvedValue({ - dashboardInput: { - ...DEFAULT_DASHBOARD_INPUT, - panels: { - ...DEFAULT_DASHBOARD_INPUT.panels, - someLegacyPanel: { - type: 'legacy', - gridData: { x: 0, y: 0, w: 0, h: 0, i: 'someLegacyPanel' }, - explicitInput: { - id: 'someLegacyPanel', - title: 'stateFromSavedObject', - }, - }, - someReactEmbeddablePanel: { - type: 'reactEmbeddable', - gridData: { x: 0, y: 0, w: 0, h: 0, i: 'someReactEmbeddablePanel' }, - explicitInput: { - id: 'someReactEmbeddablePanel', - title: 'stateFromSavedObject', - }, - }, - }, - }, - }); - const dashboard = await createDashboard( - { - useSessionStorageIntegration: true, - getInitialInput: () => ({ - ...DEFAULT_DASHBOARD_INPUT, - panels: { - ...DEFAULT_DASHBOARD_INPUT.panels, - someLegacyPanel: { - type: 'legacy', - gridData: { x: 0, y: 0, w: 0, h: 0, i: 'someLegacyPanel' }, - explicitInput: { - id: 'someLegacyPanel', - title: 'Look at me, I am the override now', - }, - }, - someReactEmbeddablePanel: { - type: 'reactEmbeddable', - gridData: { x: 0, y: 0, w: 0, h: 0, i: 'someReactEmbeddablePanel' }, - explicitInput: { - id: 'someReactEmbeddablePanel', - title: 'an elegant override, from a more civilized age', - }, - }, - }, - }), - }, - 0, - 'wow-such-id' - ); - expect(dashboard).toBeDefined(); - - // legacy panels should be completely overwritten directly in the explicitInput - expect(dashboard!.getState().explicitInput.panels.someLegacyPanel.explicitInput.title).toBe( - 'Look at me, I am the override now' - ); - - // React embeddable should still have the old state in their explicit input - expect( - dashboard!.getState().explicitInput.panels.someReactEmbeddablePanel.explicitInput.title - ).toBe('stateFromSavedObject'); - - // instead, the unsaved changes for React embeddables should be applied to the "restored runtime state" property of the Dashboard. - expect( - (dashboard!.getRuntimeStateForChild('someReactEmbeddablePanel') as { title: string }).title - ).toEqual('an elegant override, from a more civilized age'); -}); - -test('applies filters and query from state to query service', async () => { - const filters: Filter[] = [ - { meta: { alias: 'test', disabled: false, negate: false, index: 'test' } }, - ]; - const query = { language: 'kql', query: 'query' }; - await createDashboard({ - useUnifiedSearchIntegration: true, - unifiedSearchSettings: { - kbnUrlStateStorage: createKbnUrlStateStorage(), - }, - getInitialInput: () => ({ filters, query }), - }); - expect(dataService.query.queryString.setQuery).toHaveBeenCalledWith(query); - expect(dataService.query.filterManager.setAppFilters).toHaveBeenCalledWith(filters); -}); - -test('applies time range and refresh interval from initial input to query service if time restore is on', async () => { - const timeRange = { from: new Date().toISOString(), to: new Date().toISOString() }; - const refreshInterval = { pause: false, value: 42 }; - await createDashboard({ - useUnifiedSearchIntegration: true, - unifiedSearchSettings: { - kbnUrlStateStorage: createKbnUrlStateStorage(), - }, - getInitialInput: () => ({ timeRange, refreshInterval, timeRestore: true }), - }); - expect(dataService.query.timefilter.timefilter.setTime).toHaveBeenCalledWith(timeRange); - expect(dataService.query.timefilter.timefilter.setRefreshInterval).toHaveBeenCalledWith( - refreshInterval - ); -}); - -test('applies time range from query service to initial input if time restore is on but there is an explicit time range in the URL', async () => { - const urlTimeRange = { from: new Date().toISOString(), to: new Date().toISOString() }; - const savedTimeRange = { from: 'now - 7 days', to: 'now' }; - dataService.query.timefilter.timefilter.getTime = jest.fn().mockReturnValue(urlTimeRange); - const kbnUrlStateStorage = createKbnUrlStateStorage(); - kbnUrlStateStorage.get = jest.fn().mockReturnValue({ time: urlTimeRange }); - - const dashboard = await createDashboard({ - useUnifiedSearchIntegration: true, - unifiedSearchSettings: { - kbnUrlStateStorage, - }, - getInitialInput: () => ({ - timeRestore: true, - timeRange: savedTimeRange, - }), - }); - expect(dashboard).toBeDefined(); - expect(dashboard!.getState().explicitInput.timeRange).toEqual(urlTimeRange); -}); - -test('applies time range from query service to initial input if time restore is off', async () => { - const timeRange = { from: new Date().toISOString(), to: new Date().toISOString() }; - dataService.query.timefilter.timefilter.getTime = jest.fn().mockReturnValue(timeRange); - const dashboard = await createDashboard({ - useUnifiedSearchIntegration: true, - unifiedSearchSettings: { - kbnUrlStateStorage: createKbnUrlStateStorage(), - }, - }); - expect(dashboard).toBeDefined(); - expect(dashboard!.getState().explicitInput.timeRange).toEqual(timeRange); -}); - -test('replaces panel with incoming embeddable if id matches existing panel', async () => { - const incomingEmbeddable: EmbeddablePackageState = { - type: CONTACT_CARD_EMBEDDABLE, - input: { - id: 'i_match', - firstName: 'wow look at this replacement wow', - } as ContactCardEmbeddableInput, - embeddableId: 'i_match', - }; - const dashboard = await createDashboard({ - getIncomingEmbeddable: () => incomingEmbeddable, - getInitialInput: () => ({ - panels: { - i_match: getSampleDashboardPanel({ - explicitInput: { - id: 'i_match', - firstName: 'oh no, I am about to get replaced', - }, - type: CONTACT_CARD_EMBEDDABLE, - }), - }, - }), - }); - expect(dashboard).toBeDefined(); - expect(dashboard!.getState().explicitInput.panels.i_match.explicitInput).toStrictEqual( - expect.objectContaining({ - id: 'i_match', - firstName: 'wow look at this replacement wow', - }) - ); -}); - -test('creates new embeddable with incoming embeddable if id does not match existing panel', async () => { - const incomingEmbeddable: EmbeddablePackageState = { - type: CONTACT_CARD_EMBEDDABLE, - input: { - id: 'i_match', - firstName: 'wow look at this new panel wow', - } as ContactCardEmbeddableInput, - embeddableId: 'i_match', - }; - const mockContactCardFactory = { - create: jest.fn().mockReturnValue({ destroy: jest.fn() }), - getDefaultInput: jest.fn().mockResolvedValue({}), - }; - embeddableService.getEmbeddableFactory = jest.fn().mockReturnValue(mockContactCardFactory); - - const dashboard = await createDashboard({ - getIncomingEmbeddable: () => incomingEmbeddable, - getInitialInput: () => ({ - panels: { - i_do_not_match: getSampleDashboardPanel({ - explicitInput: { - id: 'i_do_not_match', - firstName: 'phew... I will not be replaced', - }, - type: CONTACT_CARD_EMBEDDABLE, - }), - }, - }), - }); - dashboard?.setControlGroupApi(mockControlGroupApi); - - // flush promises - await new Promise((r) => setTimeout(r, 1)); - expect(mockContactCardFactory.create).toHaveBeenCalledWith( - expect.objectContaining({ - id: 'i_match', - firstName: 'wow look at this new panel wow', - }), - expect.any(Object) - ); - expect(dashboard!.getState().explicitInput.panels.i_match.explicitInput).toStrictEqual( - expect.objectContaining({ - id: 'i_match', - firstName: 'wow look at this new panel wow', - }) - ); - expect(dashboard!.getState().explicitInput.panels.i_do_not_match.explicitInput).toStrictEqual( - expect.objectContaining({ - id: 'i_do_not_match', - firstName: 'phew... I will not be replaced', - }) - ); - - // expect panel to be created with the default size. - expect(dashboard!.getState().explicitInput.panels.i_match.gridData.w).toBe(24); - expect(dashboard!.getState().explicitInput.panels.i_match.gridData.h).toBe(15); -}); - -test('creates new embeddable with specified size if size is provided', async () => { - const incomingEmbeddable: EmbeddablePackageState = { - type: CONTACT_CARD_EMBEDDABLE, - input: { - id: 'new_panel', - firstName: 'what a tiny lil panel', - } as ContactCardEmbeddableInput, - size: { width: 1, height: 1 }, - embeddableId: 'new_panel', - }; - const mockContactCardFactory = { - create: jest.fn().mockReturnValue({ destroy: jest.fn() }), - getDefaultInput: jest.fn().mockResolvedValue({}), - }; - embeddableService.getEmbeddableFactory = jest.fn().mockReturnValue(mockContactCardFactory); - - const dashboard = await createDashboard({ - getIncomingEmbeddable: () => incomingEmbeddable, - getInitialInput: () => ({ - panels: { - i_do_not_match: getSampleDashboardPanel({ - explicitInput: { - id: 'i_do_not_match', - firstName: 'phew... I will not be replaced', - }, - type: CONTACT_CARD_EMBEDDABLE, - }), - }, - }), - }); - dashboard?.setControlGroupApi(mockControlGroupApi); - - // flush promises - await new Promise((r) => setTimeout(r, 1)); - - expect(mockContactCardFactory.create).toHaveBeenCalledWith( - expect.objectContaining({ - id: 'new_panel', - firstName: 'what a tiny lil panel', - }), - expect.any(Object) - ); - expect(dashboard!.getState().explicitInput.panels.new_panel.explicitInput).toStrictEqual( - expect.objectContaining({ - id: 'new_panel', - firstName: 'what a tiny lil panel', - }) - ); - expect(dashboard!.getState().explicitInput.panels.new_panel.gridData.w).toBe(1); - expect(dashboard!.getState().explicitInput.panels.new_panel.gridData.h).toBe(1); -}); - -/* - * dashboard.getInput$() subscriptions are used to update: - * 1) dashboard instance searchSessionId state - * 2) child input on parent input changes - * - * Rxjs subscriptions are executed in the order that they are created. - * This test ensures that searchSessionId update subscription is created before child input subscription - * to ensure child input subscription includes updated searchSessionId. - */ -test('searchSessionId is updated prior to child embeddable parent subscription execution', async () => { - const embeddableFactory = { - create: new ContactCardEmbeddableFactory((() => null) as any, {} as any), - getDefaultInput: jest.fn().mockResolvedValue({ - timeRange: { - to: 'now', - from: 'now-15m', - }, - }), - }; - embeddableService.getEmbeddableFactory = jest.fn().mockReturnValue(embeddableFactory); - let sessionCount = 0; - dataService.search.session.start = () => { - sessionCount++; - return `searchSessionId${sessionCount}`; - }; - const dashboard = await createDashboard({ - searchSessionSettings: { - getSearchSessionIdFromURL: () => undefined, - removeSessionIdFromUrl: () => {}, - createSessionRestorationDataProvider: () => {}, - } as unknown as DashboardCreationOptions['searchSessionSettings'], - }); - dashboard?.setControlGroupApi(mockControlGroupApi); - expect(dashboard).toBeDefined(); - const embeddable = await dashboard!.addNewEmbeddable< - ContactCardEmbeddableInput, - ContactCardEmbeddableOutput, - ContactCardEmbeddable - >(CONTACT_CARD_EMBEDDABLE, { - firstName: 'Bob', - }); - - expect(embeddable.getInput().searchSessionId).toBe('searchSessionId1'); - - dashboard!.updateInput({ - timeRange: { - to: 'now', - from: 'now-7d', - }, - }); - - expect(sessionCount).toBeGreaterThan(1); - const embeddableInput = embeddable.getInput(); - expect((embeddableInput as any).timeRange).toEqual({ - to: 'now', - from: 'now-7d', - }); - expect(embeddableInput.searchSessionId).toBe(`searchSessionId${sessionCount}`); -}); diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts deleted file mode 100644 index 2510f2e015dfb..0000000000000 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts +++ /dev/null @@ -1,522 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { cloneDeep, omit } from 'lodash'; -import { Subject } from 'rxjs'; -import { v4 } from 'uuid'; - -import { ContentInsightsClient } from '@kbn/content-management-content-insights-public'; -import { GlobalQueryStateFromUrl, syncGlobalQueryStateWithUrl } from '@kbn/data-plugin/public'; -import { ViewMode } from '@kbn/embeddable-plugin/public'; -import { TimeRange } from '@kbn/es-query'; -import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public'; - -import { - DashboardContainerInput, - DashboardPanelMap, - DashboardPanelState, -} from '../../../../common'; -import { - DEFAULT_DASHBOARD_INPUT, - DEFAULT_PANEL_HEIGHT, - DEFAULT_PANEL_WIDTH, - GLOBAL_STATE_STORAGE_KEY, - PanelPlacementStrategy, -} from '../../../dashboard_constants'; -import { - PANELS_CONTROL_GROUP_KEY, - getDashboardBackupService, -} from '../../../services/dashboard_backup_service'; -import { getDashboardContentManagementService } from '../../../services/dashboard_content_management_service'; -import { - LoadDashboardReturn, - SavedDashboardInput, -} from '../../../services/dashboard_content_management_service/types'; -import { coreServices, dataService, embeddableService } from '../../../services/kibana_services'; -import { getDashboardCapabilities } from '../../../utils/get_dashboard_capabilities'; -import { runPanelPlacementStrategy } from '../../panel_placement/place_new_panel_strategies'; -import { startDiffingDashboardState } from '../../state/diffing/dashboard_diffing_integration'; -import { UnsavedPanelState } from '../../types'; -import { DashboardContainer } from '../dashboard_container'; -import type { DashboardCreationOptions } from '../../..'; -import { startSyncingDashboardDataViews } from './data_views/sync_dashboard_data_views'; -import { startQueryPerformanceTracking } from './performance/query_performance_tracking'; -import { startDashboardSearchSessionIntegration } from './search_sessions/start_dashboard_search_session_integration'; -import { syncUnifiedSearchState } from './unified_search/sync_dashboard_unified_search_state'; -import { InitialComponentState } from '../../../dashboard_api/get_dashboard_api'; - -/** - * Builds a new Dashboard from scratch. - */ -export const createDashboard = async ( - creationOptions?: DashboardCreationOptions, - dashboardCreationStartTime?: number, - savedObjectId?: string -): Promise => { - // -------------------------------------------------------------------------------------- - // Create method which allows work to be done on the dashboard container when it's ready. - // -------------------------------------------------------------------------------------- - const dashboardContainerReady$ = new Subject(); - const untilDashboardReady = () => - new Promise((resolve) => { - const subscription = dashboardContainerReady$.subscribe((container) => { - subscription.unsubscribe(); - resolve(container); - }); - }); - - // -------------------------------------------------------------------------------------- - // Lazy load required systems and Dashboard saved object. - // -------------------------------------------------------------------------------------- - const reduxEmbeddablePackagePromise = lazyLoadReduxToolsPackage(); - const defaultDataViewExistsPromise = dataService.dataViews.defaultDataViewExists(); - const dashboardContentManagementService = getDashboardContentManagementService(); - const dashboardSavedObjectPromise = dashboardContentManagementService.loadDashboardState({ - id: savedObjectId, - }); - - const [reduxEmbeddablePackage, savedObjectResult] = await Promise.all([ - reduxEmbeddablePackagePromise, - dashboardSavedObjectPromise, - defaultDataViewExistsPromise /* the result is not used, but the side effect of setting the default data view is needed. */, - ]); - - // -------------------------------------------------------------------------------------- - // Initialize Dashboard integrations - // -------------------------------------------------------------------------------------- - const initializeResult = await initializeDashboard({ - loadDashboardReturn: savedObjectResult, - untilDashboardReady, - creationOptions, - }); - if (!initializeResult) return; - const { input, searchSessionId } = initializeResult; - - // -------------------------------------------------------------------------------------- - // Build the dashboard container. - // -------------------------------------------------------------------------------------- - const initialComponentState: InitialComponentState = { - anyMigrationRun: savedObjectResult.anyMigrationRun ?? false, - isEmbeddedExternally: creationOptions?.isEmbeddedExternally ?? false, - lastSavedInput: omit(savedObjectResult?.dashboardInput, 'controlGroupInput') ?? { - ...DEFAULT_DASHBOARD_INPUT, - id: input.id, - }, - lastSavedId: savedObjectId, - managed: savedObjectResult.managed ?? false, - fullScreenMode: creationOptions?.fullScreenMode ?? false, - }; - - const dashboardContainer = new DashboardContainer( - input, - reduxEmbeddablePackage, - searchSessionId, - dashboardCreationStartTime, - undefined, - creationOptions, - initialComponentState - ); - - // -------------------------------------------------------------------------------------- - // Start the diffing integration after all other integrations are set up. - // -------------------------------------------------------------------------------------- - untilDashboardReady().then((container) => { - startDiffingDashboardState.bind(container)(creationOptions); - }); - - dashboardContainerReady$.next(dashboardContainer); - return dashboardContainer; -}; - -/** - * Initializes a Dashboard and starts all of its integrations - */ -export const initializeDashboard = async ({ - loadDashboardReturn, - untilDashboardReady, - creationOptions, -}: { - loadDashboardReturn: LoadDashboardReturn; - untilDashboardReady: () => Promise; - creationOptions?: DashboardCreationOptions; -}) => { - const { - queryString, - filterManager, - timefilter: { timefilter: timefilterService }, - } = dataService.query; - const dashboardBackupService = getDashboardBackupService(); - - const { - getInitialInput, - searchSessionSettings, - unifiedSearchSettings, - validateLoadedSavedObject, - useUnifiedSearchIntegration, - useSessionStorageIntegration, - } = creationOptions ?? {}; - - // -------------------------------------------------------------------------------------- - // Run validation. - // -------------------------------------------------------------------------------------- - const validationResult = loadDashboardReturn && validateLoadedSavedObject?.(loadDashboardReturn); - if (validationResult === 'invalid') { - // throw error to stop the rest of Dashboard loading and make the factory return an ErrorEmbeddable. - throw new Error('Dashboard failed saved object result validation'); - } else if (validationResult === 'redirected') { - return; - } - - // -------------------------------------------------------------------------------------- - // Combine input from saved object, and session storage - // -------------------------------------------------------------------------------------- - const dashboardBackupState = dashboardBackupService.getState(loadDashboardReturn.dashboardId); - const runtimePanelsToRestore: UnsavedPanelState = useSessionStorageIntegration - ? dashboardBackupState?.panels ?? {} - : {}; - - const sessionStorageInput = ((): Partial | undefined => { - if (!useSessionStorageIntegration) return; - return dashboardBackupState?.dashboardState; - })(); - const initialViewMode = (() => { - if (loadDashboardReturn.managed || !getDashboardCapabilities().showWriteControls) - return ViewMode.VIEW; - if ( - loadDashboardReturn.newDashboardCreated || - dashboardBackupService.dashboardHasUnsavedEdits(loadDashboardReturn.dashboardId) - ) { - return ViewMode.EDIT; - } - - return dashboardBackupService.getViewMode(); - })(); - - const combinedSessionInput: DashboardContainerInput = { - ...DEFAULT_DASHBOARD_INPUT, - ...(loadDashboardReturn?.dashboardInput ?? {}), - ...sessionStorageInput, - }; - - // -------------------------------------------------------------------------------------- - // Combine input with overrides. - // -------------------------------------------------------------------------------------- - const overrideInput = getInitialInput?.(); - if (overrideInput?.panels) { - /** - * react embeddables and legacy embeddables share state very differently, so we need different - * treatment here. TODO remove this distinction when we remove the legacy embeddable system. - */ - const overridePanels: DashboardPanelMap = {}; - - for (const panel of Object.values(overrideInput?.panels)) { - if (embeddableService.reactEmbeddableRegistryHasKey(panel.type)) { - overridePanels[panel.explicitInput.id] = { - ...panel, - - /** - * here we need to keep the state of the panel that was already in the Dashboard if one exists. - * This is because this state will become the "last saved state" for this panel. - */ - ...(combinedSessionInput.panels[panel.explicitInput.id] ?? []), - }; - /** - * We also need to add the state of this react embeddable into the runtime state to be restored. - */ - runtimePanelsToRestore[panel.explicitInput.id] = panel.explicitInput; - } else { - /** - * if this is a legacy embeddable, the override state needs to completely overwrite the existing - * state for this panel. - */ - overridePanels[panel.explicitInput.id] = panel; - } - } - - /** - * If this is a React embeddable, we leave the "panel" state as-is and add this state to the - * runtime state to be restored on dashboard load. - */ - overrideInput.panels = overridePanels; - } - const combinedOverrideInput: DashboardContainerInput = { - ...combinedSessionInput, - ...(initialViewMode ? { viewMode: initialViewMode } : {}), - ...overrideInput, - }; - - // -------------------------------------------------------------------------------------- - // Combine input from saved object, session storage, & passed input to create initial input. - // -------------------------------------------------------------------------------------- - const initialDashboardInput: DashboardContainerInput = omit( - cloneDeep(combinedOverrideInput), - 'controlGroupInput' - ); - - // Back up any view mode passed in explicitly. - if (overrideInput?.viewMode) { - dashboardBackupService.storeViewMode(overrideInput?.viewMode); - } - - initialDashboardInput.executionContext = { - type: 'dashboard', - description: initialDashboardInput.title, - }; - - // -------------------------------------------------------------------------------------- - // Track references - // -------------------------------------------------------------------------------------- - untilDashboardReady().then((dashboard) => { - dashboard.savedObjectReferences = loadDashboardReturn?.references; - dashboard.controlGroupInput = loadDashboardReturn?.dashboardInput?.controlGroupInput; - }); - - // -------------------------------------------------------------------------------------- - // Set up unified search integration. - // -------------------------------------------------------------------------------------- - if (useUnifiedSearchIntegration && unifiedSearchSettings?.kbnUrlStateStorage) { - const { - query, - filters, - timeRestore, - timeRange: savedTimeRange, - refreshInterval: savedRefreshInterval, - } = initialDashboardInput; - const { kbnUrlStateStorage } = unifiedSearchSettings; - - // apply filters and query to the query service - filterManager.setAppFilters(cloneDeep(filters ?? [])); - queryString.setQuery(query ?? queryString.getDefaultQuery()); - - /** - * Get initial time range, and set up dashboard time restore if applicable - */ - const initialTimeRange: TimeRange = (() => { - // if there is an explicit time range in the URL it always takes precedence. - const urlOverrideTimeRange = - kbnUrlStateStorage.get(GLOBAL_STATE_STORAGE_KEY)?.time; - if (urlOverrideTimeRange) return urlOverrideTimeRange; - - // if this Dashboard has timeRestore return the time range that was saved with the dashboard. - if (timeRestore && savedTimeRange) return savedTimeRange; - - // otherwise fall back to the time range from the timefilterService. - return timefilterService.getTime(); - })(); - initialDashboardInput.timeRange = initialTimeRange; - if (timeRestore) { - if (savedTimeRange) timefilterService.setTime(savedTimeRange); - if (savedRefreshInterval) timefilterService.setRefreshInterval(savedRefreshInterval); - } - - // start syncing global query state with the URL. - const { stop: stopSyncingQueryServiceStateWithUrl } = syncGlobalQueryStateWithUrl( - dataService.query, - kbnUrlStateStorage - ); - - untilDashboardReady().then((dashboardContainer) => { - const stopSyncingUnifiedSearchState = - syncUnifiedSearchState.bind(dashboardContainer)(kbnUrlStateStorage); - dashboardContainer.stopSyncingWithUnifiedSearch = () => { - stopSyncingUnifiedSearchState(); - stopSyncingQueryServiceStateWithUrl(); - }; - }); - } - - // -------------------------------------------------------------------------------------- - // Place the incoming embeddable if there is one - // -------------------------------------------------------------------------------------- - const incomingEmbeddable = creationOptions?.getIncomingEmbeddable?.(); - if (incomingEmbeddable) { - const scrolltoIncomingEmbeddable = (container: DashboardContainer, id: string) => { - container.setScrollToPanelId(id); - container.setHighlightPanelId(id); - }; - - initialDashboardInput.viewMode = ViewMode.EDIT; // view mode must always be edit to recieve an embeddable. - if ( - incomingEmbeddable.embeddableId && - Boolean(initialDashboardInput.panels[incomingEmbeddable.embeddableId]) - ) { - // this embeddable already exists, we will update the explicit input. - const panelToUpdate = initialDashboardInput.panels[incomingEmbeddable.embeddableId]; - const sameType = panelToUpdate.type === incomingEmbeddable.type; - - panelToUpdate.type = incomingEmbeddable.type; - const nextRuntimeState = { - // if the incoming panel is the same type as what was there before we can safely spread the old panel's explicit input - ...(sameType ? panelToUpdate.explicitInput : {}), - - ...incomingEmbeddable.input, - id: incomingEmbeddable.embeddableId, - - // maintain hide panel titles setting. - hidePanelTitles: panelToUpdate.explicitInput.hidePanelTitles, - }; - if (embeddableService.reactEmbeddableRegistryHasKey(incomingEmbeddable.type)) { - panelToUpdate.explicitInput = { id: panelToUpdate.explicitInput.id }; - runtimePanelsToRestore[incomingEmbeddable.embeddableId] = nextRuntimeState; - } else { - panelToUpdate.explicitInput = nextRuntimeState; - } - - untilDashboardReady().then((container) => - scrolltoIncomingEmbeddable(container, incomingEmbeddable.embeddableId as string) - ); - } else { - // otherwise this incoming embeddable is brand new and can be added after the dashboard container is created. - - untilDashboardReady().then(async (container) => { - const createdEmbeddable = await (async () => { - // if there is no width or height we can add the panel using the default behaviour. - if (!incomingEmbeddable.size) { - return await container.addNewPanel<{ uuid: string }>({ - panelType: incomingEmbeddable.type, - initialState: incomingEmbeddable.input, - }); - } - - // if the incoming embeddable has an explicit width or height we add the panel to the grid directly. - const { width, height } = incomingEmbeddable.size; - const currentPanels = container.getInput().panels; - const embeddableId = incomingEmbeddable.embeddableId ?? v4(); - const { newPanelPlacement } = runPanelPlacementStrategy( - PanelPlacementStrategy.findTopLeftMostOpenSpace, - { - width: width ?? DEFAULT_PANEL_WIDTH, - height: height ?? DEFAULT_PANEL_HEIGHT, - currentPanels, - } - ); - const newPanelState: DashboardPanelState = (() => { - if (embeddableService.reactEmbeddableRegistryHasKey(incomingEmbeddable.type)) { - runtimePanelsToRestore[embeddableId] = incomingEmbeddable.input; - return { - explicitInput: { id: embeddableId }, - type: incomingEmbeddable.type, - gridData: { - ...newPanelPlacement, - i: embeddableId, - }, - }; - } - return { - explicitInput: { ...incomingEmbeddable.input, id: embeddableId }, - type: incomingEmbeddable.type, - gridData: { - ...newPanelPlacement, - i: embeddableId, - }, - }; - })(); - container.updateInput({ - panels: { - ...container.getInput().panels, - [newPanelState.explicitInput.id]: newPanelState, - }, - }); - - return await container.untilEmbeddableLoaded(embeddableId); - })(); - if (createdEmbeddable) { - scrolltoIncomingEmbeddable(container, createdEmbeddable.uuid); - } - }); - } - } - - // -------------------------------------------------------------------------------------- - // Set restored runtime state for react embeddables. - // -------------------------------------------------------------------------------------- - untilDashboardReady().then((dashboardContainer) => { - if (overrideInput?.controlGroupState) { - dashboardContainer.setRuntimeStateForChild( - PANELS_CONTROL_GROUP_KEY, - overrideInput.controlGroupState - ); - } - - for (const idWithRuntimeState of Object.keys(runtimePanelsToRestore)) { - const restoredRuntimeStateForChild = runtimePanelsToRestore[idWithRuntimeState]; - if (!restoredRuntimeStateForChild) continue; - dashboardContainer.setRuntimeStateForChild(idWithRuntimeState, restoredRuntimeStateForChild); - } - }); - - // -------------------------------------------------------------------------------------- - // Start the data views integration. - // -------------------------------------------------------------------------------------- - untilDashboardReady().then((dashboardContainer) => { - dashboardContainer.integrationSubscriptions.add( - startSyncingDashboardDataViews.bind(dashboardContainer)() - ); - }); - - // -------------------------------------------------------------------------------------- - // Start performance tracker - // -------------------------------------------------------------------------------------- - untilDashboardReady().then((dashboardContainer) => - dashboardContainer.integrationSubscriptions.add( - startQueryPerformanceTracking(dashboardContainer) - ) - ); - - // -------------------------------------------------------------------------------------- - // Start animating panel transforms 500 ms after dashboard is created. - // -------------------------------------------------------------------------------------- - untilDashboardReady().then((dashboard) => - setTimeout(() => dashboard.setAnimatePanelTransforms(true), 500) - ); - - // -------------------------------------------------------------------------------------- - // Set up search sessions integration. - // -------------------------------------------------------------------------------------- - let initialSearchSessionId; - if (searchSessionSettings) { - const { sessionIdToRestore } = searchSessionSettings; - - // if this incoming embeddable has a session, continue it. - if (incomingEmbeddable?.searchSessionId) { - dataService.search.session.continue(incomingEmbeddable.searchSessionId); - } - if (sessionIdToRestore) { - dataService.search.session.restore(sessionIdToRestore); - } - const existingSession = dataService.search.session.getSessionId(); - - initialSearchSessionId = - sessionIdToRestore ?? - (existingSession && incomingEmbeddable - ? existingSession - : dataService.search.session.start()); - - untilDashboardReady().then(async (container) => { - await container.untilContainerInitialized(); - startDashboardSearchSessionIntegration.bind(container)( - creationOptions?.searchSessionSettings - ); - }); - } - - if (loadDashboardReturn.dashboardId && !incomingEmbeddable) { - // We count a new view every time a user opens a dashboard, both in view or edit mode - // We don't count views when a user is editing a dashboard and is returning from an editor after saving - // however, there is an edge case that we now count a new view when a user is editing a dashboard and is returning from an editor by canceling - // TODO: this should be revisited by making embeddable transfer support canceling logic https://github.com/elastic/kibana/issues/190485 - const contentInsightsClient = new ContentInsightsClient( - { http: coreServices.http }, - { domainId: 'dashboard' } - ); - contentInsightsClient.track(loadDashboardReturn.dashboardId, 'viewed'); - } - - return { input: initialDashboardInput, searchSessionId: initialSearchSessionId }; -}; diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/data_views/sync_dashboard_data_views.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/data_views/sync_dashboard_data_views.ts deleted file mode 100644 index 3060987e296c6..0000000000000 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/data_views/sync_dashboard_data_views.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { uniqBy } from 'lodash'; -import { combineLatest, Observable, of, switchMap } from 'rxjs'; - -import { DataView } from '@kbn/data-views-plugin/common'; -import { combineCompatibleChildrenApis } from '@kbn/presentation-containers'; -import { apiPublishesDataViews, PublishesDataViews } from '@kbn/presentation-publishing'; - -import { dataService } from '../../../../services/kibana_services'; -import { DashboardContainer } from '../../dashboard_container'; - -export function startSyncingDashboardDataViews(this: DashboardContainer) { - const controlGroupDataViewsPipe: Observable = this.controlGroupApi$.pipe( - switchMap((controlGroupApi) => { - return controlGroupApi ? controlGroupApi.dataViews : of([]); - }) - ); - - const childDataViewsPipe = combineCompatibleChildrenApis( - this, - 'dataViews', - apiPublishesDataViews, - [] - ); - - return combineLatest([controlGroupDataViewsPipe, childDataViewsPipe]) - .pipe( - switchMap(([controlGroupDataViews, childDataViews]) => { - const allDataViews = [ - ...(controlGroupDataViews ? controlGroupDataViews : []), - ...childDataViews, - ]; - if (allDataViews.length === 0) { - return (async () => { - const defaultDataViewId = await dataService.dataViews.getDefaultId(); - return [await dataService.dataViews.get(defaultDataViewId!)]; - })(); - } - return of(uniqBy(allDataViews, 'id')); - }) - ) - .subscribe((newDataViews) => { - this.setAllDataViews(newDataViews); - }); -} diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/performance/query_performance_tracking.test.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/performance/query_performance_tracking.test.ts index 963914bea1c33..09cb313d1f5a5 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/performance/query_performance_tracking.test.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/performance/query_performance_tracking.test.ts @@ -9,12 +9,12 @@ import type { CoreStart } from '@kbn/core/public'; import { PerformanceMetricEvent } from '@kbn/ebt-tools'; -import { PresentationContainer, TracksQueryPerformance } from '@kbn/presentation-containers'; +import { PresentationContainer } from '@kbn/presentation-containers'; import { getMockPresentationContainer } from '@kbn/presentation-containers/mocks'; import { PhaseEvent, PhaseEventType, apiPublishesPhaseEvents } from '@kbn/presentation-publishing'; import { waitFor } from '@testing-library/react'; import { BehaviorSubject } from 'rxjs'; -import { startQueryPerformanceTracking } from './query_performance_tracking'; +import { PerformanceState, startQueryPerformanceTracking } from './query_performance_tracking'; const mockMetricEvent = jest.fn(); jest.mock('@kbn/ebt-tools', () => ({ @@ -26,7 +26,8 @@ jest.mock('@kbn/ebt-tools', () => ({ const mockDashboard = ( children: {} = {} ): { - dashboard: PresentationContainer & TracksQueryPerformance; + dashboard: PresentationContainer; + performanceState: PerformanceState; children$: BehaviorSubject<{ [key: string]: unknown }>; } => { const children$ = new BehaviorSubject<{ [key: string]: unknown }>(children); @@ -35,6 +36,8 @@ const mockDashboard = ( ...getMockPresentationContainer(), children$, getPanelCount: () => Object.keys(children$.value).length, + }, + performanceState: { firstLoad: true, creationStartTime: Date.now(), }, @@ -68,16 +71,16 @@ describe('startQueryPerformanceTracking', () => { phase$: new BehaviorSubject({ status: 'loading', id: '', timeToEvent: 0 }), }, }; - const { dashboard } = mockDashboard(children); - startQueryPerformanceTracking(dashboard); + const { dashboard, performanceState } = mockDashboard(children); + startQueryPerformanceTracking(dashboard, performanceState); - expect(dashboard.lastLoadStartTime).toBeDefined(); + expect(performanceState.lastLoadStartTime).toBeDefined(); }); it('sets creation end time when no children are present', async () => { - const { dashboard } = mockDashboard(); - startQueryPerformanceTracking(dashboard); - expect(dashboard.creationEndTime).toBeDefined(); + const { dashboard, performanceState } = mockDashboard(); + startQueryPerformanceTracking(dashboard, performanceState); + expect(performanceState.creationEndTime).toBeDefined(); }); it('sets creation end time when all panels with phase event reporting have rendered', async () => { @@ -89,11 +92,11 @@ describe('startQueryPerformanceTracking', () => { phase$: new BehaviorSubject({ status: 'loading', id: '', timeToEvent: 0 }), }, }; - const { dashboard } = mockDashboard(children); - startQueryPerformanceTracking(dashboard); + const { dashboard, performanceState } = mockDashboard(children); + startQueryPerformanceTracking(dashboard, performanceState); setChildrenStatus(children, 'rendered'); await waitFor(() => { - expect(dashboard.creationEndTime).toBeDefined(); + expect(performanceState.creationEndTime).toBeDefined(); }); }); @@ -106,8 +109,8 @@ describe('startQueryPerformanceTracking', () => { phase$: new BehaviorSubject({ status: 'loading', id: '', timeToEvent: 0 }), }, }; - const { dashboard } = mockDashboard(children); - startQueryPerformanceTracking(dashboard); + const { dashboard, performanceState } = mockDashboard(children); + startQueryPerformanceTracking(dashboard, performanceState); expect(mockMetricEvent).not.toHaveBeenCalled(); setChildrenStatus(children, 'rendered'); @@ -136,8 +139,8 @@ describe('startQueryPerformanceTracking', () => { panel3: { wow: 'wow' }, panel4: { wow: 'wow' }, }; - const { dashboard } = mockDashboard(children); - startQueryPerformanceTracking(dashboard); + const { dashboard, performanceState } = mockDashboard(children); + startQueryPerformanceTracking(dashboard, performanceState); setChildrenStatus(children, 'rendered'); expect(mockMetricEvent).toHaveBeenCalledWith( @@ -164,8 +167,8 @@ describe('startQueryPerformanceTracking', () => { panel3: { wow: 'wow' }, panel4: { wow: 'wow' }, }; - const { dashboard } = mockDashboard(children); - startQueryPerformanceTracking(dashboard); + const { dashboard, performanceState } = mockDashboard(children); + startQueryPerformanceTracking(dashboard, performanceState); setChildrenStatus(children, 'rendered'); await waitFor(() => { @@ -193,8 +196,8 @@ describe('startQueryPerformanceTracking', () => { phase$: new BehaviorSubject({ status: 'loading', id: '', timeToEvent: 0 }), }, }; - const { dashboard, children$ } = mockDashboard(children); - startQueryPerformanceTracking(dashboard); + const { dashboard, performanceState, children$ } = mockDashboard(children); + startQueryPerformanceTracking(dashboard, performanceState); setChildrenStatus(children, 'rendered'); expect(mockMetricEvent).toHaveBeenCalledTimes(1); @@ -218,9 +221,9 @@ describe('startQueryPerformanceTracking', () => { it('ensures the duration is at least as long as the time to data', async () => { // start an empty Dashboard. This will set the creation end time to some short value - const { dashboard, children$ } = mockDashboard(); - startQueryPerformanceTracking(dashboard); - expect(dashboard.creationEndTime).toBeDefined(); + const { dashboard, children$, performanceState } = mockDashboard(); + startQueryPerformanceTracking(dashboard, performanceState); + expect(performanceState.creationEndTime).toBeDefined(); // add a panel that takes a long time to load const children = { diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/performance/query_performance_tracking.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/performance/query_performance_tracking.ts index 4b138f6ffa21d..edccfd7fb2804 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/performance/query_performance_tracking.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/performance/query_performance_tracking.ts @@ -10,13 +10,20 @@ import { combineLatest, map, pairwise, startWith, switchMap, skipWhile, of } from 'rxjs'; import { reportPerformanceMetricEvent } from '@kbn/ebt-tools'; -import { PresentationContainer, TracksQueryPerformance } from '@kbn/presentation-containers'; +import { PresentationContainer } from '@kbn/presentation-containers'; import { PublishesPhaseEvents, apiPublishesPhaseEvents } from '@kbn/presentation-publishing'; import { DASHBOARD_LOADED_EVENT } from '../../../../dashboard_constants'; import { coreServices } from '../../../../services/kibana_services'; import { DashboardLoadType } from '../../../types'; +export interface PerformanceState { + firstLoad: boolean; + creationStartTime?: number; + creationEndTime?: number; + lastLoadStartTime?: number; +} + let isFirstDashboardLoadOfSession = true; const loadTypesMapping: { [key in DashboardLoadType]: number } = { @@ -26,7 +33,8 @@ const loadTypesMapping: { [key in DashboardLoadType]: number } = { }; export function startQueryPerformanceTracking( - dashboard: PresentationContainer & TracksQueryPerformance + dashboard: PresentationContainer, + performanceState: PerformanceState ) { return dashboard.children$ .pipe( @@ -66,31 +74,31 @@ export function startQueryPerformanceTracking( const now = performance.now(); const loadType: DashboardLoadType = isFirstDashboardLoadOfSession ? 'sessionFirstLoad' - : dashboard.firstLoad + : performanceState.firstLoad ? 'dashboardFirstLoad' : 'dashboardSubsequentLoad'; const queryHasStarted = !wasDashboardStillLoading && isDashboardStillLoading; const queryHasFinished = wasDashboardStillLoading && !isDashboardStillLoading; - if (dashboard.firstLoad && (panelCount === 0 || queryHasFinished)) { + if (performanceState.firstLoad && (panelCount === 0 || queryHasFinished)) { /** * we consider the Dashboard creation to be finished when all the panels are loaded. */ - dashboard.creationEndTime = now; + performanceState.creationEndTime = now; isFirstDashboardLoadOfSession = false; - dashboard.firstLoad = false; + performanceState.firstLoad = false; } if (queryHasStarted) { - dashboard.lastLoadStartTime = now; + performanceState.lastLoadStartTime = now; return; } if (queryHasFinished) { - const timeToData = now - (dashboard.lastLoadStartTime ?? now); + const timeToData = now - (performanceState.lastLoadStartTime ?? now); const completeLoadDuration = - (dashboard.creationEndTime ?? now) - (dashboard.creationStartTime ?? now); + (performanceState.creationEndTime ?? now) - (performanceState.creationStartTime ?? now); reportPerformanceMetrics({ timeToData, panelCount, diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/search_sessions/new_session.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/search_sessions/new_session.ts index 949f425a9ba91..8c2b88d81b631 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/search_sessions/new_session.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/search_sessions/new_session.ts @@ -11,9 +11,8 @@ import { Filter, TimeRange, onlyDisabledFiltersChanged } from '@kbn/es-query'; import { combineLatest, distinctUntilChanged, Observable, skip } from 'rxjs'; import { shouldRefreshFilterCompareOptions } from '@kbn/embeddable-plugin/public'; import { apiPublishesSettings } from '@kbn/presentation-containers/interfaces/publishes_settings'; -import { apiPublishesUnifiedSearch } from '@kbn/presentation-publishing'; -import { areTimesEqual } from '../../../state/diffing/dashboard_diffing_utils'; -import { DashboardContainer } from '../../dashboard_container'; +import { apiPublishesReload, apiPublishesUnifiedSearch } from '@kbn/presentation-publishing'; +import { areTimesEqual } from '../../../../dashboard_api/unified_search_manager'; export function newSession$(api: unknown) { const observables: Array> = []; @@ -21,7 +20,6 @@ export function newSession$(api: unknown) { if (apiPublishesUnifiedSearch(api)) { observables.push( api.filters$.pipe( - // TODO move onlyDisabledFiltersChanged to appliedFilters$ interface distinctUntilChanged((previous: Filter[] | undefined, current: Filter[] | undefined) => { return onlyDisabledFiltersChanged(previous, current, shouldRefreshFilterCompareOptions); }) @@ -57,9 +55,8 @@ export function newSession$(api: unknown) { } } - // TODO replace lastReloadRequestTime$ with reload$ when removing legacy embeddable framework - if ((api as DashboardContainer).lastReloadRequestTime$) { - observables.push((api as DashboardContainer).lastReloadRequestTime$); + if (apiPublishesReload(api)) { + observables.push(api.reload$); } return combineLatest(observables).pipe(skip(1)); diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/search_sessions/start_dashboard_search_session_integration.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/search_sessions/start_dashboard_search_session_integration.ts index 70f841db869a5..7d229e31ddf0e 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/search_sessions/start_dashboard_search_session_integration.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/search_sessions/start_dashboard_search_session_integration.ts @@ -12,7 +12,6 @@ import { skip } from 'rxjs'; import { noSearchSessionStorageCapabilityMessage } from '@kbn/data-plugin/public'; import { dataService } from '../../../../services/kibana_services'; -import { DashboardContainer } from '../../dashboard_container'; import type { DashboardApi, DashboardCreationOptions } from '../../../..'; import { newSession$ } from './new_session'; import { getDashboardCapabilities } from '../../../../utils/get_dashboard_capabilities'; @@ -21,8 +20,9 @@ import { getDashboardCapabilities } from '../../../../utils/get_dashboard_capabi * Enables dashboard search sessions. */ export function startDashboardSearchSessionIntegration( - this: DashboardContainer, - searchSessionSettings: DashboardCreationOptions['searchSessionSettings'] + dashboardApi: DashboardApi, + searchSessionSettings: DashboardCreationOptions['searchSessionSettings'], + setSearchSessionId: (searchSessionId: string) => void ) { if (!searchSessionSettings) return; @@ -33,26 +33,23 @@ export function startDashboardSearchSessionIntegration( createSessionRestorationDataProvider, } = searchSessionSettings; - dataService.search.session.enableStorage( - createSessionRestorationDataProvider(this as DashboardApi), - { - isDisabled: () => - getDashboardCapabilities().storeSearchSession - ? { disabled: false } - : { - disabled: true, - reasonText: noSearchSessionStorageCapabilityMessage, - }, - } - ); + dataService.search.session.enableStorage(createSessionRestorationDataProvider(dashboardApi), { + isDisabled: () => + getDashboardCapabilities().storeSearchSession + ? { disabled: false } + : { + disabled: true, + reasonText: noSearchSessionStorageCapabilityMessage, + }, + }); // force refresh when the session id in the URL changes. This will also fire off the "handle search session change" below. const searchSessionIdChangeSubscription = sessionIdUrlChangeObservable ?.pipe(skip(1)) - .subscribe(() => this.forceRefresh()); + .subscribe(() => dashboardApi.forceRefresh()); - newSession$(this).subscribe(() => { - const currentSearchSessionId = this.getState().explicitInput.searchSessionId; + const newSessionSubscription = newSession$(dashboardApi).subscribe(() => { + const currentSearchSessionId = dashboardApi.searchSessionId$.value; const updatedSearchSessionId: string | undefined = (() => { let searchSessionIdFromURL = getSearchSessionIdFromURL(); @@ -72,10 +69,12 @@ export function startDashboardSearchSessionIntegration( })(); if (updatedSearchSessionId && updatedSearchSessionId !== currentSearchSessionId) { - this.searchSessionId = updatedSearchSessionId; - this.searchSessionId$.next(updatedSearchSessionId); + setSearchSessionId(updatedSearchSessionId); } }); - this.integrationSubscriptions.add(searchSessionIdChangeSubscription); + return () => { + searchSessionIdChangeSubscription?.unsubscribe(); + newSessionSubscription.unsubscribe(); + }; } diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/unified_search/sync_dashboard_unified_search_state.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/unified_search/sync_dashboard_unified_search_state.ts deleted file mode 100644 index b6043f03b26c0..0000000000000 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/unified_search/sync_dashboard_unified_search_state.ts +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { Subject } from 'rxjs'; -import fastIsEqual from 'fast-deep-equal'; -import { distinctUntilChanged, finalize, switchMap, tap } from 'rxjs'; - -import type { Filter, Query } from '@kbn/es-query'; -import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; -import { cleanFiltersForSerialize } from '@kbn/presentation-util-plugin/public'; -import { - connectToQueryState, - GlobalQueryStateFromUrl, - waitUntilNextSessionCompletes$, -} from '@kbn/data-plugin/public'; - -import { DashboardContainer } from '../../dashboard_container'; -import { GLOBAL_STATE_STORAGE_KEY } from '../../../../dashboard_constants'; -import { areTimesEqual } from '../../../state/diffing/dashboard_diffing_utils'; -import { dataService } from '../../../../services/kibana_services'; - -/** - * Sets up syncing and subscriptions between the filter state from the Data plugin - * and the dashboard Redux store. - */ -export function syncUnifiedSearchState( - this: DashboardContainer, - kbnUrlStateStorage: IKbnUrlStateStorage -) { - const timefilterService = dataService.query.timefilter.timefilter; - - // get Observable for when the dashboard's saved filters or query change. - const OnFiltersChange$ = new Subject<{ filters: Filter[]; query: Query }>(); - const unsubscribeFromSavedFilterChanges = this.onStateChange(() => { - const { - explicitInput: { filters, query }, - } = this.getState(); - OnFiltersChange$.next({ - filters: filters ?? [], - query: query ?? dataService.query.queryString.getDefaultQuery(), - }); - }); - - // starts syncing app filters between dashboard state and filterManager - const { - explicitInput: { filters, query }, - } = this.getState(); - const intermediateFilterState: { filters: Filter[]; query: Query } = { - query: query ?? dataService.query.queryString.getDefaultQuery(), - filters: filters ?? [], - }; - - const stopSyncingAppFilters = connectToQueryState( - dataService.query, - { - get: () => intermediateFilterState, - set: ({ filters: newFilters, query: newQuery }) => { - intermediateFilterState.filters = cleanFiltersForSerialize(newFilters); - intermediateFilterState.query = newQuery; - this.dispatch.setFiltersAndQuery(intermediateFilterState); - }, - state$: OnFiltersChange$.pipe(distinctUntilChanged()), - }, - { - query: true, - filters: true, - } - ); - - const timeUpdateSubscription = timefilterService.getTimeUpdate$().subscribe(() => { - const newTimeRange = (() => { - // if there is an override time range in the URL, use it. - const urlOverrideTimeRange = - kbnUrlStateStorage.get(GLOBAL_STATE_STORAGE_KEY)?.time; - if (urlOverrideTimeRange) return urlOverrideTimeRange; - - // if there is no url override time range, check if this dashboard uses time restore, and restore to that. - const timeRestoreTimeRange = - this.getState().explicitInput.timeRestore && this.lastSavedInput$.value.timeRange; - if (timeRestoreTimeRange) { - timefilterService.setTime(timeRestoreTimeRange); - return timeRestoreTimeRange; - } - - // otherwise fall back to the time range from the time filter service - return timefilterService.getTime(); - })(); - - const lastTimeRange = this.getState().explicitInput.timeRange; - if ( - !areTimesEqual(newTimeRange.from, lastTimeRange?.from) || - !areTimesEqual(newTimeRange.to, lastTimeRange?.to) - ) { - this.dispatch.setTimeRange(newTimeRange); - } - }); - - const refreshIntervalSubscription = timefilterService - .getRefreshIntervalUpdate$() - .subscribe(() => { - const newRefreshInterval = (() => { - // if there is an override refresh interval in the URL, dispatch that to the dashboard. - const urlOverrideRefreshInterval = - kbnUrlStateStorage.get( - GLOBAL_STATE_STORAGE_KEY - )?.refreshInterval; - if (urlOverrideRefreshInterval) return urlOverrideRefreshInterval; - - // if there is no url override refresh interval, check if this dashboard uses time restore, and restore to that. - const timeRestoreRefreshInterval = - this.getState().explicitInput.timeRestore && this.lastSavedInput$.value.refreshInterval; - if (timeRestoreRefreshInterval) { - timefilterService.setRefreshInterval(timeRestoreRefreshInterval); - return timeRestoreRefreshInterval; - } - - // otherwise fall back to the refresh interval from the time filter service - return timefilterService.getRefreshInterval(); - })(); - - const lastRefreshInterval = this.getState().explicitInput.refreshInterval; - if (!fastIsEqual(newRefreshInterval, lastRefreshInterval)) { - this.dispatch.setRefreshInterval(newRefreshInterval); - } - }); - - const autoRefreshSubscription = timefilterService - .getAutoRefreshFetch$() - .pipe( - tap(() => { - this.forceRefresh(); - }), - switchMap((done) => - // best way on a dashboard to estimate that panels are updated is to rely on search session service state - waitUntilNextSessionCompletes$(dataService.search.session).pipe(finalize(done)) - ) - ) - .subscribe(); - - const stopSyncingUnifiedSearchState = () => { - autoRefreshSubscription.unsubscribe(); - timeUpdateSubscription.unsubscribe(); - refreshIntervalSubscription.unsubscribe(); - unsubscribeFromSavedFilterChanges(); - stopSyncingAppFilters(); - }; - - return stopSyncingUnifiedSearchState; -} diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.test.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.test.tsx deleted file mode 100644 index 167ee26055166..0000000000000 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.test.tsx +++ /dev/null @@ -1,274 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { isErrorEmbeddable, ViewMode } from '@kbn/embeddable-plugin/public'; -import { - CONTACT_CARD_EMBEDDABLE, - ContactCardEmbeddable, - ContactCardEmbeddableFactory, - ContactCardEmbeddableInput, - ContactCardEmbeddableOutput, - EMPTY_EMBEDDABLE, -} from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables'; -import type { TimeRange } from '@kbn/es-query'; -import { mockedReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public/mocks'; - -import { - buildMockDashboard, - getSampleDashboardInput, - getSampleDashboardPanel, - mockControlGroupApi, -} from '../../mocks'; -import { embeddableService } from '../../services/kibana_services'; -import { DashboardContainer } from './dashboard_container'; - -const embeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any); -embeddableService.getEmbeddableFactory = jest.fn().mockReturnValue(embeddableFactory); - -test('DashboardContainer initializes embeddables', (done) => { - const container = buildMockDashboard({ - overrides: { - panels: { - '123': getSampleDashboardPanel({ - explicitInput: { firstName: 'Sam', id: '123' }, - type: CONTACT_CARD_EMBEDDABLE, - }), - }, - }, - }); - - const subscription = container.getOutput$().subscribe((output) => { - if (container.getOutput().embeddableLoaded['123']) { - const embeddable = container.getChild('123'); - expect(embeddable).toBeDefined(); - expect(embeddable.id).toBe('123'); - done(); - } - }); - - if (container.getOutput().embeddableLoaded['123']) { - const embeddable = container.getChild('123'); - expect(embeddable).toBeDefined(); - expect(embeddable.id).toBe('123'); - subscription.unsubscribe(); - done(); - } -}); - -test('DashboardContainer.addNewEmbeddable', async () => { - const container = buildMockDashboard(); - const embeddable = await container.addNewEmbeddable( - CONTACT_CARD_EMBEDDABLE, - { - firstName: 'Kibana', - } - ); - expect(embeddable).toBeDefined(); - - if (!isErrorEmbeddable(embeddable)) { - expect(embeddable.getInput().firstName).toBe('Kibana'); - } else { - expect(false).toBe(true); - } - - const embeddableInContainer = container.getChild(embeddable.id); - expect(embeddableInContainer).toBeDefined(); - expect(embeddableInContainer.id).toBe(embeddable.id); -}); - -test('DashboardContainer.replacePanel', (done) => { - const ID = '123'; - - const container = buildMockDashboard({ - overrides: { - panels: { - [ID]: getSampleDashboardPanel({ - explicitInput: { firstName: 'Sam', id: ID }, - type: CONTACT_CARD_EMBEDDABLE, - }), - }, - }, - }); - let counter = 0; - - const subscription = container.getInput$().subscribe( - jest.fn(({ panels }) => { - counter++; - expect(panels[ID]).toBeDefined(); - // It should be called exactly 2 times and exit the second time - switch (counter) { - case 1: - return expect(panels[ID].type).toBe(CONTACT_CARD_EMBEDDABLE); - - case 2: { - expect(panels[ID].type).toBe(EMPTY_EMBEDDABLE); - subscription.unsubscribe(); - done(); - return; - } - - default: - throw Error('Called too many times!'); - } - }) - ); - - // replace the panel now - container.replaceEmbeddable( - container.getInput().panels[ID].explicitInput.id, - { id: ID }, - EMPTY_EMBEDDABLE - ); -}); - -test('Container view mode change propagates to existing children', async () => { - const container = buildMockDashboard({ - overrides: { - panels: { - '123': getSampleDashboardPanel({ - explicitInput: { firstName: 'Sam', id: '123' }, - type: CONTACT_CARD_EMBEDDABLE, - }), - }, - }, - }); - - const embeddable = await container.untilEmbeddableLoaded('123'); - expect(embeddable.getInput().viewMode).toBe(ViewMode.VIEW); - container.updateInput({ viewMode: ViewMode.EDIT }); - expect(embeddable.getInput().viewMode).toBe(ViewMode.EDIT); -}); - -test('Container view mode change propagates to new children', async () => { - const container = buildMockDashboard(); - const embeddable = await container.addNewEmbeddable< - ContactCardEmbeddableInput, - ContactCardEmbeddableOutput, - ContactCardEmbeddable - >(CONTACT_CARD_EMBEDDABLE, { - firstName: 'Bob', - }); - - expect(embeddable.getInput().viewMode).toBe(ViewMode.VIEW); - - container.updateInput({ viewMode: ViewMode.EDIT }); - - expect(embeddable.getInput().viewMode).toBe(ViewMode.EDIT); -}); - -test('searchSessionId propagates to children', async () => { - const searchSessionId1 = 'searchSessionId1'; - const sampleInput = getSampleDashboardInput(); - const container = new DashboardContainer( - sampleInput, - mockedReduxEmbeddablePackage, - searchSessionId1, - 0, - undefined, - undefined, - { - anyMigrationRun: false, - isEmbeddedExternally: false, - lastSavedInput: sampleInput, - lastSavedId: undefined, - managed: false, - fullScreenMode: false, - } - ); - container?.setControlGroupApi(mockControlGroupApi); - const embeddable = await container.addNewEmbeddable< - ContactCardEmbeddableInput, - ContactCardEmbeddableOutput, - ContactCardEmbeddable - >(CONTACT_CARD_EMBEDDABLE, { - firstName: 'Bob', - }); - - expect(embeddable.getInput().searchSessionId).toBe(searchSessionId1); -}); - -describe('getInheritedInput', () => { - const dashboardTimeRange = { - to: 'now', - from: 'now-15m', - }; - const dashboardTimeslice = [1688061910000, 1688062209000] as [number, number]; - - test('Should pass dashboard timeRange and timeslice to panel when panel does not have custom time range', async () => { - const container = buildMockDashboard(); - container.updateInput({ - timeRange: dashboardTimeRange, - timeslice: dashboardTimeslice, - }); - const embeddable = await container.addNewEmbeddable( - CONTACT_CARD_EMBEDDABLE, - { - firstName: 'Kibana', - } - ); - expect(embeddable).toBeDefined(); - - const embeddableInput = container - .getChild(embeddable.id) - .getInput() as ContactCardEmbeddableInput & { - timeRange: TimeRange; - timeslice: [number, number]; - }; - expect(embeddableInput.timeRange).toEqual(dashboardTimeRange); - expect(embeddableInput.timeslice).toEqual(dashboardTimeslice); - }); - - test('Should not pass dashboard timeRange and timeslice to panel when panel has custom time range', async () => { - const container = buildMockDashboard(); - container.updateInput({ - timeRange: dashboardTimeRange, - timeslice: dashboardTimeslice, - }); - const embeddableTimeRange = { - to: 'now', - from: 'now-24h', - }; - const embeddable = await container.addNewEmbeddable< - ContactCardEmbeddableInput & { timeRange: TimeRange } - >(CONTACT_CARD_EMBEDDABLE, { - firstName: 'Kibana', - timeRange: embeddableTimeRange, - }); - - const embeddableInput = container - .getChild(embeddable.id) - .getInput() as ContactCardEmbeddableInput & { - timeRange: TimeRange; - timeslice: [number, number]; - }; - expect(embeddableInput.timeRange).toEqual(embeddableTimeRange); - expect(embeddableInput.timeslice).toBeUndefined(); - }); - - test('Should pass dashboard settings to inherited input', async () => { - const container = buildMockDashboard({}); - const embeddable = await container.addNewEmbeddable( - CONTACT_CARD_EMBEDDABLE, - { - firstName: 'Kibana', - } - ); - expect(embeddable).toBeDefined(); - - const embeddableInput = container - .getChild(embeddable.id) - .getInput() as ContactCardEmbeddableInput & { - timeRange: TimeRange; - timeslice: [number, number]; - }; - expect(embeddableInput.syncTooltips).toBe(false); - expect(embeddableInput.syncColors).toBe(false); - expect(embeddableInput.syncCursor).toBe(true); - }); -}); diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx deleted file mode 100644 index 7063414ccca7c..0000000000000 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx +++ /dev/null @@ -1,979 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import deepEqual from 'fast-deep-equal'; -import { omit } from 'lodash'; -import React from 'react'; -import ReactDOM from 'react-dom'; -import { - BehaviorSubject, - Subject, - Subscription, - distinctUntilChanged, - first, - map, - skipWhile, - switchMap, -} from 'rxjs'; -import { v4 } from 'uuid'; - -import { METRIC_TYPE } from '@kbn/analytics'; -import type { Reference } from '@kbn/content-management-utils'; -import { ControlGroupApi } from '@kbn/controls-plugin/public'; -import type { KibanaExecutionContext, OverlayRef } from '@kbn/core/public'; -import { RefreshInterval } from '@kbn/data-plugin/public'; -import type { DataView } from '@kbn/data-views-plugin/public'; -import { - Container, - DefaultEmbeddableApi, - EmbeddableFactoryNotFoundError, - PanelNotFoundError, - ViewMode, - embeddableInputToSubject, - isExplicitInputWithAttributes, - type EmbeddableFactory, - type EmbeddableInput, - type EmbeddableOutput, - type IEmbeddable, -} from '@kbn/embeddable-plugin/public'; -import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; -import { - HasRuntimeChildState, - HasSaveNotification, - HasSerializedChildState, - PanelPackage, - TrackContentfulRender, - TracksQueryPerformance, - combineCompatibleChildrenApis, -} from '@kbn/presentation-containers'; -import { PublishesSettings } from '@kbn/presentation-containers/interfaces/publishes_settings'; -import { apiHasSerializableState } from '@kbn/presentation-containers/interfaces/serialized_state'; -import { - PublishesDataLoading, - PublishesViewMode, - apiPublishesDataLoading, - apiPublishesPanelTitle, - apiPublishesUnsavedChanges, - getPanelTitle, - type PublishingSubject, -} from '@kbn/presentation-publishing'; -import { ReduxEmbeddableTools, ReduxToolsPackage } from '@kbn/presentation-util-plugin/public'; -import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; -import { LocatorPublic } from '@kbn/share-plugin/common'; -import { ExitFullScreenButtonKibanaProvider } from '@kbn/shared-ux-button-exit-full-screen'; - -import { i18n } from '@kbn/i18n'; -import { DASHBOARD_CONTAINER_TYPE, DashboardApi, DashboardLocatorParams } from '../..'; -import type { DashboardAttributes } from '../../../server/content_management'; -import { DashboardContainerInput, DashboardPanelMap, DashboardPanelState } from '../../../common'; -import { - getReferencesForControls, - getReferencesForPanelId, -} from '../../../common/dashboard_container/persistable_state/dashboard_container_references'; -import { DashboardContext } from '../../dashboard_api/use_dashboard_api'; -import { getPanelAddedSuccessString } from '../../dashboard_app/_dashboard_app_strings'; -import { - DASHBOARD_APP_ID, - DASHBOARD_UI_METRIC_ID, - DEFAULT_PANEL_HEIGHT, - DEFAULT_PANEL_WIDTH, - PanelPlacementStrategy, -} from '../../dashboard_constants'; -import { PANELS_CONTROL_GROUP_KEY } from '../../services/dashboard_backup_service'; -import { getDashboardContentManagementService } from '../../services/dashboard_content_management_service'; -import { - coreServices, - dataService, - embeddableService, - usageCollectionService, -} from '../../services/kibana_services'; -import { getDashboardCapabilities } from '../../utils/get_dashboard_capabilities'; -import { DashboardViewport } from '../component/viewport/dashboard_viewport'; -import { placePanel } from '../panel_placement'; -import { getDashboardPanelPlacementSetting } from '../panel_placement/panel_placement_registry'; -import { runPanelPlacementStrategy } from '../panel_placement/place_new_panel_strategies'; -import { dashboardContainerReducers } from '../state/dashboard_container_reducers'; -import { getDiffingMiddleware } from '../state/diffing/dashboard_diffing_integration'; -import { DashboardReduxState, DashboardStateFromSettingsFlyout, UnsavedPanelState } from '../types'; -import { addFromLibrary, addOrUpdateEmbeddable, runInteractiveSave, runQuickSave } from './api'; -import { duplicateDashboardPanel } from './api/duplicate_dashboard_panel'; -import { - combineDashboardFiltersWithControlGroupFilters, - startSyncingDashboardControlGroup, -} from './create/controls/dashboard_control_group_integration'; -import { initializeDashboard } from './create/create_dashboard'; -import { - dashboardTypeDisplayLowercase, - dashboardTypeDisplayName, -} from './dashboard_container_factory'; -import { InitialComponentState, getDashboardApi } from '../../dashboard_api/get_dashboard_api'; -import type { DashboardCreationOptions } from '../..'; - -export interface InheritedChildInput { - filters: Filter[]; - query: Query; - timeRange?: TimeRange; - timeslice?: [number, number]; - refreshConfig?: RefreshInterval; - viewMode: ViewMode; - hidePanelTitles?: boolean; - id: string; - searchSessionId?: string; - syncColors?: boolean; - syncCursor?: boolean; - syncTooltips?: boolean; - executionContext?: KibanaExecutionContext; -} - -type DashboardReduxEmbeddableTools = ReduxEmbeddableTools< - DashboardReduxState, - typeof dashboardContainerReducers ->; - -export class DashboardContainer - extends Container - implements - TrackContentfulRender, - TracksQueryPerformance, - HasSaveNotification, - HasRuntimeChildState, - HasSerializedChildState, - PublishesSettings, - Partial -{ - public readonly type = DASHBOARD_CONTAINER_TYPE; - - // state management - public select: DashboardReduxEmbeddableTools['select']; - public getState: DashboardReduxEmbeddableTools['getState']; - public dispatch: DashboardReduxEmbeddableTools['dispatch']; - public onStateChange: DashboardReduxEmbeddableTools['onStateChange']; - public anyReducerRun: Subject = new Subject(); - public setAnimatePanelTransforms: (animate: boolean) => void; - public setManaged: (managed: boolean) => void; - public setHasUnsavedChanges: (hasUnsavedChanges: boolean) => void; - public openOverlay: (ref: OverlayRef, options?: { focusedPanelId?: string }) => void; - public clearOverlays: () => void; - public highlightPanel: (panelRef: HTMLDivElement) => void; - public setScrollToPanelId: (id: string | undefined) => void; - public setFullScreenMode: (fullScreenMode: boolean) => void; - public setExpandedPanelId: (newId?: string) => void; - public setHighlightPanelId: (highlightPanelId: string | undefined) => void; - public setLastSavedInput: (lastSavedInput: DashboardContainerInput) => void; - public lastSavedInput$: PublishingSubject; - public setSavedObjectId: (id: string | undefined) => void; - public expandPanel: (panelId: string) => void; - public scrollToPanel: (panelRef: HTMLDivElement) => Promise; - public scrollToTop: () => void; - - public integrationSubscriptions: Subscription = new Subscription(); - public publishingSubscription: Subscription = new Subscription(); - public diffingSubscription: Subscription = new Subscription(); - public controlGroupApi$: PublishingSubject; - public settings: Record>; - - public searchSessionId?: string; - public lastReloadRequestTime$ = new BehaviorSubject(undefined); - public searchSessionId$ = new BehaviorSubject(undefined); - public reload$ = new Subject(); - public timeRestore$: BehaviorSubject; - public timeslice$: BehaviorSubject<[number, number] | undefined>; - public unifiedSearchFilters$?: PublishingSubject; - public locator?: Pick, 'navigate' | 'getRedirectUrl'>; - - public readonly executionContext: KibanaExecutionContext; - - private domNode?: HTMLElement; - - // performance monitoring - public lastLoadStartTime?: number; - public creationStartTime?: number; - public creationEndTime?: number; - public firstLoad: boolean = true; - private hadContentfulRender = false; - - // setup - public untilContainerInitialized: () => Promise; - - // cleanup - public stopSyncingWithUnifiedSearch?: () => void; - private cleanupStateTools: () => void; - - // Services that are used in the Dashboard container code - private creationOptions?: DashboardCreationOptions; - private showWriteControls: boolean; - - public trackContentfulRender() { - if (!this.hadContentfulRender) { - coreServices.analytics.reportEvent('dashboard_loaded_with_data', {}); - } - this.hadContentfulRender = true; - } - - private trackPanelAddMetric: - | ((type: string, eventNames: string | string[], count?: number | undefined) => void) - | undefined; - // new embeddable framework - public savedObjectReferences: Reference[] = []; - public controlGroupInput: DashboardAttributes['controlGroupInput'] | undefined; - - constructor( - initialInput: DashboardContainerInput, - reduxToolsPackage: ReduxToolsPackage, - initialSessionId?: string, - dashboardCreationStartTime?: number, - parent?: Container, - creationOptions?: DashboardCreationOptions, - initialComponentState?: InitialComponentState - ) { - const controlGroupApi$ = new BehaviorSubject(undefined); - async function untilContainerInitialized(): Promise { - return new Promise((resolve) => { - controlGroupApi$ - .pipe( - skipWhile((controlGroupApi) => !controlGroupApi), - switchMap(async (controlGroupApi) => { - // Bug in main where panels are loaded before control filters are ready - // Want to migrate to react embeddable controls with same behavior - // TODO - do not load panels until control filters are ready - /* - await controlGroupApi?.untilInitialized(); - */ - }), - first() - ) - .subscribe(() => { - resolve(); - }); - }); - } - - super( - { - ...initialInput, - }, - { embeddableLoaded: {} }, - embeddableService.getEmbeddableFactory, - parent, - { untilContainerInitialized } - ); - - ({ showWriteControls: this.showWriteControls } = getDashboardCapabilities()); - - this.controlGroupApi$ = controlGroupApi$; - this.untilContainerInitialized = untilContainerInitialized; - - this.trackPanelAddMetric = usageCollectionService?.reportUiCounter.bind( - usageCollectionService, - DASHBOARD_UI_METRIC_ID - ); - - this.creationOptions = creationOptions; - this.searchSessionId = initialSessionId; - this.searchSessionId$.next(initialSessionId); - this.creationStartTime = dashboardCreationStartTime; - - // start diffing dashboard state - const diffingMiddleware = getDiffingMiddleware.bind(this)(); - - // build redux embeddable tools - const reduxTools = reduxToolsPackage.createReduxEmbeddableTools< - DashboardReduxState, - typeof dashboardContainerReducers - >({ - embeddable: this, - reducers: dashboardContainerReducers, - additionalMiddleware: [diffingMiddleware], - }); - this.onStateChange = reduxTools.onStateChange; - this.cleanupStateTools = reduxTools.cleanup; - this.getState = reduxTools.getState; - this.dispatch = reduxTools.dispatch; - this.select = reduxTools.select; - - this.uuid$ = embeddableInputToSubject( - this.publishingSubscription, - this, - 'id' - ) as BehaviorSubject; - - const dashboardApi = getDashboardApi( - initialComponentState - ? initialComponentState - : { - anyMigrationRun: false, - isEmbeddedExternally: false, - lastSavedInput: initialInput, - lastSavedId: undefined, - fullScreenMode: false, - managed: false, - }, - (id: string) => this.untilEmbeddableLoaded(id) - ); - this.animatePanelTransforms$ = dashboardApi.animatePanelTransforms$; - this.fullScreenMode$ = dashboardApi.fullScreenMode$; - this.hasUnsavedChanges$ = dashboardApi.hasUnsavedChanges$; - this.isEmbeddedExternally = dashboardApi.isEmbeddedExternally; - this.managed$ = dashboardApi.managed$; - this.setAnimatePanelTransforms = dashboardApi.setAnimatePanelTransforms; - this.setFullScreenMode = dashboardApi.setFullScreenMode; - this.setHasUnsavedChanges = dashboardApi.setHasUnsavedChanges; - this.setManaged = dashboardApi.setManaged; - this.expandedPanelId = dashboardApi.expandedPanelId; - this.focusedPanelId$ = dashboardApi.focusedPanelId$; - this.highlightPanelId$ = dashboardApi.highlightPanelId$; - this.highlightPanel = dashboardApi.highlightPanel; - this.setExpandedPanelId = dashboardApi.setExpandedPanelId; - this.setHighlightPanelId = dashboardApi.setHighlightPanelId; - this.scrollToPanelId$ = dashboardApi.scrollToPanelId$; - this.setScrollToPanelId = dashboardApi.setScrollToPanelId; - this.clearOverlays = dashboardApi.clearOverlays; - this.hasOverlays$ = dashboardApi.hasOverlays$; - this.openOverlay = dashboardApi.openOverlay; - this.hasRunMigrations$ = dashboardApi.hasRunMigrations$; - this.setLastSavedInput = dashboardApi.setLastSavedInput; - this.lastSavedInput$ = dashboardApi.lastSavedInput$; - this.savedObjectId = dashboardApi.savedObjectId; - this.setSavedObjectId = dashboardApi.setSavedObjectId; - this.expandPanel = dashboardApi.expandPanel; - this.scrollToPanel = dashboardApi.scrollToPanel; - this.scrollToTop = dashboardApi.scrollToTop; - - this.useMargins$ = new BehaviorSubject(this.getState().explicitInput.useMargins); - this.panels$ = new BehaviorSubject(this.getState().explicitInput.panels); - this.publishingSubscription.add( - this.onStateChange(() => { - const state = this.getState(); - if (this.useMargins$.value !== state.explicitInput.useMargins) { - this.useMargins$.next(state.explicitInput.useMargins); - } - if (this.panels$.value !== state.explicitInput.panels) { - this.panels$.next(state.explicitInput.panels); - } - }) - ); - - this.startAuditingReactEmbeddableChildren(); - - this.settings = { - syncColors$: embeddableInputToSubject( - this.publishingSubscription, - this, - 'syncColors' - ), - syncCursor$: embeddableInputToSubject( - this.publishingSubscription, - this, - 'syncCursor' - ), - syncTooltips$: embeddableInputToSubject( - this.publishingSubscription, - this, - 'syncTooltips' - ), - }; - this.timeRestore$ = embeddableInputToSubject( - this.publishingSubscription, - this, - 'timeRestore' - ); - this.timeslice$ = embeddableInputToSubject< - [number, number] | undefined, - DashboardContainerInput - >(this.publishingSubscription, this, 'timeslice'); - this.lastReloadRequestTime$ = embeddableInputToSubject< - string | undefined, - DashboardContainerInput - >(this.publishingSubscription, this, 'lastReloadRequestTime'); - - startSyncingDashboardControlGroup(this); - - this.executionContext = initialInput.executionContext; - - this.dataLoading = new BehaviorSubject(false); - this.publishingSubscription.add( - combineCompatibleChildrenApis( - this, - 'dataLoading', - apiPublishesDataLoading, - undefined, - // flatten method - (values) => { - return values.some((isLoading) => isLoading); - } - ).subscribe((isAtLeastOneChildLoading) => { - (this.dataLoading as BehaviorSubject).next(isAtLeastOneChildLoading); - }) - ); - - this.dataViews = new BehaviorSubject([]); - - const query$ = new BehaviorSubject(this.getInput().query); - this.query$ = query$; - this.publishingSubscription.add( - this.getInput$().subscribe((input) => { - if (!deepEqual(query$.getValue() ?? [], input.query)) { - query$.next(input.query); - } - }) - ); - } - - public setControlGroupApi(controlGroupApi: ControlGroupApi) { - (this.controlGroupApi$ as BehaviorSubject).next(controlGroupApi); - } - - public getAppContext() { - const embeddableAppContext = this.creationOptions?.getEmbeddableAppContext?.( - this.savedObjectId.value - ); - return { - ...embeddableAppContext, - currentAppId: embeddableAppContext?.currentAppId ?? DASHBOARD_APP_ID, - }; - } - - protected createNewPanelState< - TEmbeddableInput extends EmbeddableInput, - TEmbeddable extends IEmbeddable - >( - factory: EmbeddableFactory, - partial: Partial = {}, - attributes?: unknown - ): { - newPanel: DashboardPanelState; - otherPanels: DashboardContainerInput['panels']; - } { - const { newPanel } = super.createNewPanelState(factory, partial, attributes); - return placePanel(factory, newPanel, this.input.panels, attributes); - } - - public render(dom: HTMLElement) { - if (this.domNode) { - ReactDOM.unmountComponentAtNode(this.domNode); - } - this.domNode = dom; - this.domNode.className = 'dashboardContainer'; - - ReactDOM.render( - - - - - - - , - dom - ); - } - - public updateInput(changes: Partial): void { - // block the Dashboard from entering edit mode if this Dashboard is managed. - if ( - (this.managed$.value || !this.showWriteControls) && - changes.viewMode?.toLowerCase() === ViewMode.EDIT?.toLowerCase() - ) { - const { viewMode, ...rest } = changes; - super.updateInput(rest); - return; - } - super.updateInput(changes); - } - - protected getInheritedInput(id: string): InheritedChildInput { - const { - query, - filters, - viewMode, - timeRange, - timeslice, - syncColors, - syncTooltips, - syncCursor, - hidePanelTitles, - refreshInterval, - executionContext, - panels, - } = this.input; - - const combinedFilters = combineDashboardFiltersWithControlGroupFilters( - filters, - this.controlGroupApi$?.value - ); - const hasCustomTimeRange = Boolean( - (panels[id]?.explicitInput as Partial)?.timeRange - ); - return { - searchSessionId: this.searchSessionId, - refreshConfig: refreshInterval, - filters: combinedFilters, - hidePanelTitles, - executionContext, - syncTooltips, - syncColors, - syncCursor, - viewMode, - query, - id, - // do not pass any time information from dashboard to panel when panel has custom time range - // to avoid confusing panel which timeRange should be used - timeRange: hasCustomTimeRange ? undefined : timeRange, - timeslice: hasCustomTimeRange ? undefined : timeslice, - }; - } - - // ------------------------------------------------------------------------------------------------------ - // Cleanup - // ------------------------------------------------------------------------------------------------------ - public destroy() { - super.destroy(); - this.cleanupStateTools(); - this.diffingSubscription.unsubscribe(); - this.publishingSubscription.unsubscribe(); - this.integrationSubscriptions.unsubscribe(); - this.stopSyncingWithUnifiedSearch?.(); - if (this.domNode) ReactDOM.unmountComponentAtNode(this.domNode); - } - - // ------------------------------------------------------------------------------------------------------ - // Dashboard API - // ------------------------------------------------------------------------------------------------------ - public runInteractiveSave = runInteractiveSave; - public runQuickSave = runQuickSave; - - public addFromLibrary = addFromLibrary; - - public duplicatePanel(id: string) { - duplicateDashboardPanel.bind(this)(id); - } - - public canRemovePanels = () => this.expandedPanelId.value === undefined; - - public getTypeDisplayName = () => dashboardTypeDisplayName; - public getTypeDisplayNameLowerCase = () => dashboardTypeDisplayLowercase; - - public savedObjectId: BehaviorSubject; - public expandedPanelId: BehaviorSubject; - public focusedPanelId$: BehaviorSubject; - public managed$: BehaviorSubject; - public fullScreenMode$: BehaviorSubject; - public hasRunMigrations$: BehaviorSubject; - public hasUnsavedChanges$: BehaviorSubject; - public hasOverlays$: BehaviorSubject; - public useMargins$: BehaviorSubject; - public scrollToPanelId$: BehaviorSubject; - public highlightPanelId$: BehaviorSubject; - public animatePanelTransforms$: BehaviorSubject; - public panels$: BehaviorSubject; - public isEmbeddedExternally: boolean; - public uuid$: BehaviorSubject; - - public async replacePanel(idToRemove: string, { panelType, initialState }: PanelPackage) { - const newId = await this.replaceEmbeddable( - idToRemove, - initialState as Partial, - panelType, - true - ); - if (this.expandedPanelId.value !== undefined) { - this.setExpandedPanelId(newId); - } - this.setHighlightPanelId(newId); - return newId; - } - - public async addNewPanel( - panelPackage: PanelPackage, - displaySuccessMessage?: boolean - ) { - const onSuccess = (id?: string, title?: string) => { - if (!displaySuccessMessage) return; - coreServices.notifications.toasts.addSuccess({ - title: getPanelAddedSuccessString(title), - 'data-test-subj': 'addEmbeddableToDashboardSuccess', - }); - this.setScrollToPanelId(id); - this.setHighlightPanelId(id); - }; - - if (this.trackPanelAddMetric) { - this.trackPanelAddMetric(METRIC_TYPE.CLICK, panelPackage.panelType); - } - if (embeddableService.reactEmbeddableRegistryHasKey(panelPackage.panelType)) { - const newId = v4(); - - const getCustomPlacementSettingFunc = getDashboardPanelPlacementSetting( - panelPackage.panelType - ); - - const customPlacementSettings = getCustomPlacementSettingFunc - ? await getCustomPlacementSettingFunc(panelPackage.initialState) - : {}; - - const placementSettings = { - width: DEFAULT_PANEL_WIDTH, - height: DEFAULT_PANEL_HEIGHT, - strategy: PanelPlacementStrategy.findTopLeftMostOpenSpace, - ...customPlacementSettings, - }; - - const { width, height, strategy } = placementSettings; - - const { newPanelPlacement, otherPanels } = runPanelPlacementStrategy(strategy, { - currentPanels: this.getInput().panels, - height, - width, - }); - const newPanel: DashboardPanelState = { - type: panelPackage.panelType, - gridData: { - ...newPanelPlacement, - i: newId, - }, - explicitInput: { - id: newId, - }, - }; - if (panelPackage.initialState) { - this.setRuntimeStateForChild(newId, panelPackage.initialState); - } - this.updateInput({ panels: { ...otherPanels, [newId]: newPanel } }); - onSuccess(newId, newPanel.explicitInput.title); - return await this.untilReactEmbeddableLoaded(newId); - } - - const embeddableFactory = embeddableService.getEmbeddableFactory(panelPackage.panelType); - if (!embeddableFactory) { - throw new EmbeddableFactoryNotFoundError(panelPackage.panelType); - } - const initialInput = panelPackage.initialState as Partial; - - let explicitInput: Partial; - let attributes: unknown; - try { - if (initialInput) { - explicitInput = initialInput; - } else { - const explicitInputReturn = await embeddableFactory.getExplicitInput(undefined, this); - if (isExplicitInputWithAttributes(explicitInputReturn)) { - explicitInput = explicitInputReturn.newInput; - attributes = explicitInputReturn.attributes; - } else { - explicitInput = explicitInputReturn; - } - } - } catch (e) { - // error likely means user canceled embeddable creation - return; - } - - const newEmbeddable = await this.addNewEmbeddable( - embeddableFactory.type, - explicitInput, - attributes - ); - - if (newEmbeddable) { - onSuccess(newEmbeddable.id, newEmbeddable.getTitle()); - } - return newEmbeddable as ApiType; - } - - public getDashboardPanelFromId = async (panelId: string) => { - const panel = this.getInput().panels[panelId]; - if (embeddableService.reactEmbeddableRegistryHasKey(panel.type)) { - const child = this.children$.value[panelId]; - if (!child) throw new PanelNotFoundError(); - const serialized = apiHasSerializableState(child) - ? await child.serializeState() - : { rawState: {} }; - return { - type: panel.type, - explicitInput: { ...panel.explicitInput, ...serialized.rawState }, - gridData: panel.gridData, - references: serialized.references, - }; - } - return panel; - }; - - public addOrUpdateEmbeddable = addOrUpdateEmbeddable; - - public forceRefresh(refreshControlGroup: boolean = true) { - this.dispatch.setLastReloadRequestTimeToNow({}); - if (refreshControlGroup) { - // only reload all panels if this refresh does not come from the control group. - this.reload$.next(); - } - } - - public async asyncResetToLastSavedState() { - this.dispatch.resetToLastSavedInput(this.lastSavedInput$.value); - const { - explicitInput: { timeRange, refreshInterval }, - } = this.getState(); - - const { timeRestore: lastSavedTimeRestore } = this.lastSavedInput$.value; - - if (this.controlGroupApi$.value) { - await this.controlGroupApi$.value.asyncResetUnsavedChanges(); - } - - // if we are using the unified search integration, we need to force reset the time picker. - if (this.creationOptions?.useUnifiedSearchIntegration && lastSavedTimeRestore) { - const timeFilterService = dataService.query.timefilter.timefilter; - if (timeRange) timeFilterService.setTime(timeRange); - if (refreshInterval) timeFilterService.setRefreshInterval(refreshInterval); - } - this.resetAllReactEmbeddables(); - } - - public navigateToDashboard = async ( - newSavedObjectId?: string, - newCreationOptions?: Partial - ) => { - this.integrationSubscriptions.unsubscribe(); - this.integrationSubscriptions = new Subscription(); - this.stopSyncingWithUnifiedSearch?.(); - - if (newCreationOptions) { - this.creationOptions = { ...this.creationOptions, ...newCreationOptions }; - } - const loadDashboardReturn = await getDashboardContentManagementService().loadDashboardState({ - id: newSavedObjectId, - }); - - const dashboardContainerReady$ = new Subject(); - const untilDashboardReady = () => - new Promise((resolve) => { - const subscription = dashboardContainerReady$.subscribe((container) => { - subscription.unsubscribe(); - resolve(container); - }); - }); - - const initializeResult = await initializeDashboard({ - creationOptions: this.creationOptions, - untilDashboardReady, - loadDashboardReturn, - }); - if (!initializeResult) return; - const { input: newInput, searchSessionId } = initializeResult; - - this.searchSessionId = searchSessionId; - this.searchSessionId$.next(searchSessionId); - - this.setAnimatePanelTransforms(false); // prevents panels from animating on navigate. - this.setManaged(loadDashboardReturn?.managed ?? false); - this.setExpandedPanelId(undefined); - this.setLastSavedInput(omit(loadDashboardReturn?.dashboardInput, 'controlGroupInput')); - this.setSavedObjectId(newSavedObjectId); - this.firstLoad = true; - this.updateInput(newInput); - dashboardContainerReady$.next(this); - }; - - /** - * Use this to set the dataviews that are used in the dashboard when they change/update - * @param newDataViews The new array of dataviews that will overwrite the old dataviews array - */ - public setAllDataViews = (newDataViews: DataView[]) => { - (this.dataViews as BehaviorSubject).next(newDataViews); - }; - - public getPanelsState = () => { - return this.getState().explicitInput.panels; - }; - - public getSettings = (): DashboardStateFromSettingsFlyout => { - const state = this.getState(); - return { - description: state.explicitInput.description, - hidePanelTitles: state.explicitInput.hidePanelTitles, - syncColors: state.explicitInput.syncColors, - syncCursor: state.explicitInput.syncCursor, - syncTooltips: state.explicitInput.syncTooltips, - tags: state.explicitInput.tags, - timeRestore: state.explicitInput.timeRestore, - title: state.explicitInput.title, - useMargins: state.explicitInput.useMargins, - }; - }; - - public setSettings = (settings: DashboardStateFromSettingsFlyout) => { - this.dispatch.setStateFromSettingsFlyout(settings); - }; - - public setViewMode = (viewMode: ViewMode) => { - // block the Dashboard from entering edit mode if this Dashboard is managed. - if (this.managed$.value && viewMode?.toLowerCase() === ViewMode.EDIT) { - return; - } - this.dispatch.setViewMode(viewMode); - }; - - public setQuery = (query?: Query | undefined) => this.updateInput({ query }); - - public setFilters = (filters?: Filter[] | undefined) => this.updateInput({ filters }); - - public setTags = (tags: string[]) => { - this.updateInput({ tags }); - }; - - public getPanelCount = () => { - return Object.keys(this.getInput().panels).length; - }; - - public async getPanelTitles(): Promise { - const titles: string[] = []; - for (const [id, panel] of Object.entries(this.getInput().panels)) { - const title = await (async () => { - if (embeddableService.reactEmbeddableRegistryHasKey(panel.type)) { - const child = this.children$.value[id]; - return apiPublishesPanelTitle(child) ? getPanelTitle(child) : ''; - } - await this.untilEmbeddableLoaded(id); - const child: IEmbeddable = this.getChild(id); - if (!child) return undefined; - return child.getTitle(); - })(); - if (title) titles.push(title); - } - return titles; - } - - public setPanels = (panels: DashboardPanelMap) => { - this.dispatch.setPanels(panels); - }; - - // ------------------------------------------------------------------------------------------------------ - // React Embeddable system - // ------------------------------------------------------------------------------------------------------ - public registerChildApi = (api: DefaultEmbeddableApi) => { - this.children$.next({ - ...this.children$.value, - [api.uuid]: api as DefaultEmbeddableApi, - }); - }; - - public saveNotification$: Subject = new Subject(); - - public getSerializedStateForChild = (childId: string) => { - const rawState = this.getInput().panels[childId].explicitInput; - const { id, ...serializedState } = rawState; - if (!rawState || Object.keys(serializedState).length === 0) return; - const references = getReferencesForPanelId(childId, this.savedObjectReferences); - return { - rawState, - // references from old installations may not be prefixed with panel id - // fall back to passing all references in these cases to preserve backwards compatability - references: references.length > 0 ? references : this.savedObjectReferences, - }; - }; - - public getSerializedStateForControlGroup = () => { - return { - rawState: this.controlGroupInput - ? this.controlGroupInput - : { - labelPosition: 'oneLine', - chainingSystem: 'HIERARCHICAL', - autoApplySelections: true, - controls: [], - ignoreParentSettings: { - ignoreFilters: false, - ignoreQuery: false, - ignoreTimerange: false, - ignoreValidations: false, - }, - }, - references: getReferencesForControls(this.savedObjectReferences), - }; - }; - - private restoredRuntimeState: UnsavedPanelState | undefined = undefined; - public setRuntimeStateForChild = (childId: string, state: object) => { - const runtimeState = this.restoredRuntimeState ?? {}; - runtimeState[childId] = state; - this.restoredRuntimeState = runtimeState; - }; - public getRuntimeStateForChild = (childId: string) => { - return this.restoredRuntimeState?.[childId]; - }; - - public getRuntimeStateForControlGroup = () => { - return this.getRuntimeStateForChild(PANELS_CONTROL_GROUP_KEY); - }; - - public removePanel(id: string) { - const type = this.getInput().panels[id]?.type; - this.removeEmbeddable(id); - if (embeddableService.reactEmbeddableRegistryHasKey(type)) { - const { [id]: childToRemove, ...otherChildren } = this.children$.value; - this.children$.next(otherChildren); - } - } - - public startAuditingReactEmbeddableChildren = () => { - const auditChildren = () => { - const currentChildren = this.children$.value; - let panelsChanged = false; - for (const panelId of Object.keys(currentChildren)) { - if (!this.getInput().panels[panelId]) { - delete currentChildren[panelId]; - panelsChanged = true; - } - } - if (panelsChanged) this.children$.next(currentChildren); - }; - - // audit children when panels change - this.publishingSubscription.add( - this.getInput$() - .pipe( - map(() => Object.keys(this.getInput().panels)), - distinctUntilChanged(deepEqual) - ) - .subscribe(() => auditChildren()) - ); - auditChildren(); - }; - - public resetAllReactEmbeddables = () => { - this.restoredRuntimeState = undefined; - let resetChangedPanelCount = false; - const currentChildren = this.children$.value; - for (const panelId of Object.keys(currentChildren)) { - if (this.getInput().panels[panelId]) { - const child = currentChildren[panelId]; - if (apiPublishesUnsavedChanges(child)) { - const success = child.resetUnsavedChanges(); - if (!success) { - coreServices.notifications.toasts.addWarning( - i18n.translate('dashboard.reset.panelError', { - defaultMessage: 'Unable to reset panel changes', - }) - ); - } - } - } else { - // if reset resulted in panel removal, we need to update the list of children - delete currentChildren[panelId]; - resetChangedPanelCount = true; - } - } - if (resetChangedPanelCount) this.children$.next(currentChildren); - }; -} diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container_factory.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container_factory.tsx deleted file mode 100644 index 52d7d84f67490..0000000000000 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container_factory.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { i18n } from '@kbn/i18n'; -import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common'; -import { - Container, - ContainerOutput, - EmbeddableFactory, - EmbeddableFactoryDefinition, - ErrorEmbeddable, -} from '@kbn/embeddable-plugin/public'; - -import { DASHBOARD_CONTAINER_TYPE } from '..'; -import { createExtract, createInject, DashboardContainerInput } from '../../../common'; -import { DEFAULT_DASHBOARD_INPUT } from '../../dashboard_constants'; -import type { DashboardContainer } from './dashboard_container'; -import type { DashboardCreationOptions } from '../..'; - -export type DashboardContainerFactory = EmbeddableFactory< - DashboardContainerInput, - ContainerOutput, - DashboardContainer ->; - -export const dashboardTypeDisplayName = i18n.translate('dashboard.factory.displayName', { - defaultMessage: 'Dashboard', -}); - -export const dashboardTypeDisplayLowercase = i18n.translate( - 'dashboard.factory.displayNameLowercase', - { - defaultMessage: 'dashboard', - } -); - -export class DashboardContainerFactoryDefinition - implements - EmbeddableFactoryDefinition -{ - public readonly isContainerType = true; - public readonly type = DASHBOARD_CONTAINER_TYPE; - - public inject: EmbeddablePersistableStateService['inject']; - public extract: EmbeddablePersistableStateService['extract']; - - constructor(private readonly persistableStateService: EmbeddablePersistableStateService) { - this.inject = createInject(this.persistableStateService); - this.extract = createExtract(this.persistableStateService); - } - - public isEditable = async () => { - // Currently unused for dashboards - return false; - }; - - public readonly getDisplayName = () => dashboardTypeDisplayName; - - public getDefaultInput(): Partial { - return DEFAULT_DASHBOARD_INPUT; - } - - public create = async ( - initialInput: DashboardContainerInput, - parent?: Container, - creationOptions?: DashboardCreationOptions, - savedObjectId?: string - ): Promise => { - const dashboardCreationStartTime = performance.now(); - const { createDashboard } = await import('./create/create_dashboard'); - try { - const dashboard = await createDashboard( - creationOptions, - dashboardCreationStartTime, - savedObjectId - ); - return dashboard; - } catch (e) { - return new ErrorEmbeddable(e, { id: e.id }); - } - }; -} diff --git a/src/plugins/dashboard/public/dashboard_container/external_api/dashboard_renderer.test.tsx b/src/plugins/dashboard/public/dashboard_container/external_api/dashboard_renderer.test.tsx deleted file mode 100644 index 6a81a8c4fd601..0000000000000 --- a/src/plugins/dashboard/public/dashboard_container/external_api/dashboard_renderer.test.tsx +++ /dev/null @@ -1,305 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { setStubKibanaServices } from '@kbn/embeddable-plugin/public/mocks'; -import { NotFoundPrompt } from '@kbn/shared-ux-prompt-not-found'; -import { mountWithIntl } from '@kbn/test-jest-helpers'; -import { ReactWrapper } from 'enzyme'; -import React from 'react'; -import { act } from 'react-dom/test-utils'; - -import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/common'; -import { setStubKibanaServices as setPresentationPanelMocks } from '@kbn/presentation-panel-plugin/public/mocks'; -import { BehaviorSubject } from 'rxjs'; -import { DashboardContainerFactory } from '..'; -import { DashboardCreationOptions } from '../..'; -import { DashboardContainer } from '../embeddable/dashboard_container'; -import { DashboardRenderer } from './dashboard_renderer'; - -jest.mock('../embeddable/dashboard_container_factory', () => ({})); - -describe('dashboard renderer', () => { - let mockDashboardContainer: DashboardContainer; - let mockDashboardFactory: DashboardContainerFactory; - - beforeEach(() => { - mockDashboardContainer = { - destroy: jest.fn(), - render: jest.fn(), - select: jest.fn(), - navigateToDashboard: jest.fn().mockResolvedValue({}), - getInput: jest.fn().mockResolvedValue({}), - } as unknown as DashboardContainer; - mockDashboardFactory = { - create: jest.fn().mockReturnValue(mockDashboardContainer), - } as unknown as DashboardContainerFactory; - // eslint-disable-next-line @typescript-eslint/no-var-requires - require('../embeddable/dashboard_container_factory').DashboardContainerFactoryDefinition = jest - .fn() - .mockReturnValue(mockDashboardFactory); - setPresentationPanelMocks(); - }); - - test('calls create method on the Dashboard embeddable factory', async () => { - await act(async () => { - mountWithIntl(); - }); - expect(mockDashboardFactory.create).toHaveBeenCalled(); - }); - - test('saved object id & creation options are passed to dashboard factory', async () => { - const options: DashboardCreationOptions = { - useSessionStorageIntegration: true, - useUnifiedSearchIntegration: true, - }; - await act(async () => { - mountWithIntl( - Promise.resolve(options)} - /> - ); - }); - expect(mockDashboardFactory.create).toHaveBeenCalledWith( - expect.any(Object), - undefined, - options, - 'saved_object_kibanana' - ); - }); - - test('destroys dashboard container on unmount', async () => { - let wrapper: ReactWrapper; - await act(async () => { - wrapper = await mountWithIntl(); - }); - wrapper!.unmount(); - expect(mockDashboardContainer.destroy).toHaveBeenCalledTimes(1); - }); - - test('calls navigate and does not destroy dashboard container on ID change', async () => { - let wrapper: ReactWrapper; - await act(async () => { - wrapper = await mountWithIntl(); - }); - await act(async () => { - await wrapper.setProps({ savedObjectId: 'saved_object_kibanakiwi' }); - }); - expect(mockDashboardContainer.destroy).not.toHaveBeenCalled(); - expect(mockDashboardContainer.navigateToDashboard).toHaveBeenCalledWith( - 'saved_object_kibanakiwi' - ); - }); - - test('renders and destroys an error embeddable when the dashboard factory create method throws an error', async () => { - const mockErrorEmbeddable = { - error: 'oh my goodness an error', - destroy: jest.fn(), - render: jest.fn(), - } as unknown as DashboardContainer; - mockDashboardFactory = { - create: jest.fn().mockReturnValue(mockErrorEmbeddable), - } as unknown as DashboardContainerFactory; - // eslint-disable-next-line @typescript-eslint/no-var-requires - require('../embeddable/dashboard_container_factory').DashboardContainerFactoryDefinition = jest - .fn() - .mockReturnValue(mockDashboardFactory); - - let wrapper: ReactWrapper; - await act(async () => { - wrapper = await mountWithIntl(); - }); - - expect(mockErrorEmbeddable.render).toHaveBeenCalled(); - wrapper!.unmount(); - expect(mockErrorEmbeddable.destroy).toHaveBeenCalledTimes(1); - }); - - test('creates a new dashboard container when the ID changes, and the first created dashboard resulted in an error', async () => { - // ensure that the first attempt at creating a dashboard results in an error embeddable - const mockErrorEmbeddable = { - error: 'oh my goodness an error', - destroy: jest.fn(), - render: jest.fn(), - } as unknown as DashboardContainer; - const mockErrorFactory = { - create: jest.fn().mockReturnValue(mockErrorEmbeddable), - } as unknown as DashboardContainerFactory; - // eslint-disable-next-line @typescript-eslint/no-var-requires - require('../embeddable/dashboard_container_factory').DashboardContainerFactoryDefinition = jest - .fn() - .mockReturnValue(mockErrorFactory); - - // render the dashboard - it should run into an error and render the error embeddable. - let wrapper: ReactWrapper; - await act(async () => { - wrapper = await mountWithIntl(); - }); - expect(mockErrorEmbeddable.render).toHaveBeenCalled(); - expect(mockErrorFactory.create).toHaveBeenCalledTimes(1); - - // ensure that the next attempt at creating a dashboard is successfull. - const mockSuccessEmbeddable = { - destroy: jest.fn(), - render: jest.fn(), - navigateToDashboard: jest.fn(), - select: jest.fn(), - getInput: jest.fn().mockResolvedValue({}), - } as unknown as DashboardContainer; - const mockSuccessFactory = { - create: jest.fn().mockReturnValue(mockSuccessEmbeddable), - } as unknown as DashboardContainerFactory; - // eslint-disable-next-line @typescript-eslint/no-var-requires - require('../embeddable/dashboard_container_factory').DashboardContainerFactoryDefinition = jest - .fn() - .mockReturnValue(mockSuccessFactory); - - // update the saved object id to trigger another dashboard load. - await act(async () => { - await wrapper.setProps({ savedObjectId: 'saved_object_kibanakiwi' }); - }); - - expect(mockErrorEmbeddable.destroy).toHaveBeenCalled(); - - // because a new dashboard container has been created, we should not call navigate. - expect(mockSuccessEmbeddable.navigateToDashboard).not.toHaveBeenCalled(); - - // instead we should call create on the factory again. - expect(mockSuccessFactory.create).toHaveBeenCalledTimes(1); - }); - - test('renders a 404 page when initial dashboard creation returns a savedObjectNotFound error', async () => { - // mock embeddable dependencies so that the embeddable panel renders - setStubKibanaServices(); - - // ensure that the first attempt at creating a dashboard results in a 404 - const mockErrorEmbeddable = { - error: new SavedObjectNotFound('dashboard', 'gat em'), - destroy: jest.fn(), - render: jest.fn(), - } as unknown as DashboardContainer; - const mockErrorFactory = { - create: jest.fn().mockReturnValue(mockErrorEmbeddable), - } as unknown as DashboardContainerFactory; - // eslint-disable-next-line @typescript-eslint/no-var-requires - require('../embeddable/dashboard_container_factory').DashboardContainerFactoryDefinition = jest - .fn() - .mockReturnValue(mockErrorFactory); - - // render the dashboard - it should run into an error and render the error embeddable. - let wrapper: ReactWrapper; - await act(async () => { - wrapper = await mountWithIntl(); - }); - await wrapper!.update(); - - // The shared UX not found prompt should be rendered. - expect(wrapper!.find(NotFoundPrompt).exists()).toBeTruthy(); - }); - - test('renders a 404 page when dashboard navigation returns a savedObjectNotFound error', async () => { - mockDashboardContainer.navigateToDashboard = jest - .fn() - .mockRejectedValue(new SavedObjectNotFound('dashboard', 'gat em')); - - let wrapper: ReactWrapper; - await act(async () => { - wrapper = await mountWithIntl(); - }); - // The shared UX not found prompt should not be rendered. - expect(wrapper!.find(NotFoundPrompt).exists()).toBeFalsy(); - - expect(mockDashboardContainer.render).toHaveBeenCalled(); - await act(async () => { - await wrapper.setProps({ savedObjectId: 'saved_object_kibanakiwi' }); - }); - await wrapper!.update(); - - // The shared UX not found prompt should be rendered. - expect(wrapper!.find(NotFoundPrompt).exists()).toBeTruthy(); - }); - - test('does not add a class to the parent element when expandedPanelId is undefined', async () => { - let wrapper: ReactWrapper; - await act(async () => { - wrapper = await mountWithIntl( -
- -
- ); - }); - await wrapper!.update(); - - expect( - wrapper!.find('#superParent').getDOMNode().classList.contains('dshDashboardViewportWrapper') - ).toBe(false); - }); - - test('adds a class to the parent element when expandedPanelId is truthy', async () => { - const mockSuccessEmbeddable = { - destroy: jest.fn(), - render: jest.fn(), - navigateToDashboard: jest.fn(), - select: jest.fn().mockReturnValue('WhatAnExpandedPanel'), - getInput: jest.fn().mockResolvedValue({}), - expandedPanelId: new BehaviorSubject('panel1'), - } as unknown as DashboardContainer; - const mockSuccessFactory = { - create: jest.fn().mockReturnValue(mockSuccessEmbeddable), - } as unknown as DashboardContainerFactory; - // eslint-disable-next-line @typescript-eslint/no-var-requires - require('../embeddable/dashboard_container_factory').DashboardContainerFactoryDefinition = jest - .fn() - .mockReturnValue(mockSuccessFactory); - - let wrapper: ReactWrapper; - await act(async () => { - wrapper = await mountWithIntl( -
- -
- ); - }); - - expect( - wrapper!.find('#superParent').getDOMNode().classList.contains('dshDashboardViewportWrapper') - ).toBe(true); - }); - - test('adds a class to apply default background color when dashboard has use margin option set to false', async () => { - const mockUseMarginFalseEmbeddable = { - ...mockDashboardContainer, - getInput: jest.fn().mockResolvedValue({ useMargins: false }), - } as unknown as DashboardContainer; - - const mockUseMarginFalseFactory = { - create: jest.fn().mockReturnValue(mockUseMarginFalseEmbeddable), - } as unknown as DashboardContainerFactory; - // eslint-disable-next-line @typescript-eslint/no-var-requires - require('../embeddable/dashboard_container_factory').DashboardContainerFactoryDefinition = jest - .fn() - .mockReturnValue(mockUseMarginFalseFactory); - - let wrapper: ReactWrapper; - await act(async () => { - wrapper = await mountWithIntl( -
- -
- ); - }); - - expect( - wrapper! - .find('#superParent') - .getDOMNode() - .classList.contains('dshDashboardViewportWrapper--defaultBg') - ).not.toBe(null); - }); -}); diff --git a/src/plugins/dashboard/public/dashboard_container/external_api/dashboard_renderer.tsx b/src/plugins/dashboard/public/dashboard_container/external_api/dashboard_renderer.tsx index 40b54e42e6ffa..c6b5467e25be8 100644 --- a/src/plugins/dashboard/public/dashboard_container/external_api/dashboard_renderer.tsx +++ b/src/plugins/dashboard/public/dashboard_container/external_api/dashboard_renderer.tsx @@ -10,24 +10,23 @@ import '../_dashboard_container.scss'; import classNames from 'classnames'; -import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import useUnmount from 'react-use/lib/useUnmount'; -import { v4 as uuidv4 } from 'uuid'; +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { EuiLoadingElastic, EuiLoadingSpinner } from '@elastic/eui'; -import { ErrorEmbeddable, isErrorEmbeddable } from '@kbn/embeddable-plugin/public'; import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/common'; import { useStateFromPublishingSubject } from '@kbn/presentation-publishing'; import { LocatorPublic } from '@kbn/share-plugin/common'; -import { DashboardContainerInput } from '../../../common'; -import { DashboardApi } from '../../dashboard_api/types'; -import { embeddableService, screenshotModeService } from '../../services/kibana_services'; -import type { DashboardContainer } from '../embeddable/dashboard_container'; -import { DashboardContainerFactoryDefinition } from '../embeddable/dashboard_container_factory'; +import { ExitFullScreenButtonKibanaProvider } from '@kbn/shared-ux-button-exit-full-screen'; +import { DashboardApi, DashboardInternalApi } from '../../dashboard_api/types'; +import { coreServices, screenshotModeService } from '../../services/kibana_services'; import type { DashboardCreationOptions } from '../..'; import { DashboardLocatorParams, DashboardRedirect } from '../types'; import { Dashboard404Page } from './dashboard_404'; +import { DashboardContext } from '../../dashboard_api/use_dashboard_api'; +import { DashboardViewport } from '../component/viewport/dashboard_viewport'; +import { loadDashboardApi } from '../../dashboard_api/load_dashboard_api'; +import { DashboardInternalContext } from '../../dashboard_api/use_dashboard_internal_api'; export interface DashboardRendererProps { onApiAvailable?: (api: DashboardApi) => void; @@ -46,93 +45,55 @@ export function DashboardRenderer({ locator, onApiAvailable, }: DashboardRendererProps) { - const dashboardRoot = useRef(null); const dashboardViewport = useRef(null); - const [loading, setLoading] = useState(true); - const [dashboardContainer, setDashboardContainer] = useState(); - const [fatalError, setFatalError] = useState(); - const [dashboardMissing, setDashboardMissing] = useState(false); - - const id = useMemo(() => uuidv4(), []); + const dashboardContainer = useRef(null); + const [dashboardApi, setDashboardApi] = useState(); + const [dashboardInternalApi, setDashboardInternalApi] = useState< + DashboardInternalApi | undefined + >(); + const [error, setError] = useState(); useEffect(() => { /* In case the locator prop changes, we need to reassign the value in the container */ - if (dashboardContainer) dashboardContainer.locator = locator; - }, [dashboardContainer, locator]); + if (dashboardApi) dashboardApi.locator = locator; + }, [dashboardApi, locator]); useEffect(() => { - /** - * Here we attempt to build a dashboard or navigate to a new dashboard. Clear all error states - * if they exist in case this dashboard loads correctly. - */ - fatalError?.destroy(); - setDashboardMissing(false); - setFatalError(undefined); - - if (dashboardContainer) { - // When a dashboard already exists, don't rebuild it, just set a new id. - dashboardContainer.navigateToDashboard(savedObjectId).catch((e) => { - dashboardContainer?.destroy(); - setDashboardContainer(undefined); - setFatalError(new ErrorEmbeddable(e, { id })); - if (e instanceof SavedObjectNotFound) { - setDashboardMissing(true); - } - }); - return; - } + if (error) setError(undefined); + if (dashboardApi) setDashboardApi(undefined); + if (dashboardInternalApi) setDashboardInternalApi(undefined); - setLoading(true); let canceled = false; - (async () => { - const creationOptions = await getCreationOptions?.(); - - const dashboardFactory = new DashboardContainerFactoryDefinition(embeddableService); - const container = await dashboardFactory.create( - { id } as unknown as DashboardContainerInput, // Input from creationOptions is used instead. - undefined, - creationOptions, - savedObjectId - ); - setLoading(false); - - if (canceled || !container) { - setDashboardContainer(undefined); - container?.destroy(); - return; - } - - if (isErrorEmbeddable(container)) { - setFatalError(container); - if (container.error instanceof SavedObjectNotFound) { - setDashboardMissing(true); + let cleanupDashboardApi: (() => void) | undefined; + loadDashboardApi({ getCreationOptions, savedObjectId }) + .then((results) => { + if (!results) return; + if (canceled) { + results.cleanup(); + return; } - return; - } - if (dashboardRoot.current) { - container.render(dashboardRoot.current); - } + cleanupDashboardApi = results.cleanup; + setDashboardApi(results.api); + setDashboardInternalApi(results.internalApi); + onApiAvailable?.(results.api); + }) + .catch((err) => { + if (!canceled) setError(err); + }); - setDashboardContainer(container); - onApiAvailable?.(container as DashboardApi); - })(); return () => { + cleanupDashboardApi?.(); canceled = true; }; // Disabling exhaustive deps because embeddable should only be created on first render. // eslint-disable-next-line react-hooks/exhaustive-deps }, [savedObjectId]); - useUnmount(() => { - fatalError?.destroy(); - dashboardContainer?.destroy(); - }); - const viewportClasses = classNames( 'dashboardViewport', { 'dashboardViewport--screenshotMode': screenshotModeService.isScreenshotMode() }, - { 'dashboardViewport--loading': loading } + { 'dashboardViewport--loading': !error && !dashboardApi } ); const loadingSpinner = showPlainSpinner ? ( @@ -142,22 +103,43 @@ export function DashboardRenderer({ ); const renderDashboardContents = () => { - if (dashboardMissing) return ; - if (fatalError) return fatalError.render(); - if (loading) return loadingSpinner; - return
; + if (error) { + return error instanceof SavedObjectNotFound ? ( + + ) : ( + error.message + ); + } + + return dashboardApi && dashboardInternalApi ? ( +
+ + + + + + + +
+ ) : ( + loadingSpinner + ); }; return (
- {dashboardViewport?.current && - dashboardContainer && - !isErrorEmbeddable(dashboardContainer) && ( - - )} + {dashboardViewport?.current && dashboardApi && ( + + )} {renderDashboardContents()}
); diff --git a/src/plugins/dashboard/public/dashboard_container/index.ts b/src/plugins/dashboard/public/dashboard_container/index.ts index b4ecb30f3c25d..c3f1989d66cd6 100644 --- a/src/plugins/dashboard/public/dashboard_container/index.ts +++ b/src/plugins/dashboard/public/dashboard_container/index.ts @@ -14,9 +14,6 @@ export const DASHBOARD_CONTAINER_TYPE = 'dashboard'; export const LATEST_DASHBOARD_CONTAINER_VERSION = convertNumberToDashboardVersion(LATEST_VERSION); -export type { DashboardContainer } from './embeddable/dashboard_container'; -export { type DashboardContainerFactory } from './embeddable/dashboard_container_factory'; - export { LazyDashboardRenderer } from './external_api/lazy_dashboard_renderer'; export type { DashboardLocatorParams } from './types'; export type { IProvidesLegacyPanelPlacementSettings } from './panel_placement'; diff --git a/src/plugins/dashboard/public/dashboard_container/panel_placement/index.ts b/src/plugins/dashboard/public/dashboard_container/panel_placement/index.ts index 81b7c7d6b38ad..d903886695745 100644 --- a/src/plugins/dashboard/public/dashboard_container/panel_placement/index.ts +++ b/src/plugins/dashboard/public/dashboard_container/panel_placement/index.ts @@ -7,8 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { placePanel } from './place_panel'; - export { placeClonePanel } from './place_clone_panel_strategy'; export { registerDashboardPanelPlacementSetting } from './panel_placement_registry'; diff --git a/src/plugins/dashboard/public/dashboard_container/panel_placement/place_panel.test.ts b/src/plugins/dashboard/public/dashboard_container/panel_placement/place_panel.test.ts deleted file mode 100644 index 4dabd35a35670..0000000000000 --- a/src/plugins/dashboard/public/dashboard_container/panel_placement/place_panel.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { DashboardPanelState } from '../../../common'; -import { EmbeddableFactory, EmbeddableInput } from '@kbn/embeddable-plugin/public'; -import { CONTACT_CARD_EMBEDDABLE } from '@kbn/embeddable-plugin/public/lib/test_samples'; -import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from '../../dashboard_constants'; - -import { placePanel } from './place_panel'; -import { IProvidesLegacyPanelPlacementSettings } from './types'; - -interface TestInput extends EmbeddableInput { - test: string; -} -const panels: { [key: string]: DashboardPanelState } = {}; - -test('adds a new panel state in 0,0 position', () => { - const { newPanel: panelState } = placePanel( - {} as unknown as EmbeddableFactory, - { - type: CONTACT_CARD_EMBEDDABLE, - explicitInput: { test: 'hi', id: '123' }, - }, - panels - ); - expect(panelState.explicitInput.test).toBe('hi'); - expect(panelState.type).toBe(CONTACT_CARD_EMBEDDABLE); - expect(panelState.explicitInput.id).toBeDefined(); - expect(panelState.gridData.x).toBe(0); - expect(panelState.gridData.y).toBe(0); - expect(panelState.gridData.h).toBe(DEFAULT_PANEL_HEIGHT); - expect(panelState.gridData.w).toBe(DEFAULT_PANEL_WIDTH); - - panels[panelState.explicitInput.id] = panelState; -}); - -test('adds a second new panel state', () => { - const { newPanel: panelState } = placePanel( - {} as unknown as EmbeddableFactory, - { type: CONTACT_CARD_EMBEDDABLE, explicitInput: { test: 'bye', id: '456' } }, - panels - ); - - expect(panelState.gridData.x).toBe(DEFAULT_PANEL_WIDTH); - expect(panelState.gridData.y).toBe(0); - expect(panelState.gridData.h).toBe(DEFAULT_PANEL_HEIGHT); - expect(panelState.gridData.w).toBe(DEFAULT_PANEL_WIDTH); - - panels[panelState.explicitInput.id] = panelState; -}); - -test('adds a third new panel state', () => { - const { newPanel: panelState } = placePanel( - {} as unknown as EmbeddableFactory, - { - type: CONTACT_CARD_EMBEDDABLE, - explicitInput: { test: 'bye', id: '789' }, - }, - panels - ); - expect(panelState.gridData.x).toBe(0); - expect(panelState.gridData.y).toBe(DEFAULT_PANEL_HEIGHT); - expect(panelState.gridData.h).toBe(DEFAULT_PANEL_HEIGHT); - expect(panelState.gridData.w).toBe(DEFAULT_PANEL_WIDTH); - - panels[panelState.explicitInput.id] = panelState; -}); - -test('adds a new panel state in the top most position when it is open', () => { - // deleting panel 456 means that the top leftmost open position will be at the top of the Dashboard. - delete panels['456']; - const { newPanel: panelState } = placePanel( - {} as unknown as EmbeddableFactory, - { - type: CONTACT_CARD_EMBEDDABLE, - explicitInput: { test: 'bye', id: '987' }, - }, - panels - ); - expect(panelState.gridData.x).toBe(DEFAULT_PANEL_WIDTH); - expect(panelState.gridData.y).toBe(0); - expect(panelState.gridData.h).toBe(DEFAULT_PANEL_HEIGHT); - expect(panelState.gridData.w).toBe(DEFAULT_PANEL_WIDTH); - - // replace the topmost panel. - panels[panelState.explicitInput.id] = panelState; -}); - -test('adds a new panel state at the very top of the Dashboard with default sizing', () => { - const embeddableFactoryStub: IProvidesLegacyPanelPlacementSettings = { - getLegacyPanelPlacementSettings: jest.fn().mockImplementation(() => { - return { strategy: 'placeAtTop' }; - }), - }; - - const { newPanel: panelState } = placePanel( - embeddableFactoryStub as unknown as EmbeddableFactory, - { - type: CONTACT_CARD_EMBEDDABLE, - explicitInput: { test: 'wowee', id: '9001' }, - }, - panels - ); - expect(panelState.gridData.x).toBe(0); - expect(panelState.gridData.y).toBe(0); - expect(panelState.gridData.h).toBe(DEFAULT_PANEL_HEIGHT); - expect(panelState.gridData.w).toBe(DEFAULT_PANEL_WIDTH); - - expect(embeddableFactoryStub.getLegacyPanelPlacementSettings).toHaveBeenCalledWith( - { id: '9001', test: 'wowee' }, - undefined - ); -}); - -test('adds a new panel state at the very top of the Dashboard with custom sizing', () => { - const embeddableFactoryStub: IProvidesLegacyPanelPlacementSettings = { - getLegacyPanelPlacementSettings: jest.fn().mockImplementation(() => { - return { strategy: 'placeAtTop', width: 10, height: 5 }; - }), - }; - - const { newPanel: panelState } = placePanel( - embeddableFactoryStub as unknown as EmbeddableFactory, - { - type: CONTACT_CARD_EMBEDDABLE, - explicitInput: { test: 'woweee', id: '9002' }, - }, - panels - ); - expect(panelState.gridData.x).toBe(0); - expect(panelState.gridData.y).toBe(0); - expect(panelState.gridData.h).toBe(5); - expect(panelState.gridData.w).toBe(10); - - expect(embeddableFactoryStub.getLegacyPanelPlacementSettings).toHaveBeenCalledWith( - { id: '9002', test: 'woweee' }, - undefined - ); -}); - -test('passes through given attributes', () => { - const embeddableFactoryStub: IProvidesLegacyPanelPlacementSettings = { - getLegacyPanelPlacementSettings: jest.fn().mockImplementation(() => { - return { strategy: 'placeAtTop', width: 10, height: 5 }; - }), - }; - - placePanel( - embeddableFactoryStub as unknown as EmbeddableFactory, - { - type: CONTACT_CARD_EMBEDDABLE, - explicitInput: { test: 'wow', id: '9004' }, - }, - panels, - { testAttr: 'hello' } - ); - - expect(embeddableFactoryStub.getLegacyPanelPlacementSettings).toHaveBeenCalledWith( - { id: '9004', test: 'wow' }, - { testAttr: 'hello' } - ); -}); diff --git a/src/plugins/dashboard/public/dashboard_container/panel_placement/place_panel.ts b/src/plugins/dashboard/public/dashboard_container/panel_placement/place_panel.ts deleted file mode 100644 index 488cb6f1d463d..0000000000000 --- a/src/plugins/dashboard/public/dashboard_container/panel_placement/place_panel.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { PanelState, EmbeddableInput, EmbeddableFactory } from '@kbn/embeddable-plugin/public'; - -import { DashboardPanelState } from '../../../common'; -import { IProvidesLegacyPanelPlacementSettings } from './types'; -import { runPanelPlacementStrategy } from './place_new_panel_strategies'; -import { - DEFAULT_PANEL_HEIGHT, - DEFAULT_PANEL_WIDTH, - PanelPlacementStrategy, -} from '../../dashboard_constants'; - -export const providesLegacyPanelPlacementSettings = ( - value: unknown -): value is IProvidesLegacyPanelPlacementSettings => { - return Boolean((value as IProvidesLegacyPanelPlacementSettings).getLegacyPanelPlacementSettings); -}; - -export function placePanel( - factory: EmbeddableFactory, - newPanel: PanelState, - currentPanels: { [key: string]: DashboardPanelState }, - attributes?: unknown -): { - newPanel: DashboardPanelState; - otherPanels: { [key: string]: DashboardPanelState }; -} { - let placementSettings = { - width: DEFAULT_PANEL_WIDTH, - height: DEFAULT_PANEL_HEIGHT, - strategy: PanelPlacementStrategy.findTopLeftMostOpenSpace, - }; - if (providesLegacyPanelPlacementSettings(factory)) { - placementSettings = { - ...placementSettings, - ...factory.getLegacyPanelPlacementSettings(newPanel.explicitInput, attributes), - }; - } - const { width, height, strategy } = placementSettings; - - const { newPanelPlacement, otherPanels } = runPanelPlacementStrategy(strategy, { - currentPanels, - height, - width, - }); - - return { - newPanel: { - gridData: { - ...newPanelPlacement, - i: newPanel.explicitInput.id, - }, - ...newPanel, - }, - otherPanels, - }; -} diff --git a/src/plugins/dashboard/public/dashboard_container/state/dashboard_container_reducers.ts b/src/plugins/dashboard/public/dashboard_container/state/dashboard_container_reducers.ts deleted file mode 100644 index c0c39b0ffd284..0000000000000 --- a/src/plugins/dashboard/public/dashboard_container/state/dashboard_container_reducers.ts +++ /dev/null @@ -1,173 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { PayloadAction } from '@reduxjs/toolkit'; - -import { isFilterPinned } from '@kbn/es-query'; -import { - DashboardReduxState, - DashboardStateFromSaveModal, - DashboardStateFromSettingsFlyout, -} from '../types'; -import { DashboardContainerInput } from '../../../common'; - -export const dashboardContainerReducers = { - // ------------------------------------------------------------------------------ - // Content Reducers - // ------------------------------------------------------------------------------ - setPanels: ( - state: DashboardReduxState, - action: PayloadAction - ) => { - state.explicitInput.panels = action.payload; - }, - - // ------------------------------------------------------------------------------ - // Meta info Reducers - // ------------------------------------------------------------------------------ - setStateFromSaveModal: ( - state: DashboardReduxState, - action: PayloadAction - ) => { - state.explicitInput.tags = action.payload.tags; - state.explicitInput.title = action.payload.title; - state.explicitInput.description = action.payload.description; - state.explicitInput.timeRestore = action.payload.timeRestore; - - if (action.payload.refreshInterval) { - state.explicitInput.refreshInterval = action.payload.refreshInterval; - } - if (action.payload.timeRange) { - state.explicitInput.timeRange = action.payload.timeRange; - } - }, - - setStateFromSettingsFlyout: ( - state: DashboardReduxState, - action: PayloadAction - ) => { - state.explicitInput.tags = action.payload.tags; - state.explicitInput.title = action.payload.title; - state.explicitInput.description = action.payload.description; - state.explicitInput.timeRestore = action.payload.timeRestore; - - state.explicitInput.useMargins = action.payload.useMargins; - state.explicitInput.syncColors = action.payload.syncColors; - state.explicitInput.syncCursor = action.payload.syncCursor; - state.explicitInput.syncTooltips = action.payload.syncTooltips; - state.explicitInput.hidePanelTitles = action.payload.hidePanelTitles; - }, - - setDescription: ( - state: DashboardReduxState, - action: PayloadAction - ) => { - state.explicitInput.description = action.payload; - }, - - setViewMode: ( - state: DashboardReduxState, - action: PayloadAction - ) => { - state.explicitInput.viewMode = action.payload; - }, - - setTags: (state: DashboardReduxState, action: PayloadAction) => { - state.explicitInput.tags = action.payload; - }, - - setTitle: ( - state: DashboardReduxState, - action: PayloadAction - ) => { - state.explicitInput.title = action.payload; - }, - - /** - * Resets the dashboard to the last saved input, excluding: - * 1) The time range, unless `timeRestore` is `true` - if we include the time range on reset even when - * `timeRestore` is `false`, this causes unecessary data fetches for the control group. - * 2) The view mode, since resetting should never impact this - sometimes the Dashboard saved objects - * have this saved in and we don't want resetting to cause unexpected view mode changes. - * 3) Pinned filters. - */ - resetToLastSavedInput: ( - state: DashboardReduxState, - action: PayloadAction - ) => { - const keepPinnedFilters = [ - ...state.explicitInput.filters.filter(isFilterPinned), - ...action.payload.filters, - ]; - - state.explicitInput = { - ...action.payload, - filters: keepPinnedFilters, - ...(!state.explicitInput.timeRestore && { timeRange: state.explicitInput.timeRange }), - viewMode: state.explicitInput.viewMode, - }; - }, - - // ------------------------------------------------------------------------------ - // Filtering Reducers - // ------------------------------------------------------------------------------ - setFiltersAndQuery: ( - state: DashboardReduxState, - action: PayloadAction> - ) => { - state.explicitInput.filters = action.payload.filters; - state.explicitInput.query = action.payload.query; - }, - - setLastReloadRequestTimeToNow: (state: DashboardReduxState) => { - state.explicitInput.lastReloadRequestTime = new Date().getTime(); - }, - - setFilters: ( - state: DashboardReduxState, - action: PayloadAction - ) => { - state.explicitInput.filters = action.payload; - }, - - setQuery: ( - state: DashboardReduxState, - action: PayloadAction - ) => { - state.explicitInput.query = action.payload; - }, - - setTimeRestore: ( - state: DashboardReduxState, - action: PayloadAction - ) => { - state.explicitInput.timeRestore = action.payload; - }, - - setTimeRange: ( - state: DashboardReduxState, - action: PayloadAction - ) => { - state.explicitInput.timeRange = action.payload; - }, - - setRefreshInterval: ( - state: DashboardReduxState, - action: PayloadAction - ) => { - state.explicitInput.refreshInterval = action.payload; - }, - - setTimeslice: ( - state: DashboardReduxState, - action: PayloadAction - ) => { - state.explicitInput.timeslice = action.payload; - }, -}; diff --git a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_functions.ts b/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_functions.ts deleted file mode 100644 index 2803d9be0e32d..0000000000000 --- a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_functions.ts +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import fastIsEqual from 'fast-deep-equal'; - -import { COMPARE_ALL_OPTIONS, compareFilters, isFilterPinned } from '@kbn/es-query'; - -import { DashboardContainerInput } from '../../../../common'; -import { embeddableService } from '../../../services/kibana_services'; -import { DashboardContainer } from '../../embeddable/dashboard_container'; -import { DashboardContainerInputWithoutId } from '../../types'; -import { areTimesEqual, getPanelLayoutsAreEqual } from './dashboard_diffing_utils'; - -export interface DiffFunctionProps { - currentValue: DashboardContainerInput[Key]; - lastValue: DashboardContainerInput[Key]; - - currentInput: DashboardContainerInputWithoutId; - lastInput: DashboardContainerInputWithoutId; - container: DashboardContainer; -} - -export type DashboardDiffFunctions = { - [key in keyof Partial]: ( - props: DiffFunctionProps - ) => boolean | Promise; -}; - -export const isKeyEqualAsync = async ( - key: keyof DashboardContainerInput, - diffFunctionProps: DiffFunctionProps, - diffingFunctions: DashboardDiffFunctions -) => { - const propsAsNever = diffFunctionProps as never; // todo figure out why props has conflicting types in some constituents. - const diffingFunction = diffingFunctions[key]; - if (diffingFunction) { - return diffingFunction?.prototype?.name === 'AsyncFunction' - ? await diffingFunction(propsAsNever) - : diffingFunction(propsAsNever); - } - return fastIsEqual(diffFunctionProps.currentValue, diffFunctionProps.lastValue); -}; - -export const isKeyEqual = ( - key: keyof Omit, // only Panels is async - diffFunctionProps: DiffFunctionProps, - diffingFunctions: DashboardDiffFunctions -) => { - const propsAsNever = diffFunctionProps as never; // todo figure out why props has conflicting types in some constituents. - const diffingFunction = diffingFunctions[key]; - if (!diffingFunction) { - return fastIsEqual(diffFunctionProps.currentValue, diffFunctionProps.lastValue); - } - - if (diffingFunction?.prototype?.name === 'AsyncFunction') { - throw new Error( - `The function for key "${key}" is async, must use isKeyEqualAsync for asynchronous functions` - ); - } - return diffingFunction(propsAsNever); -}; - -/** - * A collection of functions which diff individual keys of dashboard state. If a key is missing from this list it is - * diffed by the default diffing function, fastIsEqual. - */ -export const unsavedChangesDiffingFunctions: DashboardDiffFunctions = { - panels: async ({ currentValue, lastValue, container }) => { - if (!getPanelLayoutsAreEqual(currentValue ?? {}, lastValue ?? {})) return false; - - const explicitInputComparePromises = Object.values(currentValue ?? {}).map( - (panel) => - new Promise((resolve, reject) => { - const embeddableId = panel.explicitInput.id; - if (!embeddableId || embeddableService.reactEmbeddableRegistryHasKey(panel.type)) { - // if this is a new style embeddable, it will handle its own diffing. - reject(); - return; - } - try { - container.untilEmbeddableLoaded(embeddableId).then((embeddable) => - embeddable - .getExplicitInputIsEqual(lastValue[embeddableId].explicitInput) - .then((isEqual) => { - if (isEqual) { - // rejecting the promise if the input is equal. - reject(); - } else { - // resolving false here means that the panel is unequal. The first promise to resolve this way will return false from this function. - resolve(false); - } - }) - ); - } catch (e) { - reject(); - } - }) - ); - - // If any promise resolves, return false. The catch here is only called if all promises reject which means all panels are equal. - return await Promise.any(explicitInputComparePromises).catch(() => true); - }, - - // exclude pinned filters from comparision because pinned filters are not part of application state - filters: ({ currentValue, lastValue }) => - compareFilters( - (currentValue ?? []).filter((f) => !isFilterPinned(f)), - (lastValue ?? []).filter((f) => !isFilterPinned(f)), - COMPARE_ALL_OPTIONS - ), - - timeRange: ({ currentValue, lastValue, currentInput }) => { - if (!currentInput.timeRestore) return true; // if time restore is set to false, time range doesn't count as a change. - if ( - !areTimesEqual(currentValue?.from, lastValue?.from) || - !areTimesEqual(currentValue?.to, lastValue?.to) - ) { - return false; - } - return true; - }, - - refreshInterval: ({ currentValue, lastValue, currentInput }) => { - if (!currentInput.timeRestore) return true; // if time restore is set to false, refresh interval doesn't count as a change. - return fastIsEqual(currentValue, lastValue); - }, - - viewMode: () => false, // When compared view mode is always considered unequal so that it gets backed up. -}; diff --git a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts b/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts deleted file mode 100644 index ad33b2e5fb117..0000000000000 --- a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts +++ /dev/null @@ -1,198 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ -import { childrenUnsavedChanges$ } from '@kbn/presentation-containers'; -import { omit } from 'lodash'; -import { AnyAction, Middleware } from 'redux'; -import { combineLatest, debounceTime, skipWhile, startWith, switchMap } from 'rxjs'; -import { DashboardContainer } from '../..'; -import { DashboardCreationOptions } from '../../..'; -import { DashboardContainerInput } from '../../../../common'; -import { CHANGE_CHECK_DEBOUNCE } from '../../../dashboard_constants'; -import { - PANELS_CONTROL_GROUP_KEY, - getDashboardBackupService, -} from '../../../services/dashboard_backup_service'; -import { UnsavedPanelState } from '../../types'; -import { dashboardContainerReducers } from '../dashboard_container_reducers'; -import { isKeyEqualAsync, unsavedChangesDiffingFunctions } from './dashboard_diffing_functions'; - -/** - * An array of reducers which cannot cause unsaved changes. Unsaved changes only compares the explicit input - * and the last saved input, so we can safely ignore any output reducers, and most componentState reducers. - * This is only for performance reasons, because the diffing function itself can be quite heavy. - */ -export const reducersToIgnore: Array = ['setTimeslice']; - -/** - * Some keys will often have deviated from their last saved state, but should not persist over reloads - */ -const keysToOmitFromSessionStorage: Array = [ - 'lastReloadRequestTime', - 'executionContext', - 'timeslice', - 'id', - - 'timeRange', // Current behaviour expects time range not to be backed up. Revisit this? - 'refreshInterval', -]; - -/** - * Some keys will often have deviated from their last saved state, but should be - * ignored when calculating whether or not this dashboard has unsaved changes. - */ -export const keysNotConsideredUnsavedChanges: Array = [ - 'lastReloadRequestTime', - 'executionContext', - 'timeslice', - 'viewMode', - 'id', -]; - -/** - * build middleware that fires an event any time a reducer that could cause unsaved changes is run - */ -export function getDiffingMiddleware(this: DashboardContainer) { - const diffingMiddleware: Middleware = (store) => (next) => (action) => { - const dispatchedActionName = action.type.split('/')?.[1]; - if ( - dispatchedActionName && - dispatchedActionName !== 'updateEmbeddableReduxOutput' && // ignore any generic output updates. - !reducersToIgnore.includes(dispatchedActionName) - ) { - this.anyReducerRun.next(null); - } - next(action); - }; - return diffingMiddleware; -} - -/** - * Does an initial diff between @param initialInput and @param initialLastSavedInput, and creates a middleware - * which listens to the redux store and pushes updates to the `hasUnsavedChanges` and `backupUnsavedChanges` behaviour - * subjects so that the corresponding subscriptions can dispatch updates as necessary - */ -export function startDiffingDashboardState( - this: DashboardContainer, - creationOptions?: DashboardCreationOptions -) { - /** - * Create an observable stream that checks for unsaved changes in the Dashboard state - * and the state of all of its legacy embeddable children. - */ - const dashboardUnsavedChanges = combineLatest([ - this.anyReducerRun.pipe(startWith(null)), - this.lastSavedInput$, - ]).pipe( - debounceTime(CHANGE_CHECK_DEBOUNCE), - switchMap(([, lastSavedInput]) => { - return (async () => { - const { explicitInput: currentInput } = this.getState(); - const unsavedChanges = await getDashboardUnsavedChanges.bind(this)( - lastSavedInput, - currentInput - ); - return unsavedChanges; - })(); - }) - ); - - /** - * Combine unsaved changes from all sources together. Set unsaved changes state and backup unsaved changes when any of the sources emit. - */ - this.diffingSubscription.add( - combineLatest([ - dashboardUnsavedChanges, - childrenUnsavedChanges$(this.children$), - this.controlGroupApi$.pipe( - skipWhile((controlGroupApi) => !controlGroupApi), - switchMap((controlGroupApi) => { - return controlGroupApi!.unsavedChanges; - }) - ), - ]).subscribe(([dashboardChanges, unsavedPanelState, controlGroupChanges]) => { - // calculate unsaved changes - const hasUnsavedChanges = - Object.keys(omit(dashboardChanges, keysNotConsideredUnsavedChanges)).length > 0 || - unsavedPanelState !== undefined || - controlGroupChanges !== undefined; - if (hasUnsavedChanges !== this.hasUnsavedChanges$.value) { - this.setHasUnsavedChanges(hasUnsavedChanges); - } - - // backup unsaved changes if configured to do so - if (creationOptions?.useSessionStorageIntegration) { - const reactEmbeddableChanges = unsavedPanelState ? { ...unsavedPanelState } : {}; - if (controlGroupChanges) { - reactEmbeddableChanges[PANELS_CONTROL_GROUP_KEY] = controlGroupChanges; - } - backupUnsavedChanges.bind(this)(dashboardChanges, reactEmbeddableChanges); - } - }) - ); -} - -/** - * Does a shallow diff between @param lastInput and @param input and - * @returns an object out of the keys which are different. - */ -export async function getDashboardUnsavedChanges( - this: DashboardContainer, - lastInput: DashboardContainerInput, - input: DashboardContainerInput -): Promise> { - const allKeys = [...new Set([...Object.keys(lastInput), ...Object.keys(input)])] as Array< - keyof DashboardContainerInput - >; - const keyComparePromises = allKeys.map( - (key) => - new Promise<{ key: keyof DashboardContainerInput; isEqual: boolean }>((resolve) => { - if (input[key] === undefined && lastInput[key] === undefined) { - resolve({ key, isEqual: true }); - } - isKeyEqualAsync( - key, - { - container: this, - - currentValue: input[key], - currentInput: input, - - lastValue: lastInput[key], - lastInput, - }, - unsavedChangesDiffingFunctions - ).then((isEqual) => resolve({ key, isEqual })); - }) - ); - const inputChanges = (await Promise.allSettled(keyComparePromises)).reduce((changes, current) => { - if (current.status === 'fulfilled') { - const { key, isEqual } = current.value; - if (!isEqual) (changes as { [key: string]: unknown })[key] = input[key]; - } - return changes; - }, {} as Partial); - return inputChanges; -} - -function backupUnsavedChanges( - this: DashboardContainer, - dashboardChanges: Partial, - reactEmbeddableChanges: UnsavedPanelState -) { - const dashboardStateToBackup = omit(dashboardChanges, keysToOmitFromSessionStorage); - - getDashboardBackupService().setState( - this.savedObjectId.value, - { - ...dashboardStateToBackup, - panels: dashboardChanges.panels, - }, - reactEmbeddableChanges - ); -} diff --git a/src/plugins/dashboard/public/dashboard_container/types.ts b/src/plugins/dashboard/public/dashboard_container/types.ts index cf307924e00fe..cf630902cbbd5 100644 --- a/src/plugins/dashboard/public/dashboard_container/types.ts +++ b/src/plugins/dashboard/public/dashboard_container/types.ts @@ -31,7 +31,7 @@ export type RedirectToProps = export type DashboardStateFromSaveModal = Pick< DashboardContainerInput, - 'title' | 'description' | 'tags' | 'timeRestore' | 'timeRange' | 'refreshInterval' + 'title' | 'description' | 'tags' | 'timeRestore' >; export type DashboardStateFromSettingsFlyout = DashboardStateFromSaveModal & DashboardOptions; diff --git a/src/plugins/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.test.tsx b/src/plugins/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.test.tsx index 0c3b4b583e8bf..75e46e0e23313 100644 --- a/src/plugins/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.test.tsx +++ b/src/plugins/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.test.tsx @@ -8,16 +8,19 @@ */ import React from 'react'; -import { BehaviorSubject } from 'rxjs'; import { render } from '@testing-library/react'; -import { buildMockDashboard } from '../mocks'; +import { buildMockDashboardApi } from '../mocks'; import { InternalDashboardTopNav } from './internal_dashboard_top_nav'; import { setMockedPresentationUtilServices } from '@kbn/presentation-util-plugin/public/mocks'; import { TopNavMenuProps } from '@kbn/navigation-plugin/public'; import { DashboardContext } from '../dashboard_api/use_dashboard_api'; -import { DashboardApi } from '../dashboard_api/types'; import { dataService, navigationService } from '../services/kibana_services'; +jest.mock('../dashboard_app/top_nav/dashboard_editing_toolbar', () => ({ + DashboardEditingToolbar: () => { + return
mockDashboardEditingToolbar
; + }, +})); describe('Internal dashboard top nav', () => { const mockTopNav = (badges: TopNavMenuProps['badges'] | undefined[]) => { if (badges) { @@ -41,7 +44,7 @@ describe('Internal dashboard top nav', () => { it('should not render the managed badge by default', async () => { const component = render( - + ); @@ -50,11 +53,11 @@ describe('Internal dashboard top nav', () => { }); it('should render the managed badge when the dashboard is managed', async () => { - const container = buildMockDashboard(); + const { api } = buildMockDashboardApi(); const dashboardApi = { - ...container, - managed$: new BehaviorSubject(true), - } as unknown as DashboardApi; + ...api, + isManaged: true, + }; const component = render( diff --git a/src/plugins/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx b/src/plugins/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx index 47a84b620ede3..c040a412c3c9e 100644 --- a/src/plugins/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx @@ -90,7 +90,6 @@ export function InternalDashboardTopNav({ hasRunMigrations, hasUnsavedChanges, lastSavedId, - managed, query, title, viewMode, @@ -101,7 +100,6 @@ export function InternalDashboardTopNav({ dashboardApi.hasRunMigrations$, dashboardApi.hasUnsavedChanges$, dashboardApi.savedObjectId, - dashboardApi.managed$, dashboardApi.query$, dashboardApi.panelTitle, dashboardApi.viewMode @@ -284,7 +282,7 @@ export function InternalDashboardTopNav({ } const { showWriteControls } = getDashboardCapabilities(); - if (showWriteControls && managed) { + if (showWriteControls && dashboardApi.isManaged) { const badgeProps = { ...getManagedContentBadge(dashboardManagedBadge.getBadgeAriaLabel()), onClick: () => setIsPopoverOpen(!isPopoverOpen), @@ -311,9 +309,7 @@ export function InternalDashboardTopNav({ { - dashboardApi - .runInteractiveSave(viewMode) - .then((result) => maybeRedirect(result)); + dashboardApi.runInteractiveSave().then((result) => maybeRedirect(result)); }} aria-label={dashboardManagedBadge.getDuplicateButtonAriaLabel()} > @@ -332,15 +328,7 @@ export function InternalDashboardTopNav({ }); } return allBadges; - }, [ - hasUnsavedChanges, - viewMode, - hasRunMigrations, - managed, - isPopoverOpen, - dashboardApi, - maybeRedirect, - ]); + }, [hasUnsavedChanges, viewMode, hasRunMigrations, isPopoverOpen, dashboardApi, maybeRedirect]); return (
diff --git a/src/plugins/dashboard/public/mocks.tsx b/src/plugins/dashboard/public/mocks.tsx index 2374788e60ea8..2f47c52f9660f 100644 --- a/src/plugins/dashboard/public/mocks.tsx +++ b/src/plugins/dashboard/public/mocks.tsx @@ -7,14 +7,14 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { EmbeddableInput, ViewMode } from '@kbn/embeddable-plugin/public'; -import { mockedReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public/mocks'; - import { ControlGroupApi } from '@kbn/controls-plugin/public'; import { BehaviorSubject } from 'rxjs'; -import { DashboardContainerInput, DashboardPanelState } from '../common'; -import { DashboardContainer } from './dashboard_container/embeddable/dashboard_container'; +import { ViewMode } from '@kbn/embeddable-plugin/public'; import { DashboardStart } from './plugin'; +import { DashboardState } from './dashboard_api/types'; +import { getDashboardApi } from './dashboard_api/get_dashboard_api'; +import { DashboardPanelState } from '../common'; +import { SavedDashboardInput } from './services/dashboard_content_management_service/types'; export type Start = jest.Mocked; @@ -78,37 +78,37 @@ export const mockControlGroupApi = { unsavedChanges: new BehaviorSubject(undefined), } as unknown as ControlGroupApi; -export function buildMockDashboard({ +export function buildMockDashboardApi({ overrides, savedObjectId, }: { - overrides?: Partial; + overrides?: Partial; savedObjectId?: string; } = {}) { - const initialInput = getSampleDashboardInput(overrides); - const dashboardContainer = new DashboardContainer( - initialInput, - mockedReduxEmbeddablePackage, - undefined, - undefined, - undefined, - undefined, - { - anyMigrationRun: false, - isEmbeddedExternally: false, - lastSavedInput: initialInput, - lastSavedId: savedObjectId, + const initialState = getSampleDashboardState(overrides); + const results = getDashboardApi({ + initialState, + savedObjectId, + savedObjectResult: { + dashboardFound: true, + newDashboardCreated: savedObjectId === undefined, + dashboardId: savedObjectId, managed: false, - fullScreenMode: false, - } - ); - dashboardContainer?.setControlGroupApi(mockControlGroupApi); - return dashboardContainer; + dashboardInput: { + ...initialState, + executionContext: { type: 'dashboard' }, + viewMode: initialState.viewMode as ViewMode, + id: savedObjectId ?? '123', + } as SavedDashboardInput, + anyMigrationRun: false, + references: [], + }, + }); + results.internalApi.setControlGroupApi(mockControlGroupApi); + return results; } -export function getSampleDashboardInput( - overrides?: Partial -): DashboardContainerInput { +export function getSampleDashboardState(overrides?: Partial): DashboardState { return { // options useMargins: true, @@ -117,7 +117,6 @@ export function getSampleDashboardInput( syncTooltips: false, hidePanelTitles: false, - id: '123', tags: [], filters: [], title: 'My Dashboard', @@ -130,17 +129,14 @@ export function getSampleDashboardInput( from: 'now-15m', }, timeRestore: false, - viewMode: ViewMode.VIEW, + viewMode: 'view', panels: {}, - executionContext: { - type: 'dashboard', - }, ...overrides, }; } -export function getSampleDashboardPanel( - overrides: Partial> & { +export function getSampleDashboardPanel( + overrides: Partial & { explicitInput: { id: string }; type: string; } diff --git a/src/plugins/dashboard/public/services/dashboard_backup_service.ts b/src/plugins/dashboard/public/services/dashboard_backup_service.ts index 5ffff35ff3d77..eab90df27b6a7 100644 --- a/src/plugins/dashboard/public/services/dashboard_backup_service.ts +++ b/src/plugins/dashboard/public/services/dashboard_backup_service.ts @@ -10,15 +10,15 @@ import { isEqual } from 'lodash'; import { firstValueFrom } from 'rxjs'; -import { ViewMode } from '@kbn/embeddable-plugin/public'; import { Storage } from '@kbn/kibana-utils-plugin/public'; import { set } from '@kbn/safer-lodash-set'; -import type { DashboardContainerInput } from '../../common'; +import { ViewMode } from '@kbn/presentation-publishing'; import { backupServiceStrings } from '../dashboard_container/_dashboard_container_strings'; import { UnsavedPanelState } from '../dashboard_container/types'; import { coreServices, spacesService } from './kibana_services'; -import { SavedDashboardInput } from './dashboard_content_management_service/types'; +import { DashboardState } from '../dashboard_api/types'; +import { DEFAULT_DASHBOARD_INPUT } from '../dashboard_constants'; export const DASHBOARD_PANELS_UNSAVED_ID = 'unsavedDashboard'; export const PANELS_CONTROL_GROUP_KEY = 'controlGroup'; @@ -32,13 +32,13 @@ interface DashboardBackupServiceType { clearState: (id?: string) => void; getState: (id: string | undefined) => | { - dashboardState?: Partial; + dashboardState?: Partial; panels?: UnsavedPanelState; } | undefined; setState: ( id: string | undefined, - dashboardState: Partial, + dashboardState: Partial, panels: UnsavedPanelState ) => void; getViewMode: () => ViewMode; @@ -67,7 +67,7 @@ class DashboardBackupService implements DashboardBackupServiceType { } public getViewMode = (): ViewMode => { - return this.localStorage.get(DASHBOARD_VIEWMODE_LOCAL_KEY); + return this.localStorage.get(DASHBOARD_VIEWMODE_LOCAL_KEY) ?? DEFAULT_DASHBOARD_INPUT.viewMode; }; public storeViewMode = (viewMode: ViewMode) => { @@ -112,7 +112,7 @@ class DashboardBackupService implements DashboardBackupServiceType { try { const dashboardState = this.sessionStorage.get(DASHBOARD_STATE_SESSION_KEY)?.[ this.activeSpaceId - ]?.[id] as Partial | undefined; + ]?.[id] as Partial | undefined; const panels = this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY)?.[this.activeSpaceId]?.[ id ] as UnsavedPanelState | undefined; @@ -128,7 +128,7 @@ class DashboardBackupService implements DashboardBackupServiceType { public setState( id = DASHBOARD_PANELS_UNSAVED_ID, - newState: Partial, + newState: Partial, unsavedPanels: UnsavedPanelState ) { try { @@ -159,7 +159,7 @@ class DashboardBackupService implements DashboardBackupServiceType { [...Object.keys(panelStatesInSpace), ...Object.keys(dashboardStatesInSpace)].map( (dashboardId) => { if ( - dashboardStatesInSpace[dashboardId].viewMode === ViewMode.EDIT && + dashboardStatesInSpace[dashboardId].viewMode === 'edit' && (Object.keys(dashboardStatesInSpace[dashboardId]).some( (stateKey) => stateKey !== 'viewMode' ) || diff --git a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/load_dashboard_state.test.ts b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/load_dashboard_state.test.ts index e03a078e0df45..b0c470de8812d 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/load_dashboard_state.test.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/load_dashboard_state.test.ts @@ -8,7 +8,6 @@ */ import { getDashboardContentManagementCache } from '..'; -import { getSampleDashboardInput } from '../../../mocks'; import { contentManagementService } from '../../kibana_services'; import { loadDashboardState } from './load_dashboard_state'; @@ -33,8 +32,7 @@ describe('Load dashboard state', () => { }); contentManagementService.client.get = jest.fn(); dashboardContentManagementCache.addDashboard = jest.fn(); - - const { id } = getSampleDashboardInput(); + const id = '123'; const result = await loadDashboardState({ id, }); @@ -61,9 +59,8 @@ describe('Load dashboard state', () => { }, }); }); - const { id } = getSampleDashboardInput(); await loadDashboardState({ - id, + id: '123', }); expect(dashboardContentManagementCache.fetchDashboard).toBeCalled(); expect(dashboardContentManagementCache.addDashboard).not.toBeCalled(); diff --git a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/migrate_dashboard_input.test.ts b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/migrate_dashboard_input.test.ts index ad0a5cc386b26..f0e715d624732 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/migrate_dashboard_input.test.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/migrate_dashboard_input.test.ts @@ -7,7 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { getSampleDashboardInput, getSampleDashboardPanel } from '../../../mocks'; +import { ViewMode } from '@kbn/embeddable-plugin/public'; +import { getSampleDashboardState, getSampleDashboardPanel } from '../../../mocks'; import { embeddableService } from '../../kibana_services'; import { SavedDashboardInput } from '../types'; import { migrateDashboardInput } from './migrate_dashboard_input'; @@ -23,7 +24,12 @@ jest.mock('@kbn/embeddable-plugin/public', () => { describe('Migrate dashboard input', () => { it('should run factory migrations on all Dashboard content', () => { - const dashboardInput: SavedDashboardInput = getSampleDashboardInput(); + const dashboardInput = { + ...getSampleDashboardState(), + id: '1', + viewMode: ViewMode.VIEW, + executionContext: { type: 'dashboard' }, + } as SavedDashboardInput; dashboardInput.panels = { panel1: getSampleDashboardPanel({ type: 'superLens', explicitInput: { id: 'panel1' } }), panel2: getSampleDashboardPanel({ type: 'superLens', explicitInput: { id: 'panel2' } }), diff --git a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/save_dashboard_state.test.ts b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/save_dashboard_state.test.ts index 7e35b0ec1c163..a1b18aca3aca0 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/save_dashboard_state.test.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/save_dashboard_state.test.ts @@ -8,7 +8,7 @@ */ import { DashboardContainerInput } from '../../../../common'; -import { getSampleDashboardInput } from '../../../mocks'; +import { getSampleDashboardState } from '../../../mocks'; import { contentManagementService, coreServices, @@ -48,7 +48,7 @@ describe('Save dashboard state', () => { it('should save the dashboard using the same ID', async () => { const result = await saveDashboardState({ currentState: { - ...getSampleDashboardInput(), + ...getSampleDashboardState(), title: 'BOO', } as unknown as DashboardContainerInput, lastSavedId: 'Boogaloo', @@ -69,7 +69,7 @@ describe('Save dashboard state', () => { it('should save the dashboard using a new id, and return redirect required', async () => { const result = await saveDashboardState({ currentState: { - ...getSampleDashboardInput(), + ...getSampleDashboardState(), title: 'BooToo', } as unknown as DashboardContainerInput, lastSavedId: 'Boogaloonie', @@ -93,7 +93,7 @@ describe('Save dashboard state', () => { it('should generate new panel IDs for dashboard panels when save as copy is true', async () => { const result = await saveDashboardState({ currentState: { - ...getSampleDashboardInput(), + ...getSampleDashboardState(), title: 'BooThree', panels: { aVerySpecialVeryUniqueId: { type: 'boop' } }, } as unknown as DashboardContainerInput, @@ -119,7 +119,7 @@ describe('Save dashboard state', () => { it('should update prefixes on references when save as copy is true', async () => { const result = await saveDashboardState({ currentState: { - ...getSampleDashboardInput(), + ...getSampleDashboardState(), title: 'BooFour', panels: { idOne: { type: 'boop' } }, } as unknown as DashboardContainerInput, @@ -147,7 +147,7 @@ describe('Save dashboard state', () => { contentManagementService.client.create = jest.fn().mockRejectedValue('Whoops'); const result = await saveDashboardState({ currentState: { - ...getSampleDashboardInput(), + ...getSampleDashboardState(), title: 'BooThree', panels: { idOne: { type: 'boop' } }, } as unknown as DashboardContainerInput, diff --git a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/save_dashboard_state.ts b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/save_dashboard_state.ts index 27e6a53da1f9a..58492f51f4d36 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/save_dashboard_state.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/save_dashboard_state.ts @@ -137,7 +137,7 @@ export const saveDashboardState = async ({ const rawDashboardAttributes: DashboardAttributes = { version: convertDashboardVersionToNumber(LATEST_DASHBOARD_CONTAINER_VERSION), - controlGroupInput, + controlGroupInput: controlGroupInput as DashboardAttributes['controlGroupInput'], kibanaSavedObjectMeta: { searchSource }, description: description ?? '', refreshInterval, diff --git a/src/plugins/dashboard/public/services/dashboard_content_management_service/types.ts b/src/plugins/dashboard/public/services/dashboard_content_management_service/types.ts index 0f4fe1c86a56d..e7dbcf1d230db 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management_service/types.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management_service/types.ts @@ -20,6 +20,7 @@ import { SearchDashboardsArgs, SearchDashboardsResponse, } from './lib/find_dashboards'; +import { DashboardState } from '../../dashboard_api/types'; export interface DashboardContentManagementService { findDashboards: FindDashboardsService; @@ -82,7 +83,7 @@ export type SavedDashboardSaveOpts = SavedObjectSaveOpts & { saveAsCopy?: boolea export interface SaveDashboardProps { controlGroupReferences?: Reference[]; - currentState: SavedDashboardInput; + currentState: DashboardState; saveOptions: SavedDashboardSaveOpts; panelReferences?: Reference[]; lastSavedId?: string; diff --git a/src/plugins/dashboard/tsconfig.json b/src/plugins/dashboard/tsconfig.json index 1bf6827433b66..3370a7fd85f83 100644 --- a/src/plugins/dashboard/tsconfig.json +++ b/src/plugins/dashboard/tsconfig.json @@ -81,7 +81,8 @@ "@kbn/core-custom-branding-browser-mocks", "@kbn/core-mount-utils-browser", "@kbn/visualization-utils", - "@kbn/core-rendering-browser", + "@kbn/std", + "@kbn/core-rendering-browser" ], "exclude": ["target/**/*"] } diff --git a/test/functional/apps/dashboard/group6/dashboard_esql_no_data.ts b/test/functional/apps/dashboard/group6/dashboard_esql_no_data.ts index 4298ccdfb5886..58fe8aa36d95f 100644 --- a/test/functional/apps/dashboard/group6/dashboard_esql_no_data.ts +++ b/test/functional/apps/dashboard/group6/dashboard_esql_no_data.ts @@ -15,7 +15,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const panelActions = getService('dashboardPanelActions'); const monacoEditor = getService('monacoEditor'); - const PageObjects = getPageObjects(['dashboard']); + const PageObjects = getPageObjects(['common', 'dashboard']); describe('No Data Views: Try ES|QL', () => { before(async () => { @@ -26,7 +26,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.navigateToApp(); await testSubjects.existOrFail('noDataViewsPrompt'); + await testSubjects.click('tryESQLLink'); + await PageObjects.dashboard.waitForRenderComplete(); await PageObjects.dashboard.expectOnDashboard('New Dashboard'); expect(await testSubjects.exists('lnsVisualizationContainer')).to.be(true); diff --git a/test/functional/apps/dashboard/group6/view_edit.ts b/test/functional/apps/dashboard/group6/view_edit.ts index 9304b51d302d5..220611742d51c 100644 --- a/test/functional/apps/dashboard/group6/view_edit.ts +++ b/test/functional/apps/dashboard/group6/view_edit.ts @@ -22,6 +22,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'timePicker', ]); const dashboardName = 'dashboard with filter'; + const copyOfDashboardName = `Copy of ${dashboardName}`; const filterBar = getService('filterBar'); const security = getService('security'); @@ -73,7 +74,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('save as new', () => { it('keeps duplicated dashboard in edit mode', async () => { await dashboard.gotoDashboardEditMode(dashboardName); - await dashboard.duplicateDashboard('edit'); + await dashboard.duplicateDashboard(copyOfDashboardName); const isViewMode = await dashboard.getIsInViewMode(); expect(isViewMode).to.equal(false); }); @@ -81,8 +82,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('save', function () { it('keeps dashboard in edit mode', async function () { - await dashboard.gotoDashboardEditMode(dashboardName); - await dashboard.saveDashboard(dashboardName, { + await dashboard.gotoDashboardEditMode(copyOfDashboardName); + // change dashboard time to cause unsaved change + await timePicker.setAbsoluteRange( + 'Sep 19, 2013 @ 00:00:00.000', + 'Sep 19, 2013 @ 07:00:00.000' + ); + await dashboard.saveDashboard(copyOfDashboardName, { storeTimeWithDashboard: true, saveAsNew: false, }); diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index 81298783701e2..79b8cc8c74439 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -1463,8 +1463,6 @@ "dashboard.emptyScreen.noPermissionsTitle": "Ce tableau de bord est vide.", "dashboard.emptyScreen.viewModeSubtitle": "Accédez au mode de modification, puis commencez à ajouter vos visualisations.", "dashboard.emptyScreen.viewModeTitle": "Ajouter des visualisations à votre tableau de bord", - "dashboard.factory.displayName": "Dashboard", - "dashboard.factory.displayNameLowercase": "tableau de bord", "dashboard.featureCatalogue.dashboardDescription": "Affichez et partagez une collection de visualisations et de recherches enregistrées.", "dashboard.featureCatalogue.dashboardSubtitle": "Analysez des données à l’aide de tableaux de bord.", "dashboard.featureCatalogue.dashboardTitle": "Dashboard", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index ca80aaa127e32..52c4a1bf5b8c9 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -1463,8 +1463,6 @@ "dashboard.emptyScreen.noPermissionsTitle": "このダッシュボードは空です。", "dashboard.emptyScreen.viewModeSubtitle": "編集モードに切り替えて、ビジュアライゼーションの追加を開始します。", "dashboard.emptyScreen.viewModeTitle": "ダッシュボードにビジュアライゼーションを追加", - "dashboard.factory.displayName": "ダッシュボード", - "dashboard.factory.displayNameLowercase": "ダッシュボード", "dashboard.featureCatalogue.dashboardDescription": "ビジュアライゼーションと保存された検索のコレクションの表示と共有を行います。", "dashboard.featureCatalogue.dashboardSubtitle": "ダッシュボードでデータを分析します。", "dashboard.featureCatalogue.dashboardTitle": "ダッシュボード", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index 31eb102ba6780..c7b9faa1dd4b9 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -1476,8 +1476,6 @@ "dashboard.emptyScreen.noPermissionsTitle": "此仪表板是空的。", "dashboard.emptyScreen.viewModeSubtitle": "进入编辑模式,然后开始添加可视化。", "dashboard.emptyScreen.viewModeTitle": "将可视化添加到仪表板", - "dashboard.factory.displayName": "仪表板", - "dashboard.factory.displayNameLowercase": "仪表板", "dashboard.featureCatalogue.dashboardDescription": "显示和共享可视化和已保存搜索的集合。", "dashboard.featureCatalogue.dashboardSubtitle": "在仪表板中分析数据。", "dashboard.featureCatalogue.dashboardTitle": "仪表板",