From b3c450d2315699975dffe96bb88bfa696098c1fe Mon Sep 17 00:00:00 2001 From: Devon A Thomson Date: Thu, 30 Jul 2020 17:57:53 -0400 Subject: [PATCH] Got unlink action working properly. TODO: Use config option from #73870 to show the option --- .../public/application/actions/index.ts | 5 + .../unlink_from_library_action.test.tsx | 131 ++++++++++++++++++ .../actions/unlink_from_library_action.tsx | 105 ++++++++++++++ src/plugins/dashboard/public/plugin.tsx | 9 ++ 4 files changed, 250 insertions(+) create mode 100644 src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx create mode 100644 src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx 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..ba128a7b0926b --- /dev/null +++ b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx @@ -0,0 +1,131 @@ +/* + * 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 } from '../../embeddable_plugin'; +import { DashboardContainer } from '../embeddable'; +import { getSampleDashboardInput, getSampleDashboardPanel } 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 '.'; + +// eslint-disable-next-line +import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; + +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; +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 options = { + ExitFullScreenButton: () => null, + SavedObjectFinder: () => null, + application: {} as any, + embeddable: start, + inspector: {} as any, + notifications: {} as any, + overlays: coreStart.overlays, + savedObjectMetaData: {} as any, + uiActions: {} as any, + }; + const input = getSampleDashboardInput({ + panels: { + '123': getSampleDashboardPanel({ + explicitInput: { firstName: 'Kibanana', id: '123' }, + type: CONTACT_CARD_EMBEDDABLE, + }), + }, + }); + container = new DashboardContainer(input, options); + + const contactCardEmbeddable = await container.addNewEmbeddable< + ContactCardEmbeddableInput, + ContactCardEmbeddableOutput, + ContactCardEmbeddable + >(CONTACT_CARD_EMBEDDABLE, { + firstName: 'Kibana', + }); + + contactCardEmbeddable.updateInput({ savedObjectId: 'coolestSavedObjectId' }); + + if (isErrorEmbeddable(contactCardEmbeddable)) { + throw new Error('Failed to create embeddable'); + } else { + embeddable = contactCardEmbeddable; + } +}); + +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(coreStart); + 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' }, + }; + + coreStart.savedObjects.client.get = jest.fn().mockImplementation(() => ({ + attributes: complicatedAttributes, + })); + const dashboard = embeddable.getRoot() as IContainer; + const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels)); + const action = new UnlinkFromLibraryAction(coreStart); + 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..abf4b9578ac1a --- /dev/null +++ b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx @@ -0,0 +1,105 @@ +/* + * 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 { CoreStart, SimpleSavedObject } from 'src/core/public'; +import _ from 'lodash'; +import uuid from 'uuid'; +import { ActionByType, IncompatibleActionError } from '../../ui_actions_plugin'; +import { ViewMode, PanelState, IEmbeddable } from '../../embeddable_plugin'; +import { + PanelNotFoundError, + EmbeddableInput, + SavedObjectEmbeddableInput, +} 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(private core: CoreStart) {} + + public getDisplayName({ embeddable }: UnlinkFromLibraryActionContext) { + if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) { + throw new IncompatibleActionError(); + } + return i18n.translate('dashboard.panel.unlinkFromLibrary', { + defaultMessage: 'Unlink from visualize library', + }); + } + + 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 && + (embeddable.getInput() as SavedObjectEmbeddableInput).savedObjectId && + embeddable.type === 'lens' + ); + } + + public async execute({ embeddable }: UnlinkFromLibraryActionContext) { + if ( + !embeddable.getRoot() || + !embeddable.getRoot().isContainer || + !(embeddable.getInput() as SavedObjectEmbeddableInput).savedObjectId + ) { + throw new IncompatibleActionError(); + } + + const currentInput = embeddable.getInput() as SavedObjectEmbeddableInput; + const savedObject: SimpleSavedObject = await this.core.savedObjects.client.get( + embeddable.type, + currentInput.savedObjectId + ); + + 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: { + ...panelToReplace.explicitInput, + savedObjectId: undefined, + id: uuid.v4(), + attributes: savedObject.attributes, + }, + }; + dashboard.replacePanel(panelToReplace, newPanel); + } +} \ No newline at end of file diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index f0b57fec169fd..3f2d6b4204432 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -74,6 +74,9 @@ import { RenderDeps, ReplacePanelAction, ReplacePanelActionContext, + ACTION_UNLINK_FROM_LIBRARY, + UnlinkFromLibraryActionContext, + UnlinkFromLibraryAction, } from './application'; import { createDashboardUrlGenerator, @@ -133,6 +136,7 @@ declare module '../../../plugins/ui_actions/public' { [ACTION_EXPAND_PANEL]: ExpandPanelActionContext; [ACTION_REPLACE_PANEL]: ReplacePanelActionContext; [ACTION_CLONE_PANEL]: ClonePanelActionContext; + [ACTION_UNLINK_FROM_LIBRARY]: UnlinkFromLibraryActionContext; } } @@ -396,6 +400,11 @@ export class DashboardPlugin uiActions.registerAction(clonePanelAction); uiActions.attachAction(CONTEXT_MENU_TRIGGER, clonePanelAction.id); + // TODO: once https://github.com/elastic/kibana/pull/73870 is merges, make this unlink from library action dependent on that config value. + const unlinkFromLibraryAction = new UnlinkFromLibraryAction(core); + uiActions.registerAction(unlinkFromLibraryAction); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, unlinkFromLibraryAction.id); + const savedDashboardLoader = createSavedDashboardLoader({ savedObjectsClient: core.savedObjects.client, indexPatterns,