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 <elasticmachine@users.noreply.github.com>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Hannah Mudge <Heenawter@users.noreply.github.com>
Co-authored-by: Devon Thomson <devon.thomson@elastic.co>
This commit is contained in:
Nathan Reese 2024-12-10 08:34:04 -07:00 committed by GitHub
parent 8477dc7af4
commit 101e797e9d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
82 changed files with 2474 additions and 5216 deletions

View file

@ -29,7 +29,6 @@ export {
export {
canTrackContentfulRender,
type TrackContentfulRender,
type TracksQueryPerformance,
} from './interfaces/performance_trackers';
export {
apiIsPresentationContainer,

View file

@ -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;
}

View file

@ -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

View file

@ -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,

View file

@ -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<void>;
reload$: Omit<Observable<void>, 'next'>;
}
export const apiPublishesReload = (unknownApi: null | unknown): unknownApi is PublishesReload => {

View file

@ -39,9 +39,15 @@ export const initializeTitles = (
const panelDescription = new BehaviorSubject<string | undefined>(rawState.description);
const hidePanelTitle = new BehaviorSubject<boolean | undefined>(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<SerializedTitles> = {
title: [panelTitle, setPanelTitle],

View file

@ -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;
};

View file

@ -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<boolean | undefined>(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();
},
};
}

View file

@ -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<ControlGroupApi | undefined>,
children$: PublishingSubject<{ [key: string]: unknown }>
) {
const dataViews = new BehaviorSubject<DataView[] | undefined>([]);
const controlGroupDataViewsPipe: Observable<DataView[] | undefined> = controlGroupApi$.pipe(
switchMap((controlGroupApi) => {
return controlGroupApi ? controlGroupApi.dataViews : of([]);
})
);
const childDataViewsPipe = combineCompatibleChildrenApis<PublishesDataViews, DataView[]>(
{ 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();
},
};
}

View file

@ -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';
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 interface InitialComponentState {
anyMigrationRun: boolean;
isEmbeddedExternally: boolean;
lastSavedInput: DashboardContainerInput;
lastSavedId: string | undefined;
managed: boolean;
fullScreenMode: boolean;
}
export function getDashboardApi(
initialComponentState: InitialComponentState,
untilEmbeddableLoaded: (id: string) => Promise<unknown>
) {
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<string | undefined>(initialComponentState.lastSavedId);
const controlGroupApi$ = new BehaviorSubject<ControlGroupApi | undefined>(undefined);
const fullScreenMode$ = new BehaviorSubject(creationOptions?.fullScreenMode ?? false);
const isManaged = savedObjectResult?.managed ?? false;
let references: Reference[] = savedObjectResult?.references ?? [];
const savedObjectId$ = new BehaviorSubject<string | undefined>(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,
};
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,
...unifiedSearchManager.api,
...unsavedChangesManager.api,
...trackOverlayApi,
...initializeTrackContentfulRender(),
controlGroupApi$,
executionContext: {
type: 'dashboard',
description: settingsManager.api.panelTitle.value,
},
fullScreenMode$,
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$,
setFullScreenMode: (fullScreenMode: boolean) => fullScreenMode$.next(fullScreenMode),
setSavedObjectId: (id: string | undefined) => savedObjectId$.next(id),
type: DASHBOARD_API_TYPE as 'dashboard',
uuid: v4(),
} as Omit<DashboardApi, 'searchSessionId$'>;
const searchSessionManager = initializeSearchSessionManager(
creationOptions?.searchSessionSettings,
incomingEmbeddable,
dashboardApi
);
return {
...trackPanel,
...initializeTrackOverlay(trackPanel.setFocusedPanelId),
...initializeUnsavedChanges(
initialComponentState.anyMigrationRun,
initialComponentState.lastSavedInput
),
animatePanelTransforms$,
fullScreenMode$,
isEmbeddedExternally: initialComponentState.isEmbeddedExternally,
managed$,
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),
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();
},
};
}

View file

@ -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<DashboardCreationOptions>;
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<DashboardState> | 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,
};
}

View file

