diff --git a/examples/embeddable_examples/public/book/add_book_to_library_action.tsx b/examples/embeddable_examples/public/book/add_book_to_library_action.tsx index b74a1d5642982..4ae3a545df0d0 100644 --- a/examples/embeddable_examples/public/book/add_book_to_library_action.tsx +++ b/examples/embeddable_examples/public/book/add_book_to_library_action.tsx @@ -21,6 +21,7 @@ import { i18n } from '@kbn/i18n'; import { createAction, IncompatibleActionError } from '../../../../src/plugins/ui_actions/public'; import { BookEmbeddable, BOOK_EMBEDDABLE } from './book_embeddable'; import { ViewMode, isReferenceOrValueEmbeddable } from '../../../../src/plugins/embeddable/public'; +import { DASHBOARD_CONTAINER_TYPE } from '../../../../src/plugins/dashboard/public'; interface ActionContext { embeddable: BookEmbeddable; @@ -41,6 +42,8 @@ export const createAddBookToLibraryAction = () => return ( embeddable.type === BOOK_EMBEDDABLE && embeddable.getInput().viewMode === ViewMode.EDIT && + embeddable.getRoot().isContainer && + embeddable.getRoot().type !== DASHBOARD_CONTAINER_TYPE && isReferenceOrValueEmbeddable(embeddable) && !embeddable.inputIsRefType(embeddable.getInput()) ); diff --git a/examples/embeddable_examples/public/book/book_embeddable.tsx b/examples/embeddable_examples/public/book/book_embeddable.tsx index dd9418c0e8596..73b1629d985b7 100644 --- a/examples/embeddable_examples/public/book/book_embeddable.tsx +++ b/examples/embeddable_examples/public/book/book_embeddable.tsx @@ -26,6 +26,7 @@ import { EmbeddableOutput, SavedObjectEmbeddableInput, ReferenceOrValueEmbeddable, + Container, } from '../../../../src/plugins/embeddable/public'; import { BookSavedObjectAttributes } from '../../common'; import { BookEmbeddableComponent } from './book_component'; @@ -103,7 +104,12 @@ export class BookEmbeddable extends Embeddable => { - return this.attributeService.getInputAsValueType(this.input); + const input = + this.getRoot() && (this.getRoot() as Container).getInput().panels[this.id].explicitInput + ? ((this.getRoot() as Container).getInput().panels[this.id] + .explicitInput as BookEmbeddableInput) + : this.input; + return this.attributeService.getInputAsValueType(input); }; getInputAsRefType = async (): Promise => { diff --git a/examples/embeddable_examples/public/book/unlink_book_from_library_action.tsx b/examples/embeddable_examples/public/book/unlink_book_from_library_action.tsx index cef77092a642a..ebab2c483c625 100644 --- a/examples/embeddable_examples/public/book/unlink_book_from_library_action.tsx +++ b/examples/embeddable_examples/public/book/unlink_book_from_library_action.tsx @@ -21,6 +21,7 @@ import { i18n } from '@kbn/i18n'; import { createAction, IncompatibleActionError } from '../../../../src/plugins/ui_actions/public'; import { BookEmbeddable, BOOK_EMBEDDABLE } from './book_embeddable'; import { ViewMode, isReferenceOrValueEmbeddable } from '../../../../src/plugins/embeddable/public'; +import { DASHBOARD_CONTAINER_TYPE } from '../../../../src/plugins/dashboard/public'; interface ActionContext { embeddable: BookEmbeddable; @@ -41,6 +42,8 @@ export const createUnlinkBookFromLibraryAction = () => return ( embeddable.type === BOOK_EMBEDDABLE && embeddable.getInput().viewMode === ViewMode.EDIT && + embeddable.getRoot().isContainer && + embeddable.getRoot().type !== DASHBOARD_CONTAINER_TYPE && isReferenceOrValueEmbeddable(embeddable) && embeddable.inputIsRefType(embeddable.getInput()) ); diff --git a/src/plugins/dashboard/public/application/actions/index.ts b/src/plugins/dashboard/public/application/actions/index.ts index d7a84fb79f6af..be183976c676f 100644 --- a/src/plugins/dashboard/public/application/actions/index.ts +++ b/src/plugins/dashboard/public/application/actions/index.ts @@ -32,3 +32,8 @@ export { ClonePanelActionContext, ACTION_CLONE_PANEL, } from './clone_panel_action'; +export { + UnlinkFromLibraryActionContext, + ACTION_UNLINK_FROM_LIBRARY, + UnlinkFromLibraryAction, +} from './unlink_from_library_action'; diff --git a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx new file mode 100644 index 0000000000000..681a6a734a532 --- /dev/null +++ b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx @@ -0,0 +1,160 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { isErrorEmbeddable, IContainer, ReferenceOrValueEmbeddable } from '../../embeddable_plugin'; +import { DashboardContainer } from '../embeddable'; +import { getSampleDashboardInput } from '../test_helpers'; +import { + CONTACT_CARD_EMBEDDABLE, + ContactCardEmbeddableFactory, + ContactCardEmbeddable, + ContactCardEmbeddableInput, + ContactCardEmbeddableOutput, +} from '../../embeddable_plugin_test_samples'; +import { coreMock } from '../../../../../core/public/mocks'; +import { CoreStart } from 'kibana/public'; +import { UnlinkFromLibraryAction } from '.'; +import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; +import { ViewMode } from '../../../../embeddable/public'; + +const { setup, doStart } = embeddablePluginMock.createInstance(); +setup.registerEmbeddableFactory( + CONTACT_CARD_EMBEDDABLE, + new ContactCardEmbeddableFactory((() => null) as any, {} as any) +); +const start = doStart(); + +let container: DashboardContainer; +let embeddable: ContactCardEmbeddable & ReferenceOrValueEmbeddable; +let coreStart: CoreStart; +beforeEach(async () => { + coreStart = coreMock.createStart(); + + const containerOptions = { + ExitFullScreenButton: () => null, + SavedObjectFinder: () => null, + application: {} as any, + embeddable: start, + inspector: {} as any, + notifications: {} as any, + overlays: coreStart.overlays, + savedObjectMetaData: {} as any, + uiActions: {} as any, + }; + + container = new DashboardContainer(getSampleDashboardInput(), containerOptions); + + const contactCardEmbeddable = await container.addNewEmbeddable< + ContactCardEmbeddableInput, + ContactCardEmbeddableOutput, + ContactCardEmbeddable + >(CONTACT_CARD_EMBEDDABLE, { + firstName: 'Kibanana', + }); + + if (isErrorEmbeddable(contactCardEmbeddable)) { + throw new Error('Failed to create embeddable'); + } + embeddable = embeddablePluginMock.mockRefOrValEmbeddable< + ContactCardEmbeddable, + ContactCardEmbeddableInput + >(contactCardEmbeddable, { + mockedByReferenceInput: { savedObjectId: 'testSavedObjectId', id: contactCardEmbeddable.id }, + mockedByValueInput: { firstName: 'Kibanana', id: contactCardEmbeddable.id }, + }); + embeddable.updateInput({ viewMode: ViewMode.EDIT }); +}); + +test('Unlink is compatible when embeddable on dashboard has reference type input', async () => { + const action = new UnlinkFromLibraryAction(); + embeddable.updateInput(await embeddable.getInputAsRefType()); + expect(await action.isCompatible({ embeddable })).toBe(true); +}); + +test('Unlink is not compatible when embeddable input is by value', async () => { + const action = new UnlinkFromLibraryAction(); + embeddable.updateInput(await embeddable.getInputAsValueType()); + expect(await action.isCompatible({ embeddable })).toBe(false); +}); + +test('Unlink is not compatible when view mode is set to view', async () => { + const action = new UnlinkFromLibraryAction(); + embeddable.updateInput(await embeddable.getInputAsRefType()); + embeddable.updateInput({ viewMode: ViewMode.VIEW }); + expect(await action.isCompatible({ embeddable })).toBe(false); +}); + +test('Unlink is not compatible when embeddable is not in a dashboard container', async () => { + let orphanContactCard = await container.addNewEmbeddable< + ContactCardEmbeddableInput, + ContactCardEmbeddableOutput, + ContactCardEmbeddable + >(CONTACT_CARD_EMBEDDABLE, { + firstName: 'Orphan', + }); + orphanContactCard = embeddablePluginMock.mockRefOrValEmbeddable< + ContactCardEmbeddable, + ContactCardEmbeddableInput + >(orphanContactCard, { + mockedByReferenceInput: { savedObjectId: 'test', id: orphanContactCard.id }, + mockedByValueInput: { firstName: 'Kibanana', id: orphanContactCard.id }, + }); + const action = new UnlinkFromLibraryAction(); + expect(await action.isCompatible({ embeddable: orphanContactCard })).toBe(false); +}); + +test('Unlink replaces embeddableId but retains panel count', async () => { + const dashboard = embeddable.getRoot() as IContainer; + const originalPanelCount = Object.keys(dashboard.getInput().panels).length; + const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels)); + const action = new UnlinkFromLibraryAction(); + await action.execute({ embeddable }); + expect(Object.keys(container.getInput().panels).length).toEqual(originalPanelCount); + + 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(embeddable.type); +}); + +test('Unlink unwraps all attributes from savedObject', async () => { + const complicatedAttributes = { + attribute1: 'The best attribute', + attribute2: 22, + attribute3: ['array', 'of', 'strings'], + attribute4: { nestedattribute: 'hello from the nest' }, + }; + + embeddable = embeddablePluginMock.mockRefOrValEmbeddable(embeddable, { + mockedByReferenceInput: { savedObjectId: 'testSavedObjectId', id: embeddable.id }, + mockedByValueInput: { attributes: complicatedAttributes, id: embeddable.id }, + }); + const dashboard = embeddable.getRoot() as IContainer; + const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels)); + const action = new UnlinkFromLibraryAction(); + await action.execute({ embeddable }); + 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(embeddable.type); + expect(newPanel.explicitInput.attributes).toEqual(complicatedAttributes); +}); diff --git a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx new file mode 100644 index 0000000000000..e2a6ec7dd3947 --- /dev/null +++ b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx @@ -0,0 +1,92 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { i18n } from '@kbn/i18n'; +import _ from 'lodash'; +import uuid from 'uuid'; +import { ActionByType, IncompatibleActionError } from '../../ui_actions_plugin'; +import { ViewMode, PanelState, IEmbeddable } from '../../embeddable_plugin'; +import { + PanelNotFoundError, + EmbeddableInput, + isReferenceOrValueEmbeddable, +} from '../../../../embeddable/public'; +import { DashboardPanelState, DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '..'; + +export const ACTION_UNLINK_FROM_LIBRARY = 'unlinkFromLibrary'; + +export interface UnlinkFromLibraryActionContext { + embeddable: IEmbeddable; +} + +export class UnlinkFromLibraryAction implements ActionByType { + public readonly type = ACTION_UNLINK_FROM_LIBRARY; + public readonly id = ACTION_UNLINK_FROM_LIBRARY; + public order = 15; + + constructor() {} + + public getDisplayName({ embeddable }: UnlinkFromLibraryActionContext) { + if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) { + throw new IncompatibleActionError(); + } + return i18n.translate('dashboard.panel.unlinkFromLibrary', { + defaultMessage: 'Unlink from library item', + }); + } + + public getIconType({ embeddable }: UnlinkFromLibraryActionContext) { + if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) { + throw new IncompatibleActionError(); + } + return 'folderExclamation'; + } + + public async isCompatible({ embeddable }: UnlinkFromLibraryActionContext) { + return Boolean( + embeddable.getInput()?.viewMode !== ViewMode.VIEW && + embeddable.getRoot() && + embeddable.getRoot().isContainer && + embeddable.getRoot().type === DASHBOARD_CONTAINER_TYPE && + isReferenceOrValueEmbeddable(embeddable) && + embeddable.inputIsRefType(embeddable.getInput()) + ); + } + + public async execute({ embeddable }: UnlinkFromLibraryActionContext) { + if (!isReferenceOrValueEmbeddable(embeddable)) { + throw new IncompatibleActionError(); + } + + const newInput = await embeddable.getInputAsValueType(); + embeddable.updateInput(newInput); + + const dashboard = embeddable.getRoot() as DashboardContainer; + const panelToReplace = dashboard.getInput().panels[embeddable.id] as DashboardPanelState; + if (!panelToReplace) { + throw new PanelNotFoundError(); + } + + const newPanel: PanelState = { + type: embeddable.type, + explicitInput: { ...newInput, id: uuid.v4() }, + }; + dashboard.replacePanel(panelToReplace, newPanel); + } +} diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index aa1fe1c4caad6..d15bc350ec1f2 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -80,6 +80,9 @@ import { RenderDeps, ReplacePanelAction, ReplacePanelActionContext, + ACTION_UNLINK_FROM_LIBRARY, + UnlinkFromLibraryActionContext, + UnlinkFromLibraryAction, } from './application'; import { createDashboardUrlGenerator, @@ -152,6 +155,7 @@ declare module '../../../plugins/ui_actions/public' { [ACTION_EXPAND_PANEL]: ExpandPanelActionContext; [ACTION_REPLACE_PANEL]: ReplacePanelActionContext; [ACTION_CLONE_PANEL]: ClonePanelActionContext; + [ACTION_UNLINK_FROM_LIBRARY]: UnlinkFromLibraryActionContext; } } @@ -163,6 +167,7 @@ export class DashboardPlugin private stopUrlTracking: (() => void) | undefined = undefined; private getActiveUrl: (() => string) | undefined = undefined; private currentHistory: ScopedHistory | undefined = undefined; + private dashboardFeatureFlagConfig?: DashboardFeatureFlagConfig; private dashboardUrlGenerator?: DashboardUrlGenerator; @@ -170,6 +175,9 @@ export class DashboardPlugin core: CoreSetup, { share, uiActions, embeddable, home, kibanaLegacy, data, usageCollection }: SetupDependencies ): Setup { + this.dashboardFeatureFlagConfig = this.initializerContext.config.get< + DashboardFeatureFlagConfig + >(); const expandPanelAction = new ExpandPanelAction(); uiActions.registerAction(expandPanelAction); uiActions.attachAction(CONTEXT_MENU_TRIGGER, expandPanelAction.id); @@ -415,6 +423,12 @@ export class DashboardPlugin uiActions.registerAction(clonePanelAction); uiActions.attachAction(CONTEXT_MENU_TRIGGER, clonePanelAction.id); + if (this.dashboardFeatureFlagConfig?.allowByValueEmbeddables) { + const unlinkFromLibraryAction = new UnlinkFromLibraryAction(); + uiActions.registerAction(unlinkFromLibraryAction); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, unlinkFromLibraryAction.id); + } + const savedDashboardLoader = createSavedDashboardLoader({ savedObjectsClient: core.savedObjects.client, indexPatterns, @@ -430,7 +444,7 @@ export class DashboardPlugin getSavedDashboardLoader: () => savedDashboardLoader, addEmbeddableToDashboard: this.addEmbeddableToDashboard.bind(this, core), dashboardUrlGenerator: this.dashboardUrlGenerator, - dashboardFeatureFlagConfig: this.initializerContext.config.get(), + dashboardFeatureFlagConfig: this.dashboardFeatureFlagConfig!, DashboardContainerByValueRenderer: createDashboardContainerByValueRenderer({ factory: dashboardContainerFactory, }), diff --git a/src/plugins/embeddable/public/mocks.tsx b/src/plugins/embeddable/public/mocks.tsx index fa79af909a427..7ec03ba659cda 100644 --- a/src/plugins/embeddable/public/mocks.tsx +++ b/src/plugins/embeddable/public/mocks.tsx @@ -35,6 +35,7 @@ import { dataPluginMock } from '../../data/public/mocks'; import { inspectorPluginMock } from '../../inspector/public/mocks'; import { uiActionsPluginMock } from '../../ui_actions/public/mocks'; +import { SavedObjectEmbeddableInput, ReferenceOrValueEmbeddable, EmbeddableInput } from './lib'; export type Setup = jest.Mocked; export type Start = jest.Mocked; @@ -84,6 +85,25 @@ export const createEmbeddableStateTransferMock = (): Partial( + embeddable: IEmbeddable, + options: { + mockedByReferenceInput: RefTypeInput; + mockedByValueInput: ValTypeInput; + } +): OriginalEmbeddableType & ReferenceOrValueEmbeddable => { + const newEmbeddable: ReferenceOrValueEmbeddable = (embeddable as unknown) as ReferenceOrValueEmbeddable; + newEmbeddable.inputIsRefType = (input: unknown): input is RefTypeInput => + !!(input as RefTypeInput).savedObjectId; + newEmbeddable.getInputAsRefType = () => Promise.resolve(options.mockedByReferenceInput); + newEmbeddable.getInputAsValueType = () => Promise.resolve(options.mockedByValueInput); + return newEmbeddable as OriginalEmbeddableType & ReferenceOrValueEmbeddable; +}; + const createSetupContract = (): Setup => { const setupContract: Setup = { registerEmbeddableFactory: jest.fn(), @@ -126,4 +146,5 @@ export const embeddablePluginMock = { createSetupContract, createStartContract, createInstance, + mockRefOrValEmbeddable, };