[Dashboards] Add getSerializedState method to Dashboard API (#204140)

Adds a `getSerializedState` method to the Dashboard API.
This commit is contained in:
Nick Peihl 2024-12-19 15:24:49 -05:00 committed by GitHub
parent 65a75ffcb7
commit d7280a1380
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 396 additions and 170 deletions

View file

@ -32,6 +32,7 @@ export { prefixReferencesFromPanel } from './dashboard_container/persistable_sta
export {
convertPanelsArrayToPanelMap,
convertPanelMapToPanelsArray,
generateNewPanelIds,
} from './lib/dashboard_panel_converters';
export const UI_SETTINGS = {

View file

@ -41,6 +41,7 @@ import { initializeSearchSessionManager } from './search_session_manager';
import { initializeViewModeManager } from './view_mode_manager';
import { UnsavedPanelState } from '../dashboard_container/types';
import { initializeTrackContentfulRender } from './track_contentful_render';
import { getSerializedState } from './get_serialized_state';
export function getDashboardApi({
creationOptions,
@ -110,9 +111,11 @@ export function getDashboardApi({
});
function getState() {
const { panels, references: panelReferences } = panelsManager.internalApi.getState();
const { state: unifiedSearchState, references: searchSourceReferences } =
unifiedSearchManager.internalApi.getState();
const dashboardState: DashboardState = {
...settingsManager.internalApi.getState(),
...unifiedSearchManager.internalApi.getState(),
...unifiedSearchState,
panels,
viewMode: viewModeManager.api.viewMode.value,
};
@ -130,6 +133,7 @@ export function getDashboardApi({
dashboardState,
controlGroupReferences,
panelReferences,
searchSourceReferences,
};
}
@ -168,6 +172,7 @@ export function getDashboardApi({
unifiedSearchManager.internalApi.controlGroupReload$,
unifiedSearchManager.internalApi.panelsReload$
).pipe(debounceTime(0)),
getSerializedState: () => getSerializedState(getState()),
runInteractiveSave: async () => {
trackOverlayApi.clearOverlays();
const saveResult = await openSaveModal({
@ -197,11 +202,13 @@ export function getDashboardApi({
},
runQuickSave: async () => {
if (isManaged) return;
const { controlGroupReferences, dashboardState, panelReferences } = getState();
const { controlGroupReferences, dashboardState, panelReferences, searchSourceReferences } =
getState();
const saveResult = await getDashboardContentManagementService().saveDashboardState({
controlGroupReferences,
currentState: dashboardState,
dashboardState,
panelReferences,
searchSourceReferences,
saveOptions: {},
lastSavedId: savedObjectId$.value,
});

View file

@ -0,0 +1,170 @@
/*
* 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 type { DashboardPanelState } from '../../common';
import {
dataService,
embeddableService,
savedObjectsTaggingService,
} from '../services/kibana_services';
import { getSampleDashboardState } from '../mocks';
import { getSerializedState } from './get_serialized_state';
dataService.search.searchSource.create = jest.fn().mockResolvedValue({
setField: jest.fn(),
getSerializedFields: jest.fn().mockReturnValue({}),
});
dataService.query.timefilter.timefilter.getTime = jest
.fn()
.mockReturnValue({ from: 'now-15m', to: 'now' });
dataService.query.timefilter.timefilter.getRefreshInterval = jest
.fn()
.mockReturnValue({ pause: true, value: 0 });
embeddableService.extract = jest
.fn()
.mockImplementation((attributes) => ({ state: attributes, references: [] }));
if (savedObjectsTaggingService) {
savedObjectsTaggingService.getTaggingApi = jest.fn().mockReturnValue({
ui: {
updateTagsReferences: jest.fn((references, tags) => references),
},
});
}
jest.mock('uuid', () => ({
v4: jest.fn().mockReturnValue('54321'),
}));
describe('getSerializedState', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return the current state attributes and references', () => {
const dashboardState = getSampleDashboardState();
const result = getSerializedState({
controlGroupReferences: [],
generateNewIds: false,
dashboardState,
panelReferences: [],
searchSourceReferences: [],
});
expect(result.attributes).toMatchInlineSnapshot(`
Object {
"controlGroupInput": undefined,
"description": "",
"kibanaSavedObjectMeta": Object {
"searchSource": Object {
"filter": Array [],
"query": Object {
"language": "kuery",
"query": "hi",
},
},
},
"options": Object {
"hidePanelTitles": false,
"syncColors": false,
"syncCursor": true,
"syncTooltips": false,
"useMargins": true,
},
"panels": Array [],
"refreshInterval": undefined,
"timeFrom": undefined,
"timeRestore": false,
"timeTo": undefined,
"title": "My Dashboard",
"version": 3,
}
`);
expect(result.references).toEqual([]);
});
it('should generate new IDs for panels and references when generateNewIds is true', () => {
const dashboardState = {
...getSampleDashboardState(),
panels: { oldPanelId: { type: 'visualization' } as unknown as DashboardPanelState },
};
const result = getSerializedState({
controlGroupReferences: [],
generateNewIds: true,
dashboardState,
panelReferences: [
{
name: 'oldPanelId:indexpattern_foobar',
type: 'index-pattern',
id: 'bizzbuzz',
},
],
searchSourceReferences: [],
});
expect(result.attributes.panels).toMatchInlineSnapshot(`
Array [
Object {
"gridData": Object {
"i": "54321",
},
"panelConfig": Object {},
"panelIndex": "54321",
"type": "visualization",
"version": undefined,
},
]
`);
expect(result.references).toMatchInlineSnapshot(`
Array [
Object {
"id": "bizzbuzz",
"name": "54321:indexpattern_foobar",
"type": "index-pattern",
},
]
`);
});
it('should include control group references', () => {
const dashboardState = getSampleDashboardState();
const controlGroupReferences = [
{ name: 'control1:indexpattern', type: 'index-pattern', id: 'foobar' },
];
const result = getSerializedState({
controlGroupReferences,
generateNewIds: false,
dashboardState,
panelReferences: [],
searchSourceReferences: [],
});
expect(result.references).toEqual(controlGroupReferences);
});
it('should include panel references', () => {
const dashboardState = getSampleDashboardState();
const panelReferences = [
{ name: 'panel1:boogiewoogie', type: 'index-pattern', id: 'fizzbuzz' },
];
const result = getSerializedState({
controlGroupReferences: [],
generateNewIds: false,
dashboardState,
panelReferences,
searchSourceReferences: [],
});
expect(result.references).toEqual(panelReferences);
});
});

View file

@ -0,0 +1,150 @@
/*
* 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 { pick } from 'lodash';
import moment, { Moment } from 'moment';
import { RefreshInterval } from '@kbn/data-plugin/public';
import type { Reference } from '@kbn/content-management-utils';
import { convertPanelMapToPanelsArray, extractReferences, generateNewPanelIds } from '../../common';
import type { DashboardAttributes } from '../../server';
import { convertDashboardVersionToNumber } from '../services/dashboard_content_management_service/lib/dashboard_versioning';
import {
dataService,
embeddableService,
savedObjectsTaggingService,
} from '../services/kibana_services';
import { LATEST_DASHBOARD_CONTAINER_VERSION } from '../dashboard_container';
import { DashboardState } from './types';
export 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 getSerializedState = ({
controlGroupReferences,
generateNewIds,
dashboardState,
panelReferences,
searchSourceReferences,
}: {
controlGroupReferences?: Reference[];
generateNewIds?: boolean;
dashboardState: DashboardState;
panelReferences?: Reference[];
searchSourceReferences?: Reference[];
}) => {
const {
query: {
timefilter: { timefilter },
},
} = dataService;
const {
tags,
query,
title,
filters,
timeRestore,
description,
// Dashboard options
useMargins,
syncColors,
syncCursor,
syncTooltips,
hidePanelTitles,
controlGroupInput,
} = dashboardState;
let { panels } = dashboardState;
let prefixedPanelReferences = panelReferences;
if (generateNewIds) {
const { panels: newPanels, references: newPanelReferences } = generateNewPanelIds(
panels,
panelReferences
);
panels = newPanels;
prefixedPanelReferences = newPanelReferences;
//
// do not need to generate new ids for controls.
// ControlGroup Component is keyed on dashboard id so changing dashboard id mounts new ControlGroup Component.
//
}
const searchSource = { filter: filters, query };
const options = {
useMargins,
syncColors,
syncCursor,
syncTooltips,
hidePanelTitles,
};
const savedPanels = convertPanelMapToPanelsArray(panels, true);
/**
* Parse global time filter settings
*/
const { from, to } = timefilter.getTime();
const timeFrom = timeRestore ? convertTimeToUTCString(from) : undefined;
const timeTo = timeRestore ? convertTimeToUTCString(to) : undefined;
const refreshInterval = timeRestore
? (pick(timefilter.getRefreshInterval(), [
'display',
'pause',
'section',
'value',
]) as RefreshInterval)
: undefined;
const rawDashboardAttributes: DashboardAttributes = {
version: convertDashboardVersionToNumber(LATEST_DASHBOARD_CONTAINER_VERSION),
controlGroupInput: controlGroupInput as DashboardAttributes['controlGroupInput'],
kibanaSavedObjectMeta: { searchSource },
description: description ?? '',
refreshInterval,
timeRestore,
options,
panels: savedPanels,
timeFrom,
title,
timeTo,
};
/**
* Extract references from raw attributes and tags into the references array.
*/
const { attributes, references: dashboardReferences } = extractReferences(
{
attributes: rawDashboardAttributes,
references: searchSourceReferences ?? [],
},
{ embeddablePersistableStateService: embeddableService }
);
const savedObjectsTaggingApi = savedObjectsTaggingService?.getTaggingApi();
const references = savedObjectsTaggingApi?.ui.updateTagsReferences
? savedObjectsTaggingApi?.ui.updateTagsReferences(dashboardReferences, tags)
: dashboardReferences;
const allReferences = [
...references,
...(prefixedPanelReferences ?? []),
...(controlGroupReferences ?? []),
...(searchSourceReferences ?? []),
];
return { attributes, references: allReferences };
};

View file

@ -32,6 +32,7 @@ export async function openSaveModal({
isManaged,
lastSavedId,
panelReferences,
searchSourceReferences,
viewMode,
}: {
controlGroupReferences?: Reference[];
@ -39,6 +40,7 @@ export async function openSaveModal({
isManaged: boolean;
lastSavedId: string | undefined;
panelReferences: Reference[];
searchSourceReferences: Reference[];
viewMode: ViewMode;
}) {
if (viewMode === 'edit' && isManaged) {
@ -101,8 +103,9 @@ export async function openSaveModal({
const saveResult = await dashboardContentManagementService.saveDashboardState({
controlGroupReferences,
panelReferences,
searchSourceReferences,
saveOptions,
currentState: dashboardStateToSave,
dashboardState: dashboardStateToSave,
lastSavedId,
});

View file

@ -53,6 +53,7 @@ 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 type { SavedObjectReference } from '@kbn/core-saved-objects-api-server';
import { DashboardPanelMap, DashboardPanelState } from '../../common';
import type { DashboardAttributes, DashboardOptions } from '../../server/content_management';
import {
@ -146,6 +147,10 @@ export type DashboardApi = CanExpandPanels &
focusedPanelId$: PublishingSubject<string | undefined>;
forceRefresh: () => void;
getSettings: () => DashboardSettings;
getSerializedState: () => {
attributes: DashboardAttributes;
references: SavedObjectReference[];
};
getDashboardPanelFromId: (id: string) => DashboardPanelState;
hasOverlays$: PublishingSubject<boolean>;
hasUnsavedChanges$: PublishingSubject<boolean>;

View file

@ -33,10 +33,12 @@ 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 type { SavedObjectReference } from '@kbn/core-saved-objects-api-server';
import {
GlobalQueryStateFromUrl,
RefreshInterval,
connectToQueryState,
extractSearchSourceReferences,
syncGlobalQueryStateWithUrl,
} from '@kbn/data-plugin/public';
import moment, { Moment } from 'moment';
@ -324,16 +326,30 @@ export function initializeUnifiedSearchManager(
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,
}),
getState: (): {
state: Pick<
DashboardState,
'filters' | 'query' | 'refreshInterval' | 'timeRange' | 'timeRestore'
>;
references: SavedObjectReference[];
} => {
// pinned filters are not serialized when saving the dashboard
const serializableFilters = unifiedSearchFilters$.value?.filter((f) => !isFilterPinned(f));
const [{ filter, query }, references] = extractSearchSourceReferences({
filter: serializableFilters,
query: query$.value,
});
return {
state: {
filters: filter ?? DEFAULT_DASHBOARD_INPUT.filters,
query: (query as Query) ?? DEFAULT_DASHBOARD_INPUT.query,
refreshInterval: refreshInterval$.value,
timeRange: timeRange$.value,
timeRestore: timeRestore$.value ?? DEFAULT_DASHBOARD_INPUT.timeRestore,
},
references,
};
},
},
cleanup: () => {
controlGroupSubscriptions.unsubscribe();

View file

@ -47,7 +47,7 @@ describe('Save dashboard state', () => {
it('should save the dashboard using the same ID', async () => {
const result = await saveDashboardState({
currentState: {
dashboardState: {
...getSampleDashboardState(),
title: 'BOO',
} as unknown as DashboardContainerInput,
@ -68,7 +68,7 @@ describe('Save dashboard state', () => {
it('should save the dashboard using a new id, and return redirect required', async () => {
const result = await saveDashboardState({
currentState: {
dashboardState: {
...getSampleDashboardState(),
title: 'BooToo',
} as unknown as DashboardContainerInput,
@ -92,7 +92,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: {
dashboardState: {
...getSampleDashboardState(),
title: 'BooThree',
panels: { aVerySpecialVeryUniqueId: { type: 'boop' } },
@ -118,7 +118,7 @@ describe('Save dashboard state', () => {
it('should update prefixes on references when save as copy is true', async () => {
const result = await saveDashboardState({
currentState: {
dashboardState: {
...getSampleDashboardState(),
title: 'BooFour',
panels: { idOne: { type: 'boop' } },
@ -146,7 +146,7 @@ describe('Save dashboard state', () => {
it('should return an error when the save fails.', async () => {
contentManagementService.client.create = jest.fn().mockRejectedValue('Whoops');
const result = await saveDashboardState({
currentState: {
dashboardState: {
...getSampleDashboardState(),
title: 'BooThree',
panels: { idOne: { type: 'boop' } },

View file

@ -7,169 +7,37 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { pick } from 'lodash';
import moment, { Moment } from 'moment';
import { extractSearchSourceReferences, RefreshInterval } from '@kbn/data-plugin/public';
import { isFilterPinned } from '@kbn/es-query';
import type { SavedObjectReference } from '@kbn/core/server';
import { getDashboardContentManagementCache } from '..';
import { convertPanelMapToPanelsArray, extractReferences } from '../../../../common';
import type {
DashboardAttributes,
DashboardCreateIn,
DashboardCreateOut,
DashboardUpdateIn,
DashboardUpdateOut,
} from '../../../../server/content_management';
import { generateNewPanelIds } from '../../../../common/lib/dashboard_panel_converters';
import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants';
import { LATEST_DASHBOARD_CONTAINER_VERSION } from '../../../dashboard_container';
import { dashboardSaveToastStrings } from '../../../dashboard_container/_dashboard_container_strings';
import { getDashboardBackupService } from '../../dashboard_backup_service';
import {
contentManagementService,
coreServices,
dataService,
embeddableService,
savedObjectsTaggingService,
} from '../../kibana_services';
import { DashboardSearchSource, SaveDashboardProps, SaveDashboardReturn } from '../types';
import { convertDashboardVersionToNumber } from './dashboard_versioning';
export 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;
}
};
import { contentManagementService, coreServices } from '../../kibana_services';
import { SaveDashboardProps, SaveDashboardReturn } from '../types';
import { getSerializedState } from '../../../dashboard_api/get_serialized_state';
export const saveDashboardState = async ({
controlGroupReferences,
lastSavedId,
saveOptions,
currentState,
dashboardState,
panelReferences,
searchSourceReferences,
}: SaveDashboardProps): Promise<SaveDashboardReturn> => {
const {
search: dataSearchService,
query: {
timefilter: { timefilter },
},
} = dataService;
const dashboardContentManagementCache = getDashboardContentManagementCache();
const {
tags,
query,
title,
filters,
timeRestore,
description,
// Dashboard options
useMargins,
syncColors,
syncCursor,
syncTooltips,
hidePanelTitles,
controlGroupInput,
} = currentState;
let { panels } = currentState;
let prefixedPanelReferences = panelReferences;
if (saveOptions.saveAsCopy) {
const { panels: newPanels, references: newPanelReferences } = generateNewPanelIds(
panels,
panelReferences
);
panels = newPanels;
prefixedPanelReferences = newPanelReferences;
//
// do not need to generate new ids for controls.
// ControlGroup Component is keyed on dashboard id so changing dashboard id mounts new ControlGroup Component.
//
}
const { searchSource, searchSourceReferences } = await (async () => {
const searchSourceFields = await dataSearchService.searchSource.create();
searchSourceFields.setField(
'filter', // save only unpinned filters
filters.filter((filter) => !isFilterPinned(filter))
);
searchSourceFields.setField('query', query);
const rawSearchSourceFields = searchSourceFields.getSerializedFields();
const [fields, references] = extractSearchSourceReferences(rawSearchSourceFields) as [
DashboardSearchSource,
SavedObjectReference[]
];
return { searchSourceReferences: references, searchSource: fields };
})();
const options = {
useMargins,
syncColors,
syncCursor,
syncTooltips,
hidePanelTitles,
};
const savedPanels = convertPanelMapToPanelsArray(panels, true);
/**
* Parse global time filter settings
*/
const { from, to } = timefilter.getTime();
const timeFrom = timeRestore ? convertTimeToUTCString(from) : undefined;
const timeTo = timeRestore ? convertTimeToUTCString(to) : undefined;
const refreshInterval = timeRestore
? (pick(timefilter.getRefreshInterval(), [
'display',
'pause',
'section',
'value',
]) as RefreshInterval)
: undefined;
const rawDashboardAttributes: DashboardAttributes = {
version: convertDashboardVersionToNumber(LATEST_DASHBOARD_CONTAINER_VERSION),
controlGroupInput: controlGroupInput as DashboardAttributes['controlGroupInput'],
kibanaSavedObjectMeta: { searchSource },
description: description ?? '',
refreshInterval,
timeRestore,
options,
panels: savedPanels,
timeFrom,
title,
timeTo,
};
/**
* Extract references from raw attributes and tags into the references array.
*/
const { attributes, references: dashboardReferences } = extractReferences(
{
attributes: rawDashboardAttributes,
references: searchSourceReferences,
},
{ embeddablePersistableStateService: embeddableService }
);
const savedObjectsTaggingApi = savedObjectsTaggingService?.getTaggingApi();
const references = savedObjectsTaggingApi?.ui.updateTagsReferences
? savedObjectsTaggingApi?.ui.updateTagsReferences(dashboardReferences, tags)
: dashboardReferences;
const allReferences = [
...references,
...(prefixedPanelReferences ?? []),
...(controlGroupReferences ?? []),
];
const { attributes, references } = getSerializedState({
controlGroupReferences,
generateNewIds: saveOptions.saveAsCopy,
dashboardState,
panelReferences,
searchSourceReferences,
});
/**
* Save the saved object using the content management
@ -183,7 +51,7 @@ export const saveDashboardState = async ({
contentTypeId: DASHBOARD_CONTENT_ID,
data: attributes,
options: {
references: allReferences,
references,
/** perform a "full" update instead, where the provided attributes will fully replace the existing ones */
mergeAttributes: false,
},
@ -192,14 +60,14 @@ export const saveDashboardState = async ({
contentTypeId: DASHBOARD_CONTENT_ID,
data: attributes,
options: {
references: allReferences,
references,
},
});
const newId = result.item.id;
if (newId) {
coreServices.notifications.toasts.addSuccess({
title: dashboardSaveToastStrings.getSuccessString(currentState.title),
title: dashboardSaveToastStrings.getSuccessString(dashboardState.title),
className: 'eui-textBreakWord',
'data-test-subj': 'saveDashboardSuccess',
});
@ -209,15 +77,15 @@ export const saveDashboardState = async ({
*/
if (newId !== lastSavedId) {
getDashboardBackupService().clearState(lastSavedId);
return { redirectRequired: true, id: newId, references: allReferences };
return { redirectRequired: true, id: newId, references };
} else {
dashboardContentManagementCache.deleteDashboard(newId); // something changed in an existing dashboard, so delete it from the cache so that it can be re-fetched
}
}
return { id: newId, references: allReferences };
return { id: newId, references };
} catch (error) {
coreServices.notifications.toasts.addDanger({
title: dashboardSaveToastStrings.getFailureString(currentState.title, error.message),
title: dashboardSaveToastStrings.getFailureString(dashboardState.title, error.message),
'data-test-subj': 'saveDashboardFailure',
});
return { error };

View file

@ -81,12 +81,18 @@ export type SavedDashboardSaveOpts = SavedObjectSaveOpts & { saveAsCopy?: boolea
export interface SaveDashboardProps {
controlGroupReferences?: Reference[];
currentState: DashboardState;
dashboardState: DashboardState;
saveOptions: SavedDashboardSaveOpts;
panelReferences?: Reference[];
searchSourceReferences?: Reference[];
lastSavedId?: string;
}
export interface GetDashboardStateReturn {
attributes: DashboardAttributes;
references: Reference[];
}
export interface SaveDashboardReturn {
id?: string;
error?: string;