@ -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<SaveDashboardReturn> => {
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(
<DashboardSaveModal
tags={dashboardState.tags}
title={saveAsTitle}
onClose={() => 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;
}

View file

@ -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<typeof initializeTrackPanel>,
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<ApiType>(id: string): Promise<ApiType | undefined> {
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<string[]> {
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 <ApiType extends unknown = unknown>(
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<ApiType>(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<Pick<DashboardState, 'panels'>>,
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<object> }>
> = [];
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})`;
}

View file

@ -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<DashboardApi, 'searchSessionId$'>
) {
const searchSessionId$ = new BehaviorSubject<string | undefined>(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?.();
},
};
}

View file

@ -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<boolean | undefined>;
}) {
const syncColors$ = new BehaviorSubject<boolean>(
initialState?.syncColors ?? DEFAULT_DASHBOARD_INPUT.syncColors
);
function setSyncColors(syncColors: boolean) {
if (syncColors !== syncColors$.value) syncColors$.next(syncColors);
}
const syncCursor$ = new BehaviorSubject<boolean>(
initialState?.syncCursor ?? DEFAULT_DASHBOARD_INPUT.syncCursor
);
function setSyncCursor(syncCursor: boolean) {
if (syncCursor !== syncCursor$.value) syncCursor$.next(syncCursor);
}
const syncTooltips$ = new BehaviorSubject<boolean>(
initialState?.syncTooltips ?? DEFAULT_DASHBOARD_INPUT.syncTooltips
);
function setSyncTooltips(syncTooltips: boolean) {
if (syncTooltips !== syncTooltips$.value) syncTooltips$.next(syncTooltips);
}
const tags$ = new BehaviorSubject<string[]>(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<boolean>(
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);
},
},
};
}

View file

@ -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;
},
};
}

View file

@ -9,7 +9,7 @@
import { BehaviorSubject } from 'rxjs';
export function initializeTrackPanel(untilEmbeddableLoaded: (id: string) => Promise<unknown>) {
export function initializeTrackPanel(untilEmbeddableLoaded: (id: string) => Promise<undefined>) {
const expandedPanelId$ = new BehaviorSubject<string | undefined>(undefined);
const focusedPanelId$ = new BehaviorSubject<string | undefined>(undefined);
const highlightPanelId$ = new BehaviorSubject<string | undefined>(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);

View file

@ -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<SavedDashboardInput>;
getInitialInput?: () => Partial<DashboardState>;
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<ControlGroupRuntimeState>;
}
export type DashboardApi = CanExpandPanels &
HasAppContext &
HasExecutionContext &
HasRuntimeChildState &
HasSaveNotification &
HasSerializedChildState &
HasType<'dashboard'> &
HasType<typeof DASHBOARD_API_TYPE> &
HasUniqueId &
PresentationContainer &
PublishesDataLoading &
PublishesDataViews &
PublishesPanelDescription &
Pick<PublishesPanelTitle, 'panelTitle'> &
PublishesReload &
PublishesSavedObjectId &
PublishesSearchSession &
PublishesSettings &
PublishesUnifiedSearch &
PublishesViewMode &
PublishesWritableViewMode &
TrackContentfulRender &
TracksOverlays & {
addFromLibrary: () => void;
animatePanelTransforms$: PublishingSubject<boolean>;
asyncResetToLastSavedState: () => Promise<void>;
controlGroupApi$: PublishingSubject<ControlGroupApi | undefined>;
fullScreenMode$: PublishingSubject<boolean>;
focusedPanelId$: PublishingSubject<string | undefined>;
forceRefresh: () => void;
getRuntimeStateForControlGroup: () => UnsavedPanelState | undefined;
getSerializedStateForControlGroup: () => SerializedPanelState<ControlGroupSerializedState>;
getSettings: () => DashboardStateFromSettingsFlyout;
getDashboardPanelFromId: (id: string) => Promise<DashboardPanelState>;
hasOverlays$: PublishingSubject<boolean>;
@ -104,27 +160,35 @@ export type DashboardApi = CanExpandPanels &
highlightPanel: (panelRef: HTMLDivElement) => void;
highlightPanelId$: PublishingSubject<string | undefined>;
isEmbeddedExternally: boolean;
managed$: PublishingSubject<boolean>;
isManaged: boolean;
locator?: Pick<LocatorPublic<DashboardLocatorParams>, 'navigate' | 'getRedirectUrl'>;
panels$: PublishingSubject<DashboardPanelMap>;
registerChildApi: (api: DefaultEmbeddableApi) => void;
runInteractiveSave: (interactionMode: ViewMode) => Promise<SaveDashboardReturn | undefined>;
runInteractiveSave: () => Promise<SaveDashboardReturn | undefined>;
runQuickSave: () => Promise<void>;
scrollToPanel: (panelRef: HTMLDivElement) => void;
scrollToPanelId$: PublishingSubject<string | undefined>;
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<boolean | undefined>;
// TODO replace with HasUniqueId once dashboard is refactored and navigateToDashboard is removed
uuid$: PublishingSubject<string>;
unifiedSearchFilters$: PublishesUnifiedSearch['filters$'];
// TODO remove types below this line - from legacy embeddable system
untilEmbeddableLoaded: (id: string) => Promise<IEmbeddable | ErrorEmbeddable>;
};
export interface DashboardInternalApi {
animatePanelTransforms$: PublishingSubject<boolean>;
controlGroupReload$: Subject<void>;
panelsReload$: Subject<void>;
getRuntimeStateForControlGroup: () => object | undefined;
getSerializedStateForControlGroup: () => SerializedPanelState<ControlGroupSerializedState>;
registerChildApi: (api: DefaultEmbeddableApi) => void;
setControlGroupApi: (controlGroupApi: ControlGroupApi) => void;
}

View file

@ -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<ControlGroupApi | undefined>,
waitForPanelsToLoad$: Observable<void>,
getLastSavedState: () => DashboardState | undefined,
creationOptions?: DashboardCreationOptions
) {
const {
queryString,
filterManager,
timefilter: { timefilter: timefilterService },
} = dataService.query;
const controlGroupReload$ = new Subject<void>();
const filters$ = new BehaviorSubject<Filter[] | undefined>(undefined);
const panelsReload$ = new Subject<void>();
const query$ = new BehaviorSubject<Query | undefined>(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<RefreshInterval | undefined>(
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<TimeRange | undefined>(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<boolean | undefined>(
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<Filter[] | undefined>(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<GlobalQueryStateFromUrl>(
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<GlobalQueryStateFromUrl>(
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<GlobalQueryStateFromUrl>(
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<DashboardState, 'filters' | 'query' | 'refreshInterval' | 'timeRange' | 'timeRestore'>
>,
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);
};

View file

@ -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<DashboardContainerInput>(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);
},
};
}

View file

@ -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<ControlGroupApi | undefined>;
lastSavedState: DashboardState;
panelsManager: ReturnType<typeof initializePanelsManager>;
savedObjectId$: PublishesSavedObjectId['savedObjectId'];
settingsManager: ReturnType<typeof initializeSettingsManager>;
viewModeManager: ReturnType<typeof initializeViewModeManager>;
unifiedSearchManager: ReturnType<typeof initializeUnifiedSearchManager>;
}) {
const hasRunMigrations$ = new BehaviorSubject(anyMigrationRun);
const hasUnsavedChanges$ = new BehaviorSubject(false);
const lastSavedState$ = new BehaviorSubject<DashboardState>(lastSavedState);
const saveNotification$ = new Subject<void>();
const dashboardUnsavedChanges = initializeUnsavedChanges<
Omit<DashboardState, 'controlGroupInput' | 'controlGroupState' | 'timeslice' | 'tags'>
>(
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();
},
},
};
}

View file

@ -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<DashboardInternalApi | undefined>(undefined);
export const useDashboardInternalApi = (): DashboardInternalApi => {
const internalApi = useContext<DashboardInternalApi | undefined>(DashboardInternalContext);
if (!internalApi) {
throw new Error('useDashboardInternalApi must be used inside DashboardContext');
}
return internalApi;
};

View file

@ -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<ViewMode>(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<Pick<DashboardState, 'viewMode'>>,
};
}

View file

@ -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 <div>Test renderer</div>;
@ -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(<DashboardApp redirectTo={jest.fn()} history={mockHistory} />);
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');
});

View file

@ -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<boolean>(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<DashboardApi | undefined>(undefined);
const showPlainSpinner = useObservable(coreServices.customBranding.hasCustomBranding$, false);
@ -138,8 +160,7 @@ export function DashboardApp({
};
return Promise.resolve<DashboardCreationOptions>({
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;

View file

@ -104,6 +104,7 @@ export async function mountApp({
}
return (
<DashboardApp
key={routeProps.match.params.id ?? 'newDashboard'}
history={routeProps.history}
embedSettings={globalEmbedSettings}
savedDashboardId={routeProps.match.params.id}

View file

@ -21,7 +21,6 @@ import { withSuspense } from '@kbn/shared-ux-utility';
import type { TypedLensByValueInput } from '@kbn/lens-plugin/public';
import { getLensAttributesFromSuggestion } from '@kbn/visualization-utils';
import { DASHBOARD_APP_ID } from '../../dashboard_constants';
import {
coreServices,
dataService,
@ -153,15 +152,8 @@ export const DashboardAppNoDataPage = ({
export const isDashboardAppInNoDataState = async () => {
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;

View file

@ -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 = [
<EditorMenu createNewVisType={createNewVisType} isDisabled={isDisabled} />,
<AddFromLibraryButton
onClick={() => dashboardApi.addFromLibrary()}
onClick={() => addFromLibrary(dashboardApi)}
size="s"
data-test-subj="dashboardAddFromLibraryButton"
isDisabled={isDisabled}

View file

@ -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(<EditorMenu createNewVisType={jest.fn()} />, {
wrapper: ({ children }) => {
return (
<DashboardContext.Provider value={buildMockDashboard() as DashboardApi}>
{children}
</DashboardContext.Provider>
);
return <DashboardContext.Provider value={api}>{children}</DashboardContext.Provider>;
},
});
});

View file

@ -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] : [];

View file

@ -51,7 +51,7 @@ export function createSessionRestorationDataProvider(
): SearchSessionInfoProvider<DashboardLocatorParams> {
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 }),

View file

@ -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>(viewMode),
} as unknown as DashboardApi;
return mountWithIntl(
<DashboardContext.Provider value={dashboardApi}>
<DashboardContext.Provider value={mockDashboardApi}>
<DashboardEmptyScreen />
</DashboardContext.Provider>
);
}
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');

View file

@ -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() {
<EuiButtonEmpty
flush="left"
iconType="folderOpen"
onClick={() => dashboardApi.addFromLibrary()}
onClick={() => addFromLibrary(dashboardApi)}
>
{emptyScreenStrings.getAddFromLibraryButtonTitle()}
</EuiButtonEmpty>

View file

@ -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(
<DashboardContext.Provider value={dashboardContainer as DashboardApi}>
<DashboardGrid viewportWidth={1000} />
<DashboardContext.Provider value={api}>
<DashboardInternalContext.Provider value={internalApi}>
<DashboardGrid viewportWidth={1000} />
</DashboardInternalContext.Provider>
</DashboardContext.Provider>
);
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);

View file

@ -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);
}
},

View file

@ -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 (
<div className="embedPanel" id={`mockEmbedPanel_${props.id}`}>
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(
<DashboardContext.Provider value={dashboardApi}>
<Item {...props} />
<DashboardContext.Provider value={api}>
<DashboardInternalContext.Provider value={internalApi}>
<Item {...props} />
</DashboardInternalContext.Provider>
</DashboardContext.Provider>
);
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',
});

View file

@ -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<React.HTMLAttributes<HTMLDivElement>, 'className' | 'style' | 'children'>;
@ -54,10 +55,11 @@ export const Item = React.forwardRef<HTMLDivElement, Props>(
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<HTMLDivElement, Props>(
showShadow: false,
};
// render React embeddable
if (embeddableService.reactEmbeddableRegistryHasKey(type)) {
return (
<ReactEmbeddableRenderer
type={type}
maybeId={id}
getParentApi={() => dashboardApi}
key={`${type}_${id}`}
panelProps={panelProps}
onApiAvailable={(api) => dashboardApi.registerChildApi(api)}
/>
);
}
// render legacy embeddable
return (
<EmbeddablePanel
key={type}
index={index}
embeddable={() => dashboardApi.untilEmbeddableLoaded(id)}
{...panelProps}
<ReactEmbeddableRenderer
type={type}
maybeId={id}
getParentApi={() => ({
...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 (
<div

View file

@ -25,6 +25,7 @@ import { CONTROL_GROUP_TYPE } from '@kbn/controls-plugin/common';
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
import { DashboardGrid } from '../grid';
import { useDashboardApi } from '../../../dashboard_api/use_dashboard_api';
import { useDashboardInternalApi } from '../../../dashboard_api/use_dashboard_internal_api';
import { DashboardEmptyScreen } from '../empty_screen/dashboard_empty_screen';
export const useDebouncedWidthObserver = (skipDebounce = false, wait = 100) => {
@ -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)}
/>
</div>
) : null}

View file

@ -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();
},
})
);

View file

@ -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<ContactCardEmbeddableInput>({
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 <Api extends DefaultEmbeddableApi>(
testType: string,
mockApi: BuildReactEmbeddableApiRegistration<{}, {}, Api>
) => {
const fullApi$ = new Subject<Api & HasSnapshottableState<{}>>();
const reactEmbeddableFactory: ReactEmbeddableFactory<{}, {}, Api> = {
type: testType,
deserializeState: jest.fn().mockImplementation((state) => state.rawState),
buildEmbeddable: async (state, registerApi) => {
const fullApi = registerApi(
{
...mockApi,
},
{}
);
return {
Component: () => <div> TEST DUPLICATE </div>,
api: fullApi,
};
},
};
registerReactEmbeddableFactory(testType, async () => reactEmbeddableFactory);
const dashboard = buildMockDashboard({
overrides: {
panels: {
[testId]: getSampleDashboardPanel<ContactCardEmbeddableInput>({
explicitInput: { id: testId },
type: testType,
}),
},
},
});
// render a fake Dashboard to initialize react embeddables
const FakeDashboard = () => {
return (
<div>
{Object.keys(dashboard.getInput().panels).map((panelId) => {
const panel = dashboard.getInput().panels[panelId];
return (
<div style={{ width: '100%', height: '100px' }} key={panelId}>
<ReactEmbeddableRenderer
type={panel.type}
onApiAvailable={(api) => {
fullApi$.next(api as Api & HasSnapshottableState<{}>);
fullApi$.complete();
dashboard.children$.next({ [panelId]: api });
}}
getParentApi={() => ({
getSerializedStateForChild: () =>
panel.explicitInput as unknown as SerializedPanelState<object> | undefined,
})}
/>
</div>
);
})}
</div>
);
};
render(<FakeDashboard />);
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<string | undefined>(''),
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();
});
});

View file

@ -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})`;
};

View file

@ -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';

View file

@ -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<EEI, EEO> = IEmbeddable<EEI, EEO>
>(this: DashboardContainer, type: string, explicitInput: Partial<EEI>, 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<EEI, EEO, E>(type, explicitInput);
}

View file

@ -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<object> }>
> = [];
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<SaveDashboardReturn | undefined>((resolve, reject) => {
if (interactionMode === ViewMode.EDIT && managed) {
resolve(undefined);
}
const onSaveAttempt = async ({
newTags,
newTitle,
newDescription,
newCopyOnSave,
newTimeRestore,
onTitleDuplicate,
isTitleDuplicateConfirmed,
}: DashboardSaveOptions): Promise<SaveDashboardReturn> => {
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 = (
<DashboardSaveModal
tags={currentState.tags}
title={newTitle}
onClose={() => resolve(undefined)}
timeRestore={currentState.timeRestore}
showStoreTimeOnSave={!lastSavedId}
description={currentState.description ?? ''}
showCopyOnSave={false}
onSave={onSaveAttempt}
customModalTitle={customModalTitle}
/>
);
this.clearOverlays();
showSaveModal(dashboardDuplicateModal);
} catch (error) {
reject(error);
}
})();
});
}

