mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
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:
parent
8477dc7af4
commit
101e797e9d
82 changed files with 2474 additions and 5216 deletions
|
@ -29,7 +29,6 @@ export {
|
|||
export {
|
||||
canTrackContentfulRender,
|
||||
type TrackContentfulRender,
|
||||
type TracksQueryPerformance,
|
||||
} from './interfaces/performance_trackers';
|
||||
export {
|
||||
apiIsPresentationContainer,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
140
src/plugins/dashboard/public/dashboard_api/load_dashboard_api.ts
Normal file
140
src/plugins/dashboard/public/dashboard_api/load_dashboard_api.ts
Normal 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,
|
||||
};
|
||||
}
|
171
src/plugins/dashboard/public/dashboard_api/open_save_modal.tsx
Normal file
171
src/plugins/dashboard/public/dashboard_api/open_save_modal.tsx
Normal 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;
|
||||
}
|
475
src/plugins/dashboard/public/dashboard_api/panels_manager.ts
Normal file
475
src/plugins/dashboard/public/dashboard_api/panels_manager.ts
Normal 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})`;
|
||||
}
|
|
@ -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?.();
|
||||
},
|
||||
};
|
||||
}
|
140
src/plugins/dashboard/public/dashboard_api/settings_manager.ts
Normal file
140
src/plugins/dashboard/public/dashboard_api/settings_manager.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
};
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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();
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
};
|
|
@ -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'>>,
|
||||
};
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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] : [];
|
||||
|
|
|
@ -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 }),
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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();
|
||||
},
|
||||
})
|
||||
);
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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})`;
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
|
@ -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));
|
||||
});
|
||||
});
|
|
@ -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 ?? [])];
|
||||
};
|
|
@ -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}`);
|
||||
});
|
|
@ -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 };
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
}
|
|
@ -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 = {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
};
|
||||
}
|
|
@ -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 });
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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' }
|
||||
);
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
},
|
||||
};
|
|
@ -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.
|
||||
};
|
|
@ -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
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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()} />
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
) ||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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' } }),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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/**/*"]
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -1463,8 +1463,6 @@
|
|||
"dashboard.emptyScreen.noPermissionsTitle": "Ce tableau de bord est vide.",
|
||||
"dashboard.emptyScreen.viewModeSubtitle": "Accédez au mode de modification, puis commencez à ajouter vos visualisations.",
|
||||
"dashboard.emptyScreen.viewModeTitle": "Ajouter des visualisations à votre tableau de bord",
|
||||
"dashboard.factory.displayName": "Dashboard",
|
||||
"dashboard.factory.displayNameLowercase": "tableau de bord",
|
||||
"dashboard.featureCatalogue.dashboardDescription": "Affichez et partagez une collection de visualisations et de recherches enregistrées.",
|
||||
"dashboard.featureCatalogue.dashboardSubtitle": "Analysez des données à l’aide de tableaux de bord.",
|
||||
"dashboard.featureCatalogue.dashboardTitle": "Dashboard",
|
||||
|
|
|
@ -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": "ダッシュボード",
|
||||
|
|
|
@ -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": "仪表板",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue