[Embeddables Rebuild] Create & copy panels with runtime state (#188039)

Makes the new Embeddable framework use runtime state for creating new panels, and for passing panel state around in the state transfer service.
This commit is contained in:
Devon Thomson 2024-07-15 18:59:09 -04:00 committed by GitHub
parent 31e0c57b82
commit 1140cae77e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 137 additions and 161 deletions

View file

@ -21,11 +21,7 @@ import {
import { ADD_SAVED_BOOK_ACTION_ID, SAVED_BOOK_ID } from './constants';
import { openSavedBookEditor } from './saved_book_editor';
import { saveBookAttributes } from './saved_book_library';
import {
BookByReferenceSerializedState,
BookByValueSerializedState,
BookSerializedState,
} from './types';
import { BookRuntimeState } from './types';
export const registerCreateSavedBookAction = (uiActions: UiActionsPublicStart, core: CoreStart) => {
uiActions.registerAction<EmbeddableApiContext>({
@ -43,21 +39,17 @@ export const registerCreateSavedBookAction = (uiActions: UiActionsPublicStart, c
parentApi: embeddable,
});
const initialState: BookSerializedState = await (async () => {
const initialState: BookRuntimeState = await (async () => {
const bookAttributes = serializeBookAttributes(newPanelStateManager);
// if we're adding this to the library, we only need to return the by reference state.
if (addToLibrary) {
const savedBookId = await saveBookAttributes(
undefined,
serializeBookAttributes(newPanelStateManager)
);
return { savedBookId } as BookByReferenceSerializedState;
const savedBookId = await saveBookAttributes(undefined, bookAttributes);
return { savedBookId, ...bookAttributes };
}
return {
attributes: serializeBookAttributes(newPanelStateManager),
} as BookByValueSerializedState;
return bookAttributes;
})();
embeddable.addNewPanel<BookSerializedState>({
embeddable.addNewPanel<BookRuntimeState>({
panelType: SAVED_BOOK_ID,
initialState,
});

View file

@ -28,7 +28,7 @@ export const runComparators = <StateType extends object = object>(
lastSavedState: StateType | undefined,
latestState: Partial<StateType>
) => {
if (!lastSavedState) {
if (!lastSavedState || Object.keys(latestState).length === 0) {
// if we have no last saved state, everything is considered a change
return latestState;
}

View file

@ -5,9 +5,6 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { omit } from 'lodash';
import React, { useCallback, useState } from 'react';
import {
EuiButton,
EuiButtonEmpty,
@ -20,8 +17,10 @@ import {
EuiSpacer,
} from '@elastic/eui';
import { EmbeddablePackageState, PanelNotFoundError } from '@kbn/embeddable-plugin/public';
import { apiHasSnapshottableState } from '@kbn/presentation-containers/interfaces/serialized_state';
import { LazyDashboardPicker, withSuspense } from '@kbn/presentation-util-plugin/public';
import { omit } from 'lodash';
import React, { useCallback, useState } from 'react';
import { createDashboardEditUrl, CREATE_NEW_DASHBOARD_URL } from '../dashboard_constants';
import { pluginServices } from '../services/plugin_services';
import { CopyToDashboardAPI } from './copy_to_dashboard_action';
@ -51,21 +50,21 @@ export function CopyToDashboardModal({ api, closeModal }: CopyToDashboardModalPr
const onSubmit = useCallback(async () => {
const dashboard = api.parentApi;
const panelToCopy = await dashboard.getDashboardPanelFromId(api.uuid);
const runtimeSnapshot = apiHasSnapshottableState(api) ? api.snapshotRuntimeState() : undefined;
if (!panelToCopy) {
if (!panelToCopy && !runtimeSnapshot) {
throw new PanelNotFoundError();
}
const state: EmbeddablePackageState = {
type: panelToCopy.type,
input: {
input: runtimeSnapshot ?? {
...omit(panelToCopy.explicitInput, 'id'),
},
size: {
width: panelToCopy.gridData.w,
height: panelToCopy.gridData.h,
},
references: panelToCopy.references,
};
const path =

View file

@ -5,7 +5,6 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import deepEqual from 'fast-deep-equal';
import {
ControlGroupInput,
CONTROL_GROUP_TYPE,
@ -28,16 +27,21 @@ import {
TimeRange,
} from '@kbn/es-query';
import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
import deepEqual from 'fast-deep-equal';
import { cloneDeep, identity, omit, pickBy } from 'lodash';
import { BehaviorSubject, combineLatest, Subject } from 'rxjs';
import { map, distinctUntilChanged, startWith } from 'rxjs';
import {
BehaviorSubject,
combineLatest,
distinctUntilChanged,
map,
startWith,
Subject,
} from 'rxjs';
import { v4 } from 'uuid';
import { combineDashboardFiltersWithControlGroupFilters } from './controls/dashboard_control_group_integration';
import {
DashboardContainerInput,
DashboardPanelMap,
DashboardPanelState,
prefixReferencesFromPanel,
} from '../../../../common';
import {
DEFAULT_DASHBOARD_INPUT,
@ -56,11 +60,14 @@ import { startDiffingDashboardState } from '../../state/diffing/dashboard_diffin
import { DashboardPublicState, UnsavedPanelState } from '../../types';
import { DashboardContainer } from '../dashboard_container';
import { DashboardCreationOptions } from '../dashboard_container_factory';
import { startSyncingDashboardControlGroup } from './controls/dashboard_control_group_integration';
import {
combineDashboardFiltersWithControlGroupFilters,
startSyncingDashboardControlGroup,
} from './controls/dashboard_control_group_integration';
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 { startQueryPerformanceTracking } from './performance/query_performance_tracking';
/**
* Builds a new Dashboard from scratch.
@ -276,17 +283,6 @@ export const initializeDashboard = async ({
...overrideInput,
};
// --------------------------------------------------------------------------------------
// Set restored runtime state for react embeddables.
// --------------------------------------------------------------------------------------
untilDashboardReady().then((dashboardContainer) => {
for (const idWithRuntimeState of Object.keys(runtimePanelsToRestore)) {
const restoredRuntimeStateForChild = runtimePanelsToRestore[idWithRuntimeState];
if (!restoredRuntimeStateForChild) continue;
dashboardContainer.setRuntimeStateForChild(idWithRuntimeState, restoredRuntimeStateForChild);
}
});
// --------------------------------------------------------------------------------------
// Combine input from saved object, session storage, & passed input to create initial input.
// --------------------------------------------------------------------------------------
@ -391,7 +387,7 @@ export const initializeDashboard = async ({
const sameType = panelToUpdate.type === incomingEmbeddable.type;
panelToUpdate.type = incomingEmbeddable.type;
panelToUpdate.explicitInput = {
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 : {}),
@ -401,6 +397,13 @@ export const initializeDashboard = async ({
// maintain hide panel titles setting.
hidePanelTitles: panelToUpdate.explicitInput.hidePanelTitles,
};
if (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)
);
@ -429,19 +432,27 @@ export const initializeDashboard = async ({
currentPanels,
}
);
const newPanelState: DashboardPanelState = {
explicitInput: { ...incomingEmbeddable.input, id: embeddableId },
type: incomingEmbeddable.type,
gridData: {
...newPanelPlacement,
i: embeddableId,
},
};
if (incomingEmbeddable.references) {
container.savedObjectReferences.push(
...prefixReferencesFromPanel(embeddableId, incomingEmbeddable.references)
);
}
const newPanelState: DashboardPanelState = (() => {
if (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,
@ -458,6 +469,17 @@ export const initializeDashboard = async ({
}
}
// --------------------------------------------------------------------------------------
// Set restored runtime state for react embeddables.
// --------------------------------------------------------------------------------------
untilDashboardReady().then((dashboardContainer) => {
for (const idWithRuntimeState of Object.keys(runtimePanelsToRestore)) {
const restoredRuntimeStateForChild = runtimePanelsToRestore[idWithRuntimeState];
if (!restoredRuntimeStateForChild) continue;
dashboardContainer.setRuntimeStateForChild(idWithRuntimeState, restoredRuntimeStateForChild);
}
});
// --------------------------------------------------------------------------------------
// Start the control group integration.
// --------------------------------------------------------------------------------------

View file

@ -527,10 +527,12 @@ export class DashboardContainer
i: newId,
},
explicitInput: {
...panelPackage.initialState,
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);

View file

@ -6,8 +6,6 @@
* Side Public License, v 1.
*/
import { Reference } from '@kbn/content-management-utils';
export const EMBEDDABLE_EDITOR_STATE_KEY = 'embeddable_editor_state';
/**
@ -39,23 +37,15 @@ export const EMBEDDABLE_PACKAGE_STATE_KEY = 'embeddable_package_state';
*/
export interface EmbeddablePackageState {
type: string;
/**
* For react embeddables, this input must be runtime state.
*/
input: object;
embeddableId?: string;
size?: {
width?: number;
height?: number;
};
/**
* Copy dashboard panel transfers serialized panel state for React embeddables.
* The rawState will be passed into the input and references are passed separately
* so the container can update its references array and the updated references
* are correctly passed to the factory's deserialize method.
*
* Legacy embeddables have already injected the references
* into the input state, so they will not pass references.
*/
references?: Reference[];
/**
* Pass current search session id when navigating to an editor,
* Editors could use it continue previous search session

View file

@ -30,7 +30,6 @@
"@kbn/presentation-containers",
"@kbn/react-kibana-mount",
"@kbn/analytics",
"@kbn/content-management-utils"
],
"exclude": ["target/**/*"]
}

View file

@ -12,8 +12,6 @@ import { ADD_PANEL_TRIGGER, IncompatibleActionError } from '@kbn/ui-actions-plug
import { COMMON_EMBEDDABLE_GROUPING } from '@kbn/embeddable-plugin/public';
import { APP_ICON, APP_NAME, CONTENT_ID } from '../../common';
import { uiActions } from '../services/kibana_services';
import { serializeLinksAttributes } from '../lib/serialize_attributes';
import { LinksSerializedState } from '../types';
const ADD_LINKS_PANEL_ACTION_ID = 'create_links_panel';
@ -35,14 +33,9 @@ export const registerCreateLinksPanelAction = () => {
});
if (!runtimeState) return;
const initialState: LinksSerializedState = runtimeState.savedObjectId
? { savedObjectId: runtimeState.savedObjectId }
: // We should not extract the references when passing initialState to addNewPanel
serializeLinksAttributes(runtimeState, false);
await embeddable.addNewPanel({
panelType: CONTENT_ID,
initialState,
initialState: runtimeState,
});
},
grouping: [COMMON_EMBEDDABLE_GROUPING.annotation],

View file

@ -9,4 +9,3 @@
export { linksClient } from './links_content_management_client';
export { checkForDuplicateTitle } from './duplicate_title_check';
export { runSaveToLibrary } from './save_to_library';
export { loadFromLibrary } from './load_from_library';

View file

@ -1,30 +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 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 or the Server
* Side Public License, v 1.
*/
import { injectReferences } from '../../common/persistable_state';
import { linksClient } from './links_content_management_client';
export async function loadFromLibrary(savedObjectId: string) {
const {
item: savedObject,
meta: { outcome, aliasPurpose, aliasTargetId },
} = await linksClient.get(savedObjectId);
if (savedObject.error) throw savedObject.error;
const { attributes } = injectReferences(savedObject);
return {
attributes,
metaInfo: {
sharingSavedObjectProps: {
aliasTargetId,
outcome,
aliasPurpose,
sourceId: savedObjectId,
},
},
};
}

View file

@ -24,7 +24,7 @@ import {
import { linksClient } from '../content_management';
import { getMockLinksParentApi } from '../mocks';
const links: Link[] = [
const getLinks: () => Link[] = () => [
{
id: '001',
order: 0,
@ -54,7 +54,7 @@ const links: Link[] = [
},
];
const resolvedLinks: ResolvedLink[] = [
const getResolvedLinks: () => ResolvedLink[] = () => [
{
id: '001',
order: 0,
@ -105,33 +105,35 @@ const references = [
jest.mock('../lib/resolve_links', () => {
return {
resolveLinks: jest.fn().mockResolvedValue(resolvedLinks),
resolveLinks: jest.fn().mockResolvedValue(getResolvedLinks()),
};
});
jest.mock('../content_management', () => {
return {
loadFromLibrary: jest.fn((savedObjectId) => {
return Promise.resolve({
attributes: {
title: 'links 001',
description: 'some links',
links,
layout: 'vertical',
},
metaInfo: {
sharingSavedObjectProps: {
linksClient: {
create: jest.fn().mockResolvedValue({ item: { id: '333' } }),
update: jest.fn().mockResolvedValue({ item: { id: '123' } }),
get: jest.fn((savedObjectId) => {
return Promise.resolve({
item: {
id: savedObjectId,
attributes: {
title: 'links 001',
description: 'some links',
links: getLinks(),
layout: 'vertical',
},
references,
},
meta: {
aliasTargetId: '123',
outcome: 'exactMatch',
aliasPurpose: 'sharing',
sourceId: savedObjectId,
},
},
});
}),
linksClient: {
create: jest.fn().mockResolvedValue({ item: { id: '333' } }),
update: jest.fn().mockResolvedValue({ item: { id: '123' } }),
});
}),
},
};
});
@ -157,7 +159,7 @@ describe('getLinksEmbeddableFactory', () => {
defaultPanelTitle: 'links 001',
defaultPanelDescription: 'some links',
layout: 'vertical',
links: resolvedLinks,
links: getResolvedLinks(),
description: 'just a few links',
title: 'my links',
savedObjectId: '123',
@ -172,7 +174,7 @@ describe('getLinksEmbeddableFactory', () => {
test('deserializeState', async () => {
const deserializedState = await factory.deserializeState({
rawState,
references,
references: [], // no references passed because the panel is by reference
});
expect(deserializedState).toEqual({
...expectedRuntimeState,
@ -240,7 +242,7 @@ describe('getLinksEmbeddableFactory', () => {
attributes: {
description: 'some links',
title: 'links 001',
links,
links: getLinks(),
layout: 'vertical',
},
},
@ -254,7 +256,7 @@ describe('getLinksEmbeddableFactory', () => {
describe('by value embeddable', () => {
const rawState = {
attributes: {
links,
links: getLinks(),
layout: 'horizontal',
},
description: 'just a few links',
@ -265,7 +267,7 @@ describe('getLinksEmbeddableFactory', () => {
defaultPanelTitle: undefined,
defaultPanelDescription: undefined,
layout: 'horizontal',
links: resolvedLinks,
links: getResolvedLinks(),
description: 'just a few links',
title: 'my links',
savedObjectId: undefined,
@ -315,7 +317,7 @@ describe('getLinksEmbeddableFactory', () => {
description: 'just a few links',
hidePanelTitles: undefined,
attributes: {
links,
links: getLinks(),
layout: 'horizontal',
},
},
@ -342,7 +344,7 @@ describe('getLinksEmbeddableFactory', () => {
expect(linksClient.create).toHaveBeenCalledWith({
data: {
title: 'some new title',
links,
links: getLinks(),
layout: 'horizontal',
},
options: { references },

View file

@ -74,9 +74,10 @@ export const getLinksEmbeddableFactory = () => {
const { title, description } = serializedState.rawState;
if (linksSerializeStateIsByReference(state)) {
const attributes = await deserializeLinksSavedObject(state);
const linksSavedObject = await linksClient.get(state.savedObjectId);
const runtimeState = await deserializeLinksSavedObject(linksSavedObject.item);
return {
...attributes,
...runtimeState,
title,
description,
};

View file

@ -6,8 +6,10 @@
* Side Public License, v 1.
*/
import { loadFromLibrary } from '../content_management';
import { LinksByReferenceSerializedState, LinksSerializedState } from '../types';
import { SOWithMetadata } from '@kbn/content-management-utils';
import { LinksAttributes } from '../../common/content_management';
import { injectReferences } from '../../common/persistable_state';
import { LinksByReferenceSerializedState, LinksRuntimeState, LinksSerializedState } from '../types';
import { resolveLinks } from './resolve_links';
export const linksSerializeStateIsByReference = (
@ -16,8 +18,11 @@ export const linksSerializeStateIsByReference = (
return Boolean(state && (state as LinksByReferenceSerializedState).savedObjectId !== undefined);
};
export const deserializeLinksSavedObject = async (state: LinksByReferenceSerializedState) => {
const { attributes } = await loadFromLibrary(state.savedObjectId);
export const deserializeLinksSavedObject = async (
linksSavedObject: SOWithMetadata<LinksAttributes>
): Promise<LinksRuntimeState> => {
if (linksSavedObject.error) throw linksSavedObject.error;
const { attributes } = injectReferences(linksSavedObject);
const links = await resolveLinks(attributes.links ?? []);
@ -26,7 +31,7 @@ export const deserializeLinksSavedObject = async (state: LinksByReferenceSeriali
return {
links,
layout,
savedObjectId: state.savedObjectId,
savedObjectId: linksSavedObject.id,
defaultPanelTitle,
defaultPanelDescription,
};

View file

@ -14,9 +14,15 @@ export const serializeLinksAttributes = (
state: LinksRuntimeState,
shouldExtractReferences: boolean = true
) => {
const linksToSave: Link[] | undefined = state.links?.map(
({ title, description, error, ...linkToSave }) => linkToSave
);
const linksToSave: Link[] | undefined = state.links
?.map(({ title, description, error, ...linkToSave }) => linkToSave)
?.map(
// fiilter out null values which may have been introduced by the session state backup (undefined values are serialized as null).
(link) =>
Object.fromEntries(
Object.entries(link).filter(([key, value]) => value !== null)
) as unknown as Link
);
const attributes = {
title: state.defaultPanelTitle,
description: state.defaultPanelDescription,

View file

@ -22,17 +22,14 @@ import { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
import { VisualizationsSetup } from '@kbn/visualizations-plugin/public';
import { UiActionsPublicStart } from '@kbn/ui-actions-plugin/public/plugin';
import { LinksSerializedState } from './types';
import { LinksRuntimeState } from './types';
import { APP_ICON, APP_NAME, CONTENT_ID, LATEST_VERSION } from '../common';
import { LinksCrudTypes } from '../common/content_management';
import { LinksStrings } from './components/links_strings';
import { getLinksClient } from './content_management/links_content_management_client';
import { setKibanaServices, untilPluginStartServicesReady } from './services/kibana_services';
import { registerCreateLinksPanelAction } from './actions/create_links_panel_action';
import {
deserializeLinksSavedObject,
linksSerializeStateIsByReference,
} from './lib/deserialize_from_library';
import { deserializeLinksSavedObject } from './lib/deserialize_from_library';
export interface LinksSetupDependencies {
embeddable: EmbeddableSetup;
visualizations: VisualizationsSetup;
@ -64,10 +61,11 @@ export class LinksPlugin
});
plugins.embeddable.registerReactEmbeddableSavedObject({
onAdd: (container, savedObject) => {
container.addNewPanel({
onAdd: async (container, savedObject) => {
const initialState = await deserializeLinksSavedObject(savedObject);
container.addNewPanel<LinksRuntimeState>({
panelType: CONTENT_ID,
initialState: { savedObjectId: savedObject.id },
initialState,
});
},
embeddableType: CONTENT_ID,
@ -103,7 +101,8 @@ export class LinksPlugin
editor: {
onEdit: async (savedObjectId: string) => {
const { openEditorFlyout } = await import('./editor/open_editor_flyout');
const initialState = await deserializeLinksSavedObject({ savedObjectId });
const linksSavedObject = await getLinksClient().get(savedObjectId);
const initialState = await deserializeLinksSavedObject(linksSavedObject.item);
await openEditorFlyout({ initialState });
},
},
@ -128,14 +127,11 @@ export class LinksPlugin
plugins.dashboard.registerDashboardPanelPlacementSetting(
CONTENT_ID,
async (serializedState?: LinksSerializedState) => {
if (!serializedState) return {};
const { links, layout } = linksSerializeStateIsByReference(serializedState)
? await deserializeLinksSavedObject(serializedState)
: serializedState.attributes;
const isHorizontal = layout === 'horizontal';
async (runtimeState?: LinksRuntimeState) => {
if (!runtimeState) return {};
const isHorizontal = runtimeState.layout === 'horizontal';
const width = isHorizontal ? DASHBOARD_GRID_COLUMN_COUNT : 8;
const height = isHorizontal ? 4 : (links?.length ?? 1 * 3) + 4;
const height = isHorizontal ? 4 : (runtimeState.links?.length ?? 1 * 3) + 4;
return { width, height, strategy: PanelPlacementStrategy.placeAtTop };
}
);