View file

@ -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<Filter[] | undefined>([]),
};
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<Filter[] | undefined>(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<Filter[] | undefined>(controlGroupFilters),
};
const combinedFilters = combineDashboardFiltersWithControlGroupFilters(
dashboardFilterPills,
mockControlGroupApi
);
expect(combinedFilters).toEqual(dashboardFilterPills.concat(controlGroupFilters));
});
});

View file

@ -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<Filter[] | undefined>(
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<Filter[] | undefined>(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 ?? [])];
};

View file

@ -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<ContactCardEmbeddableInput>({
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<ContactCardEmbeddableInput>({
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<ContactCardEmbeddableInput>({
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}`);
});

View file

@ -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<DashboardContainer | undefined> => {
// --------------------------------------------------------------------------------------
// Create method which allows work to be done on the dashboard container when it's ready.
// --------------------------------------------------------------------------------------
const dashboardContainerReady$ = new Subject<DashboardContainer>();
const untilDashboardReady = () =>
new Promise<DashboardContainer>((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<DashboardContainer>;
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<SavedDashboardInput> | 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<GlobalQueryStateFromUrl>(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 };
};

View file

@ -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<DataView[] | undefined> = this.controlGroupApi$.pipe(
switchMap((controlGroupApi) => {
return controlGroupApi ? controlGroupApi.dataViews : of([]);
})
);
const childDataViewsPipe = combineCompatibleChildrenApis<PublishesDataViews, DataView[]>(
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);
});
}

View file

@ -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<PhaseEvent>({ 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<PhaseEvent>({ 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<PhaseEvent>({ 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<PhaseEvent>({ 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 = {

View file

@ -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,

View file

@ -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<Observable<unknown>> = [];
@ -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));

View file

@ -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();
};
}

View file

@ -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<GlobalQueryStateFromUrl>(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<GlobalQueryStateFromUrl>(
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;
}

View file

@ -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<ContactCardEmbeddableInput>({
explicitInput: { firstName: 'Sam', id: '123' },
type: CONTACT_CARD_EMBEDDABLE,
}),
},
},
});
const subscription = container.getOutput$().subscribe((output) => {
if (container.getOutput().embeddableLoaded['123']) {
const embeddable = container.getChild<ContactCardEmbeddable>('123');
expect(embeddable).toBeDefined();
expect(embeddable.id).toBe('123');
done();
}
});
if (container.getOutput().embeddableLoaded['123']) {
const embeddable = container.getChild<ContactCardEmbeddable>('123');
expect(embeddable).toBeDefined();
expect(embeddable.id).toBe('123');
subscription.unsubscribe();
done();
}
});
test('DashboardContainer.addNewEmbeddable', async () => {
const container = buildMockDashboard();
const embeddable = await container.addNewEmbeddable<ContactCardEmbeddableInput>(
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<ContactCardEmbeddable>(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<ContactCardEmbeddableInput>({
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<ContactCardEmbeddableInput>({
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<ContactCardEmbeddableInput>(
CONTACT_CARD_EMBEDDABLE,
{
firstName: 'Kibana',
}
);
expect(embeddable).toBeDefined();
const embeddableInput = container
.getChild<ContactCardEmbeddable>(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<ContactCardEmbeddable>(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<ContactCardEmbeddableInput>(
CONTACT_CARD_EMBEDDABLE,
{
firstName: 'Kibana',
}
);
expect(embeddable).toBeDefined();
const embeddableInput = container
.getChild<ContactCardEmbeddable>(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);
});
});

View file

@ -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<InheritedChildInput, DashboardContainerInput>
implements
TrackContentfulRender,
TracksQueryPerformance,
HasSaveNotification,
HasRuntimeChildState,
HasSerializedChildState,
PublishesSettings,
Partial<PublishesViewMode>
{
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<null> = 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<DashboardContainerInput>;
public setSavedObjectId: (id: string | undefined) => void;
public expandPanel: (panelId: string) => void;
public scrollToPanel: (panelRef: HTMLDivElement) => Promise<void>;
public scrollToTop: () => void;
public integrationSubscriptions: Subscription = new Subscription();
public publishingSubscription: Subscription = new Subscription();
public diffingSubscription: Subscription = new Subscription();
public controlGroupApi$: PublishingSubject<ControlGroupApi | undefined>;
public settings: Record<string, PublishingSubject<boolean | undefined>>;
public searchSessionId?: string;
public lastReloadRequestTime$ = new BehaviorSubject<string | undefined>(undefined);
public searchSessionId$ = new BehaviorSubject<string | undefined>(undefined);
public reload$ = new Subject<void>();
public timeRestore$: BehaviorSubject<boolean | undefined>;
public timeslice$: BehaviorSubject<[number, number] | undefined>;
public unifiedSearchFilters$?: PublishingSubject<Filter[] | undefined>;
public locator?: Pick<LocatorPublic<DashboardLocatorParams>, '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<void>;
// 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<ControlGroupApi | undefined>(undefined);
async function untilContainerInitialized(): Promise<void> {
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<string>(
this.publishingSubscription,
this,
'id'
) as BehaviorSubject<string>;
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<boolean | undefined, DashboardContainerInput>(
this.publishingSubscription,
this,
'syncColors'
),
syncCursor$: embeddableInputToSubject<boolean | undefined, DashboardContainerInput>(
this.publishingSubscription,
this,
'syncCursor'
),
syncTooltips$: embeddableInputToSubject<boolean | undefined, DashboardContainerInput>(
this.publishingSubscription,
this,
'syncTooltips'
),
};
this.timeRestore$ = embeddableInputToSubject<boolean | undefined, DashboardContainerInput>(
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<boolean | undefined>(false);
this.publishingSubscription.add(
combineCompatibleChildrenApis<PublishesDataLoading, boolean | undefined>(
this,
'dataLoading',
apiPublishesDataLoading,
undefined,
// flatten method
(values) => {
return values.some((isLoading) => isLoading);
}
).subscribe((isAtLeastOneChildLoading) => {
(this.dataLoading as BehaviorSubject<boolean | undefined>).next(isAtLeastOneChildLoading);
})
);
this.dataViews = new BehaviorSubject<DataView[] | undefined>([]);
const query$ = new BehaviorSubject<Query | AggregateQuery | undefined>(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<ControlGroupApi | undefined>).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<TEmbeddableInput, any>
>(
factory: EmbeddableFactory<TEmbeddableInput, any, TEmbeddable>,
partial: Partial<TEmbeddableInput> = {},
attributes?: unknown
): {
newPanel: DashboardPanelState<TEmbeddableInput>;
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(
<KibanaRenderContextProvider
analytics={coreServices.analytics}
i18n={coreServices.i18n}
theme={coreServices.theme}
>
<ExitFullScreenButtonKibanaProvider
coreStart={{ chrome: coreServices.chrome, customBranding: coreServices.customBranding }}
>
<DashboardContext.Provider value={this as DashboardApi}>
<DashboardViewport dashboardContainer={this.domNode} />
</DashboardContext.Provider>
</ExitFullScreenButtonKibanaProvider>
</KibanaRenderContextProvider>,
dom
);
}
public updateInput(changes: Partial<DashboardContainerInput>): 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<InheritedChildInput>)?.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<string | undefined>;
public expandedPanelId: BehaviorSubject<string | undefined>;
public focusedPanelId$: BehaviorSubject<string | undefined>;
public managed$: BehaviorSubject<boolean>;
public fullScreenMode$: BehaviorSubject<boolean>;
public hasRunMigrations$: BehaviorSubject<boolean>;
public hasUnsavedChanges$: BehaviorSubject<boolean>;
public hasOverlays$: BehaviorSubject<boolean>;
public useMargins$: BehaviorSubject<boolean>;
public scrollToPanelId$: BehaviorSubject<string | undefined>;
public highlightPanelId$: BehaviorSubject<string | undefined>;
public animatePanelTransforms$: BehaviorSubject<boolean>;
public panels$: BehaviorSubject<DashboardPanelMap>;
public isEmbeddedExternally: boolean;
public uuid$: BehaviorSubject<string>;
public async replacePanel(idToRemove: string, { panelType, initialState }: PanelPackage) {
const newId = await this.replaceEmbeddable(
idToRemove,
initialState as Partial<EmbeddableInput>,
panelType,
true
);
if (this.expandedPanelId.value !== undefined) {
this.setExpandedPanelId(newId);
}
this.setHighlightPanelId(newId);
return newId;
}
public async addNewPanel<ApiType extends unknown = unknown>(
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<ApiType>(newId);
}
const embeddableFactory = embeddableService.getEmbeddableFactory(panelPackage.panelType);
if (!embeddableFactory) {
throw new EmbeddableFactoryNotFoundError(panelPackage.panelType);
}
const initialInput = panelPackage.initialState as Partial<EmbeddableInput>;
let explicitInput: Partial<EmbeddableInput>;
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<DashboardCreationOptions>
) => {
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<DashboardContainer>();
const untilDashboardReady = () =>
new Promise<DashboardContainer>((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<DataView[] | undefined>).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<string[]> {
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<EmbeddableInput, EmbeddableOutput> = 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<void> = new Subject<void>();
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);
};
}

View file

@ -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<DashboardContainerInput, ContainerOutput, DashboardContainer>
{
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<DashboardContainerInput> {
return DEFAULT_DASHBOARD_INPUT;
}
public create = async (
initialInput: DashboardContainerInput,
parent?: Container,
creationOptions?: DashboardCreationOptions,
savedObjectId?: string
): Promise<DashboardContainer | ErrorEmbeddable | undefined> => {
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 });
}
};
}

View file

@ -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(<DashboardRenderer />);
});
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(
<DashboardRenderer
savedObjectId="saved_object_kibanana"
getCreationOptions={() => 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(<DashboardRenderer savedObjectId="saved_object_kibanana" />);
});
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(<DashboardRenderer savedObjectId="saved_object_kibanana" />);
});
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(<DashboardRenderer savedObjectId="saved_object_kibanana" />);
});
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(<DashboardRenderer savedObjectId="saved_object_kibanana" />);
});
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(<DashboardRenderer savedObjectId="saved_object_kibanana" />);
});
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(<DashboardRenderer savedObjectId="saved_object_kibanana" />);
});
// 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(
<div id="superParent">
<DashboardRenderer />
</div>
);
});
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(
<div id="superParent">
<DashboardRenderer savedObjectId="saved_object_kibanana" />
</div>
);
});
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(
<div id="superParent">
<DashboardRenderer savedObjectId="saved_object_kibanana" />
</div>
);
});
expect(
wrapper!
.find('#superParent')
.getDOMNode()
.classList.contains('dshDashboardViewportWrapper--defaultBg')
).not.toBe(null);
});
});

View file

@ -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<DashboardContainer>();
const [fatalError, setFatalError] = useState<ErrorEmbeddable | undefined>();
const [dashboardMissing, setDashboardMissing] = useState(false);
const id = useMemo(() => uuidv4(), []);
const dashboardContainer = useRef(null);
const [dashboardApi, setDashboardApi] = useState<DashboardApi | undefined>();
const [dashboardInternalApi, setDashboardInternalApi] = useState<
DashboardInternalApi | undefined
>();
const [error, setError] = useState<Error | undefined>();
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 (error) setError(undefined);
if (dashboardApi) setDashboardApi(undefined);
if (dashboardInternalApi) setDashboardInternalApi(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;
}
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 <Dashboard404Page dashboardRedirect={dashboardRedirect} />;
if (fatalError) return fatalError.render();
if (loading) return loadingSpinner;
return <div ref={dashboardRoot} />;
if (error) {
return error instanceof SavedObjectNotFound ? (
<Dashboard404Page dashboardRedirect={dashboardRedirect} />
) : (
error.message
);
}
return dashboardApi && dashboardInternalApi ? (
<div className="dashboardContainer" ref={dashboardContainer}>
<ExitFullScreenButtonKibanaProvider
coreStart={{ chrome: coreServices.chrome, customBranding: coreServices.customBranding }}
>
<DashboardContext.Provider value={dashboardApi}>
<DashboardInternalContext.Provider value={dashboardInternalApi}>
<DashboardViewport
dashboardContainer={
dashboardContainer.current ? dashboardContainer.current : undefined
}
/>
</DashboardInternalContext.Provider>
</DashboardContext.Provider>
</ExitFullScreenButtonKibanaProvider>
</div>
) : (
loadingSpinner
);
};
return (
<div ref={dashboardViewport} className={viewportClasses}>
{dashboardViewport?.current &&
dashboardContainer &&
!isErrorEmbeddable(dashboardContainer) && (
<ParentClassController
viewportRef={dashboardViewport.current}
dashboardApi={dashboardContainer as DashboardApi}
/>
)}
{dashboardViewport?.current && dashboardApi && (
<ParentClassController
viewportRef={dashboardViewport.current}
dashboardApi={dashboardApi}
/>
)}
{renderDashboardContents()}
</div>
);

View file

@ -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';

View file

@ -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';

View file

@ -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<TestInput>(
{} 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<TestInput>(
{} 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<TestInput>(
{} 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<TestInput>(
{} 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<TestInput>(
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<TestInput>(
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<TestInput>(
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' }
);
});

View file

@ -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<TEmbeddableInput extends EmbeddableInput>(
factory: EmbeddableFactory,
newPanel: PanelState<TEmbeddableInput>,
currentPanels: { [key: string]: DashboardPanelState },
attributes?: unknown
): {
newPanel: DashboardPanelState<TEmbeddableInput>;
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,
};
}

View file

@ -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<DashboardContainerInput['panels']>
) => {
state.explicitInput.panels = action.payload;
},
// ------------------------------------------------------------------------------
// Meta info Reducers
// ------------------------------------------------------------------------------
setStateFromSaveModal: (
state: DashboardReduxState,
action: PayloadAction<DashboardStateFromSaveModal>
) => {
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<DashboardStateFromSettingsFlyout>
) => {
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<DashboardContainerInput['description']>
) => {
state.explicitInput.description = action.payload;
},
setViewMode: (
state: DashboardReduxState,
action: PayloadAction<DashboardContainerInput['viewMode']>
) => {
state.explicitInput.viewMode = action.payload;
},
setTags: (state: DashboardReduxState, action: PayloadAction<DashboardContainerInput['tags']>) => {
state.explicitInput.tags = action.payload;
},
setTitle: (
state: DashboardReduxState,
action: PayloadAction<DashboardContainerInput['title']>
) => {
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<DashboardContainerInput>
) => {
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<Pick<DashboardContainerInput, 'filters' | 'query'>>
) => {
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<DashboardContainerInput['filters']>
) => {
state.explicitInput.filters = action.payload;
},
setQuery: (
state: DashboardReduxState,
action: PayloadAction<DashboardContainerInput['query']>
) => {
state.explicitInput.query = action.payload;
},
setTimeRestore: (
state: DashboardReduxState,
action: PayloadAction<DashboardContainerInput['timeRestore']>
) => {
state.explicitInput.timeRestore = action.payload;
},
setTimeRange: (
state: DashboardReduxState,
action: PayloadAction<DashboardContainerInput['timeRange']>
) => {
state.explicitInput.timeRange = action.payload;
},
setRefreshInterval: (
state: DashboardReduxState,
action: PayloadAction<DashboardContainerInput['refreshInterval']>
) => {
state.explicitInput.refreshInterval = action.payload;
},
setTimeslice: (
state: DashboardReduxState,
action: PayloadAction<DashboardContainerInput['timeslice']>
) => {
state.explicitInput.timeslice = action.payload;
},
};

View file

@ -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<Key extends keyof DashboardContainerInput> {
currentValue: DashboardContainerInput[Key];
lastValue: DashboardContainerInput[Key];
currentInput: DashboardContainerInputWithoutId;
lastInput: DashboardContainerInputWithoutId;
container: DashboardContainer;
}
export type DashboardDiffFunctions = {
[key in keyof Partial<DashboardContainerInput>]: (
props: DiffFunctionProps<key>
) => boolean | Promise<boolean>;
};
export const isKeyEqualAsync = async (
key: keyof DashboardContainerInput,
diffFunctionProps: DiffFunctionProps<typeof key>,
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<DashboardContainerInput, 'panels'>, // only Panels is async
diffFunctionProps: DiffFunctionProps<typeof key>,
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<boolean>((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.
};

View file

@ -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<keyof typeof dashboardContainerReducers> = ['setTimeslice'];
/**
* Some keys will often have deviated from their last saved state, but should not persist over reloads
*/
const keysToOmitFromSessionStorage: Array<keyof DashboardContainerInput> = [
'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<keyof DashboardContainerInput> = [
'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<AnyAction> = (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<Partial<DashboardContainerInput>> {
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<DashboardContainerInput>);
return inputChanges;
}
function backupUnsavedChanges(
this: DashboardContainer,
dashboardChanges: Partial<DashboardContainerInput>,
reactEmbeddableChanges: UnsavedPanelState
) {
const dashboardStateToBackup = omit(dashboardChanges, keysToOmitFromSessionStorage);
getDashboardBackupService().setState(
this.savedObjectId.value,
{
...dashboardStateToBackup,
panels: dashboardChanges.panels,
},
reactEmbeddableChanges
);
}

View file

@ -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;

View file

@ -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 <div>mockDashboardEditingToolbar</div>;
},
}));
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(
<DashboardContext.Provider value={buildMockDashboard() as DashboardApi}>
<DashboardContext.Provider value={buildMockDashboardApi().api}>
<InternalDashboardTopNav redirectTo={jest.fn()} />
</DashboardContext.Provider>
);
@ -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(
<DashboardContext.Provider value={dashboardApi}>
<InternalDashboardTopNav redirectTo={jest.fn()} />

View file

@ -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({
<EuiLink
id="dashboardManagedContentPopoverButton"
onClick={() => {
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 (
<div className="dashboardTopNav">

View file

@ -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<DashboardStart>;
@ -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<DashboardContainerInput>;
overrides?: Partial<DashboardState>;
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>
): DashboardContainerInput {
export function getSampleDashboardState(overrides?: Partial<DashboardState>): 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<TEmbeddableInput extends EmbeddableInput = EmbeddableInput>(
overrides: Partial<DashboardPanelState<TEmbeddableInput>> & {
export function getSampleDashboardPanel(
overrides: Partial<DashboardPanelState> & {
explicitInput: { id: string };
type: string;
}

View file

@ -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<SavedDashboardInput>;
dashboardState?: Partial<DashboardState>;
panels?: UnsavedPanelState;
}
| undefined;
setState: (
id: string | undefined,
dashboardState: Partial<SavedDashboardInput>,
dashboardState: Partial<DashboardState>,
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<DashboardContainerInput> | undefined;
]?.[id] as Partial<DashboardState> | 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<DashboardContainerInput>,
newState: Partial<DashboardState>,
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'
) ||

View file

@ -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();

View file

@ -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' } }),

View file

@ -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,

View file

@ -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,

View file

@ -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;

View file

@ -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/**/*"]
}

View file

@ -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);

View file

@ -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,
});

View file

@ -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 à laide de tableaux de bord.",
"dashboard.featureCatalogue.dashboardTitle": "Dashboard",

View file

@ -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": "ダッシュボード",

View file

@ -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": "仪表板",