From 7e19cc566065962f5072f33506efeecdbaee14ec Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Thu, 20 Jun 2024 10:18:42 -0400 Subject: [PATCH] [Embeddables Rebuild] Clone panels with runtime state (#186052) Makes the clone operation use runtime state rather than serialized state. --- .../react_controls/control_renderer.tsx | 2 +- .../saved_book_react_embeddable.tsx | 11 +- .../react_embeddables/saved_book/types.ts | 2 +- .../interfaces/child_state.ts | 4 +- .../interfaces/serialized_state.ts | 4 +- .../presentation_publishing/index.ts | 18 +- .../interfaces/has_library_transforms.ts | 8 +- .../interfaces/titles/titles_api.ts | 8 + .../unlink_from_library_action.tsx | 2 +- .../api/duplicate_dashboard_panel.test.ts | 177 ---------- .../api/duplicate_dashboard_panel.test.tsx | 328 ++++++++++++++++++ .../api/duplicate_dashboard_panel.ts | 46 ++- .../create/create_dashboard.test.ts | 2 +- .../embeddable/create/create_dashboard.ts | 8 +- .../embeddable/dashboard_container.tsx | 12 +- .../dashboard_backup_service.ts | 2 +- .../react_embeddable_renderer.tsx | 4 +- .../react_embeddable_state.ts | 7 +- .../public/react_embeddable_system/types.ts | 2 +- .../kibana_utils/public/storage/storage.ts | 8 +- 20 files changed, 441 insertions(+), 214 deletions(-) delete mode 100644 src/plugins/dashboard/public/dashboard_container/embeddable/api/duplicate_dashboard_panel.test.ts create mode 100644 src/plugins/dashboard/public/dashboard_container/embeddable/api/duplicate_dashboard_panel.test.tsx diff --git a/examples/controls_example/public/react_controls/control_renderer.tsx b/examples/controls_example/public/react_controls/control_renderer.tsx index 471b12894bae7..1629673e64f7b 100644 --- a/examples/controls_example/public/react_controls/control_renderer.tsx +++ b/examples/controls_example/public/react_controls/control_renderer.tsx @@ -59,7 +59,7 @@ export const ControlRenderer = < return fullApi; }; - const { rawState: initialState } = parentApi.getSerializedStateForChild(uuid); + const { rawState: initialState } = parentApi.getSerializedStateForChild(uuid) ?? {}; const { api, Component } = factory.buildControl( initialState as unknown as StateType, diff --git a/examples/embeddable_examples/public/react_embeddables/saved_book/saved_book_react_embeddable.tsx b/examples/embeddable_examples/public/react_embeddables/saved_book/saved_book_react_embeddable.tsx index 0a9c3c4d7117b..af96793e6522e 100644 --- a/examples/embeddable_examples/public/react_embeddables/saved_book/saved_book_react_embeddable.tsx +++ b/examples/embeddable_examples/public/react_embeddables/saved_book/saved_book_react_embeddable.tsx @@ -35,7 +35,7 @@ import { const bookSerializedStateIsByReference = ( state?: BookSerializedState ): state is BookByReferenceSerializedState => { - return Boolean(state && (state as BookByReferenceSerializedState).savedBookId !== undefined); + return Boolean(state && (state as BookByReferenceSerializedState).savedBookId); }; export const getSavedBookEmbeddableFactory = (core: CoreStart) => { @@ -86,7 +86,7 @@ export const getSavedBookEmbeddableFactory = (core: CoreStart) => { defaultMessage: 'book', }), serializeState: async () => { - if (savedBookId$.value === undefined) { + if (!Boolean(savedBookId$.value)) { // if this book is currently by value, we serialize the entire state. const bookByValueState: BookByValueSerializedState = { attributes: serializeBookAttributes(bookAttributesManager), @@ -97,7 +97,7 @@ export const getSavedBookEmbeddableFactory = (core: CoreStart) => { // if this book is currently by reference, we serialize the reference and write to the external store. const bookByReferenceState: BookByReferenceSerializedState = { - savedBookId: savedBookId$.value, + savedBookId: savedBookId$.value!, ...serializeTitles(), }; @@ -123,6 +123,11 @@ export const getSavedBookEmbeddableFactory = (core: CoreStart) => { unlinkFromLibrary: () => { savedBookId$.next(undefined); }, + getByValueRuntimeSnapshot: () => { + const snapshot = api.snapshotRuntimeState(); + delete snapshot.savedBookId; + return snapshot; + }, }, { savedBookId: [savedBookId$, (val) => savedBookId$.next(val)], diff --git a/examples/embeddable_examples/public/react_embeddables/saved_book/types.ts b/examples/embeddable_examples/public/react_embeddables/saved_book/types.ts index 362d95604cf97..00290698a9b1d 100644 --- a/examples/embeddable_examples/public/react_embeddables/saved_book/types.ts +++ b/examples/embeddable_examples/public/react_embeddables/saved_book/types.ts @@ -47,4 +47,4 @@ export interface BookRuntimeState export type BookApi = DefaultEmbeddableApi & HasEditCapabilities & - HasInPlaceLibraryTransforms; + HasInPlaceLibraryTransforms; diff --git a/packages/presentation/presentation_containers/interfaces/child_state.ts b/packages/presentation/presentation_containers/interfaces/child_state.ts index c197974c67add..3a399d8a3c913 100644 --- a/packages/presentation/presentation_containers/interfaces/child_state.ts +++ b/packages/presentation/presentation_containers/interfaces/child_state.ts @@ -9,7 +9,9 @@ import { SerializedPanelState } from './serialized_state'; export interface HasSerializedChildState { - getSerializedStateForChild: (childId: string) => SerializedPanelState; + getSerializedStateForChild: ( + childId: string + ) => SerializedPanelState | undefined; } export interface HasRuntimeChildState { diff --git a/packages/presentation/presentation_containers/interfaces/serialized_state.ts b/packages/presentation/presentation_containers/interfaces/serialized_state.ts index 593362ab6a7e1..be9e9d4236c33 100644 --- a/packages/presentation/presentation_containers/interfaces/serialized_state.ts +++ b/packages/presentation/presentation_containers/interfaces/serialized_state.ts @@ -32,8 +32,8 @@ export const apiHasSerializableState = (api: unknown | null): api is HasSerializ export interface HasSnapshottableState { /** - * Serializes all runtime state exactly as it appears. This could be used - * to rehydrate a component's state without needing to deserialize it. + * Serializes all runtime state exactly as it appears. This can be used + * to rehydrate a component's state without needing to serialize then deserialize it. */ snapshotRuntimeState: () => RuntimeState; } diff --git a/packages/presentation/presentation_publishing/index.ts b/packages/presentation/presentation_publishing/index.ts index e3136215f3d40..d56cd53b67a54 100644 --- a/packages/presentation/presentation_publishing/index.ts +++ b/packages/presentation/presentation_publishing/index.ts @@ -16,8 +16,8 @@ export interface EmbeddableApiContext { export { getInitialValuesFromComparators, - runComparators, getUnchangingComparator, + runComparators, type ComparatorDefinition, type ComparatorFunction, type StateComparators, @@ -35,22 +35,22 @@ export { type SerializedTimeRange, } from './interfaces/fetch/initialize_time_range'; export { - apiPublishesPartialUnifiedSearch, apiPublishesFilters, + apiPublishesPartialUnifiedSearch, apiPublishesTimeRange, apiPublishesUnifiedSearch, apiPublishesWritableUnifiedSearch, useSearchApi, - type PublishesTimeRange, type PublishesFilters, + type PublishesTimeRange, + type PublishesTimeslice, type PublishesUnifiedSearch, type PublishesWritableUnifiedSearch, - type PublishesTimeslice, } from './interfaces/fetch/publishes_unified_search'; export { apiHasAppContext, - type HasAppContext, type EmbeddableAppContext, + type HasAppContext, } from './interfaces/has_app_context'; export { apiHasDisableTriggers, @@ -63,9 +63,9 @@ export { type HasExecutionContext, } from './interfaces/has_execution_context'; export { + apiHasInPlaceLibraryTransforms, apiHasLegacyLibraryTransforms, apiHasLibraryTransforms, - apiHasInPlaceLibraryTransforms, type HasInPlaceLibraryTransforms, type HasLegacyLibraryTransforms, type HasLibraryTransforms, @@ -130,7 +130,11 @@ export { type PublishesPanelTitle, type PublishesWritablePanelTitle, } from './interfaces/titles/publishes_panel_title'; -export { initializeTitles, type SerializedTitles } from './interfaces/titles/titles_api'; +export { + initializeTitles, + stateHasTitles, + type SerializedTitles, +} from './interfaces/titles/titles_api'; export { useBatchedOptionalPublishingSubjects, useBatchedPublishingSubjects, diff --git a/packages/presentation/presentation_publishing/interfaces/has_library_transforms.ts b/packages/presentation/presentation_publishing/interfaces/has_library_transforms.ts index 17d48eca51be7..778748f0e1f2c 100644 --- a/packages/presentation/presentation_publishing/interfaces/has_library_transforms.ts +++ b/packages/presentation/presentation_publishing/interfaces/has_library_transforms.ts @@ -34,7 +34,7 @@ interface LibraryTransformGuards { * APIs that inherit this interface can be linked to and unlinked from the library in place without * re-initialization. */ -export interface HasInPlaceLibraryTransforms +export interface HasInPlaceLibraryTransforms extends Partial, DuplicateTitleCheck { /** @@ -49,6 +49,11 @@ export interface HasInPlaceLibraryTransforms */ saveToLibrary: (title: string) => Promise; + /** + * gets a snapshot of this embeddable's runtime state without any state that links it to a library item. + */ + getByValueRuntimeSnapshot: () => RuntimeState; + /** * Un-links this embeddable from the library. This method is optional, and only needed if the Embeddable * is not meant to be re-initialized as part of the unlink operation. If the embeddable needs to be re-initialized @@ -69,6 +74,7 @@ export const apiHasInPlaceLibraryTransforms = ( }; /** + * @deprecated use HasInPlaceLibraryTransforms instead * APIs that inherit this interface can be linked to and unlinked from the library. After the save or unlink * operation, the embeddable will be reinitialized. */ diff --git a/packages/presentation/presentation_publishing/interfaces/titles/titles_api.ts b/packages/presentation/presentation_publishing/interfaces/titles/titles_api.ts index 026ceb1cc84fc..772e7fe6b113a 100644 --- a/packages/presentation/presentation_publishing/interfaces/titles/titles_api.ts +++ b/packages/presentation/presentation_publishing/interfaces/titles/titles_api.ts @@ -17,6 +17,14 @@ export interface SerializedTitles { hidePanelTitles?: boolean; } +export const stateHasTitles = (state: unknown): state is SerializedTitles => { + return ( + (state as SerializedTitles)?.title !== undefined || + (state as SerializedTitles)?.description !== undefined || + (state as SerializedTitles)?.hidePanelTitles !== undefined + ); +}; + export interface TitlesApi extends PublishesWritablePanelTitle, PublishesWritablePanelDescription {} export const initializeTitles = ( diff --git a/src/plugins/dashboard/public/dashboard_actions/unlink_from_library_action.tsx b/src/plugins/dashboard/public/dashboard_actions/unlink_from_library_action.tsx index 17612fa9c6ef7..77aa1e5036c7b 100644 --- a/src/plugins/dashboard/public/dashboard_actions/unlink_from_library_action.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/unlink_from_library_action.tsx @@ -77,7 +77,7 @@ export class UnlinkFromLibraryAction implements Action { return api.canUnlinkFromLibrary(); } else if (apiHasInPlaceLibraryTransforms(api)) { const canUnLink = api.canUnlinkFromLibrary ? await api.canUnlinkFromLibrary() : true; - return canUnLink && api.libraryId$.value !== undefined; + return canUnLink && Boolean(api.libraryId$.value); } throw new IncompatibleActionError(); } diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/api/duplicate_dashboard_panel.test.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/api/duplicate_dashboard_panel.test.ts deleted file mode 100644 index 256d03681447d..0000000000000 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/api/duplicate_dashboard_panel.test.ts +++ /dev/null @@ -1,177 +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 { CoreStart } from '@kbn/core/public'; -import { coreMock } from '@kbn/core/public/mocks'; -import { isErrorEmbeddable, ReferenceOrValueEmbeddable } from '@kbn/embeddable-plugin/public'; -import { - ContactCardEmbeddable, - ContactCardEmbeddableFactory, - ContactCardEmbeddableInput, - ContactCardEmbeddableOutput, - CONTACT_CARD_EMBEDDABLE, -} from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables'; -import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks'; -import { duplicateDashboardPanel, incrementPanelTitle } from './duplicate_dashboard_panel'; -import { buildMockDashboard, getSampleDashboardPanel } from '../../../mocks'; -import { pluginServices } from '../../../services/plugin_services'; -import { DashboardContainer } from '../dashboard_container'; - -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); - - pluginServices.getServices().embeddable.getEmbeddableFactory = jest - .fn() - .mockReturnValue(mockEmbeddableFactory); - container = buildMockDashboard({ - overrides: { - panels: { - '123': getSampleDashboardPanel({ - explicitInput: { firstName: 'Kibanana', id: '123' }, - type: CONTACT_CARD_EMBEDDABLE, - }), - }, - }, - }); - - const refOrValContactCardEmbeddable = await container.addNewEmbeddable< - ContactCardEmbeddableInput, - ContactCardEmbeddableOutput, - ContactCardEmbeddable - >(CONTACT_CARD_EMBEDDABLE, { - firstName: 'RefOrValEmbeddable', - }); - - const nonRefOrValueContactCard = await container.addNewEmbeddable< - ContactCardEmbeddableInput, - ContactCardEmbeddableOutput, - ContactCardEmbeddable - >(CONTACT_CARD_EMBEDDABLE, { - firstName: 'Not a refOrValEmbeddable', - }); - - if ( - isErrorEmbeddable(refOrValContactCardEmbeddable) || - isErrorEmbeddable(nonRefOrValueContactCard) - ) { - throw new Error('Failed to create embeddables'); - } else { - genericEmbeddable = nonRefOrValueContactCard; - byRefOrValEmbeddable = embeddablePluginMock.mockRefOrValEmbeddable< - ContactCardEmbeddable, - ContactCardEmbeddableInput - >(refOrValContactCardEmbeddable, { - mockedByReferenceInput: { - savedObjectId: 'testSavedObjectId', - id: refOrValContactCardEmbeddable.id, - }, - mockedByValueInput: { firstName: 'RefOrValEmbeddable', id: refOrValContactCardEmbeddable.id }, - }); - jest.spyOn(byRefOrValEmbeddable, 'getInputAsValueType'); - } -}); - -test('Duplication adds a new embeddable', async () => { - const originalPanelCount = Object.keys(container.getInput().panels).length; - const originalPanelKeySet = new Set(Object.keys(container.getInput().panels)); - await duplicateDashboardPanel.bind(container)(byRefOrValEmbeddable.id); - - expect(Object.keys(container.getInput().panels).length).toEqual(originalPanelCount + 1); - const newPanelId = Object.keys(container.getInput().panels).find( - (key) => !originalPanelKeySet.has(key) - ); - expect(newPanelId).toBeDefined(); - const newPanel = container.getInput().panels[newPanelId!]; - expect(newPanel.type).toEqual(byRefOrValEmbeddable.type); -}); - -test('Duplicates a RefOrVal embeddable by value', async () => { - const originalPanelKeySet = new Set(Object.keys(container.getInput().panels)); - await duplicateDashboardPanel.bind(container)(byRefOrValEmbeddable.id); - const newPanelId = Object.keys(container.getInput().panels).find( - (key) => !originalPanelKeySet.has(key) - ); - - const originalFirstName = ( - container.getInput().panels[byRefOrValEmbeddable.id].explicitInput as ContactCardEmbeddableInput - ).firstName; - - const newFirstName = ( - container.getInput().panels[newPanelId!].explicitInput as ContactCardEmbeddableInput - ).firstName; - - expect(byRefOrValEmbeddable.getInputAsValueType).toHaveBeenCalled(); - - expect(originalFirstName).toEqual(newFirstName); - expect(container.getInput().panels[newPanelId!].type).toEqual(byRefOrValEmbeddable.type); -}); - -test('Duplicates a non RefOrVal embeddable by value', async () => { - const originalPanelKeySet = new Set(Object.keys(container.getInput().panels)); - await duplicateDashboardPanel.bind(container)(genericEmbeddable.id); - const newPanelId = Object.keys(container.getInput().panels).find( - (key) => !originalPanelKeySet.has(key) - ); - - const originalFirstName = ( - container.getInput().panels[genericEmbeddable.id].explicitInput as ContactCardEmbeddableInput - ).firstName; - - const newFirstName = ( - container.getInput().panels[newPanelId!].explicitInput as ContactCardEmbeddableInput - ).firstName; - - expect(originalFirstName).toEqual(newFirstName); - expect(container.getInput().panels[newPanelId!].type).toEqual(genericEmbeddable.type); -}); - -test('Gets a unique title from the dashboard', async () => { - expect(await incrementPanelTitle(container, '')).toEqual(''); - - container.getPanelTitles = jest.fn().mockImplementation(() => { - return ['testDuplicateTitle', 'testDuplicateTitle (copy)', 'testUniqueTitle']; - }); - expect(await incrementPanelTitle(container, 'testUniqueTitle')).toEqual('testUniqueTitle (copy)'); - expect(await incrementPanelTitle(container, 'testDuplicateTitle')).toEqual( - 'testDuplicateTitle (copy 1)' - ); - - container.getPanelTitles = jest.fn().mockImplementation(() => { - return ['testDuplicateTitle', 'testDuplicateTitle (copy)'].concat( - Array.from([...Array(39)], (_, index) => `testDuplicateTitle (copy ${index + 1})`) - ); - }); - expect(await incrementPanelTitle(container, 'testDuplicateTitle')).toEqual( - 'testDuplicateTitle (copy 40)' - ); - expect(await incrementPanelTitle(container, 'testDuplicateTitle (copy 100)')).toEqual( - 'testDuplicateTitle (copy 40)' - ); - - container.getPanelTitles = jest.fn().mockImplementation(() => { - return ['testDuplicateTitle (copy 100)']; - }); - expect(await incrementPanelTitle(container, 'testDuplicateTitle')).toEqual( - 'testDuplicateTitle (copy 101)' - ); - expect(await incrementPanelTitle(container, 'testDuplicateTitle (copy 100)')).toEqual( - 'testDuplicateTitle (copy 101)' - ); -}); diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/api/duplicate_dashboard_panel.test.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/api/duplicate_dashboard_panel.test.tsx new file mode 100644 index 0000000000000..39bb0f754cfbe --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/api/duplicate_dashboard_panel.test.tsx @@ -0,0 +1,328 @@ +/* + * 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 { CoreStart } from '@kbn/core/public'; +import { coreMock } from '@kbn/core/public/mocks'; +import { + isErrorEmbeddable, + ReactEmbeddableFactory, + ReferenceOrValueEmbeddable, +} from '@kbn/embeddable-plugin/public'; +import { + ContactCardEmbeddable, + ContactCardEmbeddableFactory, + ContactCardEmbeddableInput, + ContactCardEmbeddableOutput, + CONTACT_CARD_EMBEDDABLE, +} 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 { pluginServices } from '../../../services/plugin_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); + + pluginServices.getServices().embeddable.getEmbeddableFactory = jest + .fn() + .mockReturnValue(mockEmbeddableFactory); + container = buildMockDashboard({ + overrides: { + panels: { + '123': getSampleDashboardPanel({ + explicitInput: { firstName: 'Kibanana', id: '123' }, + type: CONTACT_CARD_EMBEDDABLE, + }), + }, + }, + }); + + const refOrValContactCardEmbeddable = await container.addNewEmbeddable< + ContactCardEmbeddableInput, + ContactCardEmbeddableOutput, + ContactCardEmbeddable + >(CONTACT_CARD_EMBEDDABLE, { + firstName: 'RefOrValEmbeddable', + }); + + const nonRefOrValueContactCard = await container.addNewEmbeddable< + ContactCardEmbeddableInput, + ContactCardEmbeddableOutput, + ContactCardEmbeddable + >(CONTACT_CARD_EMBEDDABLE, { + firstName: 'Not a refOrValEmbeddable', + }); + + if ( + isErrorEmbeddable(refOrValContactCardEmbeddable) || + isErrorEmbeddable(nonRefOrValueContactCard) + ) { + throw new Error('Failed to create embeddables'); + } else { + genericEmbeddable = nonRefOrValueContactCard; + byRefOrValEmbeddable = embeddablePluginMock.mockRefOrValEmbeddable< + ContactCardEmbeddable, + ContactCardEmbeddableInput + >(refOrValContactCardEmbeddable, { + mockedByReferenceInput: { + savedObjectId: 'testSavedObjectId', + id: refOrValContactCardEmbeddable.id, + }, + mockedByValueInput: { + firstName: 'RefOrValEmbeddable', + id: refOrValContactCardEmbeddable.id, + }, + }); + jest.spyOn(byRefOrValEmbeddable, 'getInputAsValueType'); + } + }); + test('Duplication adds a new embeddable', async () => { + const originalPanelCount = Object.keys(container.getInput().panels).length; + const originalPanelKeySet = new Set(Object.keys(container.getInput().panels)); + await duplicateDashboardPanel.bind(container)(byRefOrValEmbeddable.id); + + expect(Object.keys(container.getInput().panels).length).toEqual(originalPanelCount + 1); + const newPanelId = Object.keys(container.getInput().panels).find( + (key) => !originalPanelKeySet.has(key) + ); + expect(newPanelId).toBeDefined(); + const newPanel = container.getInput().panels[newPanelId!]; + expect(newPanel.type).toEqual(byRefOrValEmbeddable.type); + }); + + test('Duplicates a RefOrVal embeddable by value', async () => { + const originalPanelKeySet = new Set(Object.keys(container.getInput().panels)); + await duplicateDashboardPanel.bind(container)(byRefOrValEmbeddable.id); + const newPanelId = Object.keys(container.getInput().panels).find( + (key) => !originalPanelKeySet.has(key) + ); + + const originalFirstName = ( + container.getInput().panels[byRefOrValEmbeddable.id] + .explicitInput as ContactCardEmbeddableInput + ).firstName; + + const newFirstName = ( + container.getInput().panels[newPanelId!].explicitInput as ContactCardEmbeddableInput + ).firstName; + + expect(byRefOrValEmbeddable.getInputAsValueType).toHaveBeenCalled(); + + expect(originalFirstName).toEqual(newFirstName); + expect(container.getInput().panels[newPanelId!].type).toEqual(byRefOrValEmbeddable.type); + }); + + test('Duplicates a non RefOrVal embeddable by value', async () => { + const originalPanelKeySet = new Set(Object.keys(container.getInput().panels)); + await duplicateDashboardPanel.bind(container)(genericEmbeddable.id); + const newPanelId = Object.keys(container.getInput().panels).find( + (key) => !originalPanelKeySet.has(key) + ); + + const originalFirstName = ( + container.getInput().panels[genericEmbeddable.id].explicitInput as ContactCardEmbeddableInput + ).firstName; + + const newFirstName = ( + container.getInput().panels[newPanelId!].explicitInput as ContactCardEmbeddableInput + ).firstName; + + expect(originalFirstName).toEqual(newFirstName); + expect(container.getInput().panels[newPanelId!].type).toEqual(genericEmbeddable.type); + }); + + test('Gets a unique title from the dashboard', async () => { + expect(await incrementPanelTitle(container, '')).toEqual(''); + + container.getPanelTitles = jest.fn().mockImplementation(() => { + return ['testDuplicateTitle', 'testDuplicateTitle (copy)', 'testUniqueTitle']; + }); + expect(await incrementPanelTitle(container, 'testUniqueTitle')).toEqual( + 'testUniqueTitle (copy)' + ); + expect(await incrementPanelTitle(container, 'testDuplicateTitle')).toEqual( + 'testDuplicateTitle (copy 1)' + ); + + container.getPanelTitles = jest.fn().mockImplementation(() => { + return ['testDuplicateTitle', 'testDuplicateTitle (copy)'].concat( + Array.from([...Array(39)], (_, index) => `testDuplicateTitle (copy ${index + 1})`) + ); + }); + expect(await incrementPanelTitle(container, 'testDuplicateTitle')).toEqual( + 'testDuplicateTitle (copy 40)' + ); + expect(await incrementPanelTitle(container, 'testDuplicateTitle (copy 100)')).toEqual( + 'testDuplicateTitle (copy 40)' + ); + + container.getPanelTitles = jest.fn().mockImplementation(() => { + return ['testDuplicateTitle (copy 100)']; + }); + expect(await incrementPanelTitle(container, 'testDuplicateTitle')).toEqual( + 'testDuplicateTitle (copy 101)' + ); + expect(await incrementPanelTitle(container, 'testDuplicateTitle (copy 100)')).toEqual( + 'testDuplicateTitle (copy 101)' + ); + }); +}); + +describe('React embeddables', () => { + const testId = '1234'; + const buildDashboardWithReactEmbeddable = async ( + testType: string, + mockApi: BuildReactEmbeddableApiRegistration<{}, {}, Api> + ) => { + const fullApi$ = new Subject>(); + const reactEmbeddableFactory: ReactEmbeddableFactory<{}, {}, Api> = { + type: testType, + deserializeState: jest.fn().mockImplementation((state) => state.rawState), + buildEmbeddable: async (state, registerApi) => { + const fullApi = registerApi( + { + ...mockApi, + }, + {} + ); + fullApi$.next(fullApi); + fullApi$.complete(); + return { + Component: () =>
TEST DUPLICATE
, + api: fullApi, + }; + }, + }; + registerReactEmbeddableFactory(testType, async () => reactEmbeddableFactory); + const dashboard = buildMockDashboard({ + overrides: { + panels: { + [testId]: getSampleDashboardPanel({ + explicitInput: { id: testId }, + type: testType, + }), + }, + }, + }); + // render a fake Dashboard to initialize react embeddables + const FakeDashboard = () => { + return ( +
+ {Object.keys(dashboard.getInput().panels).map((panelId) => { + const panel = dashboard.getInput().panels[panelId]; + return ( +
+ dashboard.children$.next({ [panelId]: api })} + getParentApi={() => ({ + getSerializedStateForChild: () => + panel.explicitInput as unknown as SerializedPanelState | undefined, + })} + /> + + ); + })} + + ); + }; + render(); + + return { dashboard, apiPromise: lastValueFrom(fullApi$) }; + }; + + it('Duplicates child without library transforms', async () => { + const mockApi = { + serializeState: jest.fn().mockImplementation(() => ({ rawState: {} })), + }; + const { dashboard, apiPromise } = await buildDashboardWithReactEmbeddable( + 'byValueOnly', + mockApi + ); + const api = await apiPromise; + + const snapshotSpy = jest.spyOn(api, 'snapshotRuntimeState'); + + await duplicateDashboardPanel.bind(dashboard)(testId); + + expect(snapshotSpy).toHaveBeenCalled(); + expect(Object.keys(dashboard.getInput().panels).length).toBe(2); + }); + + it('Duplicates child with library transforms', async () => { + const libraryTransformsMockApi: BuildReactEmbeddableApiRegistration< + {}, + {}, + DefaultEmbeddableApi & HasLibraryTransforms + > = { + serializeState: jest.fn().mockImplementation(() => ({ rawState: {} })), + saveToLibrary: jest.fn(), + getByReferenceState: jest.fn(), + getByValueState: jest.fn(), + canLinkToLibrary: jest.fn(), + canUnlinkFromLibrary: jest.fn(), + checkForDuplicateTitle: jest.fn(), + }; + const { dashboard, apiPromise } = await buildDashboardWithReactEmbeddable( + 'libraryTransforms', + libraryTransformsMockApi + ); + await apiPromise; + + await duplicateDashboardPanel.bind(dashboard)(testId); + expect(libraryTransformsMockApi.getByValueState).toHaveBeenCalled(); + }); + + it('Duplicates a child with in place library transforms', async () => { + const inPlaceLibraryTransformsMockApi: BuildReactEmbeddableApiRegistration< + {}, + {}, + DefaultEmbeddableApi & HasInPlaceLibraryTransforms + > = { + unlinkFromLibrary: jest.fn(), + saveToLibrary: jest.fn(), + checkForDuplicateTitle: jest.fn(), + libraryId$: new BehaviorSubject(''), + getByValueRuntimeSnapshot: jest.fn(), + serializeState: jest.fn().mockImplementation(() => ({ rawState: {} })), + }; + const { dashboard, apiPromise } = await buildDashboardWithReactEmbeddable( + 'inPlaceLibraryTransforms', + inPlaceLibraryTransformsMockApi + ); + await apiPromise; + + await duplicateDashboardPanel.bind(dashboard)(testId); + expect(inPlaceLibraryTransformsMockApi.getByValueRuntimeSnapshot).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/api/duplicate_dashboard_panel.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/api/duplicate_dashboard_panel.ts index 225e89109639a..e0840ad0912b3 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/api/duplicate_dashboard_panel.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/api/duplicate_dashboard_panel.ts @@ -7,7 +7,14 @@ */ import { isReferenceOrValueEmbeddable, PanelNotFoundError } from '@kbn/embeddable-plugin/public'; -import { apiPublishesPanelTitle, getPanelTitle } from '@kbn/presentation-publishing'; +import { apiHasSnapshottableState } from '@kbn/presentation-containers/interfaces/serialized_state'; +import { + apiHasInPlaceLibraryTransforms, + apiHasLibraryTransforms, + apiPublishesPanelTitle, + getPanelTitle, + stateHasTitles, +} from '@kbn/presentation-publishing'; import { filter, map, max } from 'lodash'; import { v4 as uuidv4 } from 'uuid'; import { DashboardPanelState, prefixReferencesFromPanel } from '../../../../common'; @@ -52,18 +59,45 @@ const duplicateReactEmbeddableInput = async ( 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); - const id = uuidv4(); - if (panelToClone.references) { - dashboard.savedObjectReferences.push(...prefixReferencesFromPanel(id, panelToClone.references)); + + /** + * 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: { - ...panelToClone.explicitInput, - title: newTitle, id, }, }; diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.test.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.test.ts index f20e9f8c46c1b..e3a321f2355df 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.test.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.test.ts @@ -275,7 +275,7 @@ test('pulls panels from override input', async () => { // instead, the unsaved changes for React embeddables should be applied to the "restored runtime state" property of the Dashboard. expect( - (dashboard!.restoredRuntimeState!.someReactEmbeddablePanel as { title: string }).title + (dashboard!.getRuntimeStateForChild('someReactEmbeddablePanel') as { title: string }).title ).toEqual('an elegant override, from a more civilized age'); }); diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts index 8f7e8bdb21bb5..32b21f0cf1c1e 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts @@ -277,10 +277,14 @@ export const initializeDashboard = async ({ }; // -------------------------------------------------------------------------------------- - // Set latest runtime state for react embeddables. + // Set restored runtime state for react embeddables. // -------------------------------------------------------------------------------------- untilDashboardReady().then((dashboardContainer) => { - dashboardContainer.restoredRuntimeState = runtimePanelsToRestore; + for (const idWithRuntimeState of Object.keys(runtimePanelsToRestore)) { + const restoredRuntimeStateForChild = runtimePanelsToRestore[idWithRuntimeState]; + if (!restoredRuntimeStateForChild) continue; + dashboardContainer.setRuntimeStateForChild(idWithRuntimeState, restoredRuntimeStateForChild); + } }); // -------------------------------------------------------------------------------------- diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx index bb49255d41711..1bfa289a5e912 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx @@ -810,14 +810,22 @@ export class DashboardContainer public saveNotification$: Subject = new Subject(); public getSerializedStateForChild = (childId: string) => { + const rawState = this.getInput().panels[childId].explicitInput; + const { id, ...serializedState } = rawState; + if (!rawState || Object.keys(serializedState).length === 0) return; const references = getReferencesForPanelId(childId, this.savedObjectReferences); return { - rawState: this.getInput().panels[childId].explicitInput, + rawState, references, }; }; - public restoredRuntimeState: UnsavedPanelState | undefined = undefined; + 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]; }; diff --git a/src/plugins/dashboard/public/services/dashboard_backup/dashboard_backup_service.ts b/src/plugins/dashboard/public/services/dashboard_backup/dashboard_backup_service.ts index 5a121ef430400..f97d88fd1c4fe 100644 --- a/src/plugins/dashboard/public/services/dashboard_backup/dashboard_backup_service.ts +++ b/src/plugins/dashboard/public/services/dashboard_backup/dashboard_backup_service.ts @@ -133,7 +133,7 @@ class DashboardBackupService implements DashboardBackupServiceType { const panelsStorage = this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY) ?? {}; set(panelsStorage, [this.activeSpaceId, id], unsavedPanels); - this.sessionStorage.set(DASHBOARD_PANELS_SESSION_KEY, panelsStorage); + this.sessionStorage.set(DASHBOARD_PANELS_SESSION_KEY, panelsStorage, true); } catch (e) { this.notifications.toasts.addDanger({ title: backupServiceStrings.getPanelsSetError(e.message), diff --git a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_renderer.tsx b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_renderer.tsx index 002328cf22274..91f4e02527bbb 100644 --- a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_renderer.tsx +++ b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_renderer.tsx @@ -9,6 +9,7 @@ import { apiIsPresentationContainer, HasSerializedChildState, + HasSnapshottableState, SerializedPanelState, } from '@kbn/presentation-containers'; import { PresentationPanel, PresentationPanelProps } from '@kbn/presentation-panel-plugin/public'; @@ -170,7 +171,7 @@ export const ReactEmbeddableRenderer = < } as unknown as SetReactEmbeddableApiRegistration); cleanupFunction.current = () => cleanup(); - return fullApi; + return fullApi as Api & HasSnapshottableState; }; const { api, Component } = await factory.buildEmbeddable( @@ -188,7 +189,6 @@ export const ReactEmbeddableRenderer = < } else { reportPhaseChange(false); } - return { api, Component }; }; diff --git a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_state.ts b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_state.ts index ddfaf10953821..f29d418bd3963 100644 --- a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_state.ts +++ b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_state.ts @@ -39,9 +39,10 @@ export const initializeReactEmbeddableState = async < factory: ReactEmbeddableFactory, parentApi: HasSerializedChildState ) => { - const lastSavedRuntimeState = await factory.deserializeState( - parentApi.getSerializedStateForChild(uuid) - ); + const serializedState = parentApi.getSerializedStateForChild(uuid); + const lastSavedRuntimeState = serializedState + ? await factory.deserializeState(serializedState) + : ({} as RuntimeState); // If the parent provides runtime state for the child (usually as a state backup or cache), // we merge it with the last saved runtime state. diff --git a/src/plugins/embeddable/public/react_embeddable_system/types.ts b/src/plugins/embeddable/public/react_embeddable_system/types.ts index cc4b9cf9b7976..e9a5a697f07e5 100644 --- a/src/plugins/embeddable/public/react_embeddable_system/types.ts +++ b/src/plugins/embeddable/public/react_embeddable_system/types.ts @@ -114,7 +114,7 @@ export interface ReactEmbeddableFactory< buildApi: ( apiRegistration: BuildReactEmbeddableApiRegistration, comparators: StateComparators - ) => Api, + ) => Api & HasSnapshottableState, uuid: string, parentApi: unknown | undefined, /** `setApi` should be used when the unsaved changes logic in `buildApi` is unnecessary */ diff --git a/src/plugins/kibana_utils/public/storage/storage.ts b/src/plugins/kibana_utils/public/storage/storage.ts index 7f759c005daad..497fdef49abc0 100644 --- a/src/plugins/kibana_utils/public/storage/storage.ts +++ b/src/plugins/kibana_utils/public/storage/storage.ts @@ -32,9 +32,13 @@ export class Storage implements IStorageWrapper { } }; - public set = (key: string, value: any) => { + public set = (key: string, value: any, includeUndefined: boolean = false) => { + const replacer = includeUndefined + ? (_: string, currentValue: any) => + typeof currentValue === 'undefined' ? null : currentValue + : undefined; try { - return this.store.setItem(key, JSON.stringify(value)); + return this.store.setItem(key, JSON.stringify(value, replacer)); } catch (e) { return false; }