From 1a35b78060294aedd07be77dbbcdea941549ee20 Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Thu, 20 Aug 2020 10:26:53 -0400 Subject: [PATCH] [Dashboard First] Add to Library Action (#75098) * created an add to library action that turns 'by value' embeddables into 'by reference' embeddables --- .../public/book/book_embeddable.tsx | 10 +- .../actions/add_to_library_action.test.tsx | 162 ++++++++++++++++++ .../actions/add_to_library_action.tsx | 93 ++++++++++ .../public/application/actions/index.ts | 7 +- .../attribute_service/attribute_service.tsx | 54 +++++- src/plugins/dashboard/public/plugin.tsx | 14 +- src/plugins/embeddable/public/mocks.tsx | 4 +- 7 files changed, 328 insertions(+), 16 deletions(-) create mode 100644 src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx create mode 100644 src/plugins/dashboard/public/application/actions/add_to_library_action.tsx diff --git a/examples/embeddable_examples/public/book/book_embeddable.tsx b/examples/embeddable_examples/public/book/book_embeddable.tsx index 73b1629d985b7..33876ab24414e 100644 --- a/examples/embeddable_examples/public/book/book_embeddable.tsx +++ b/examples/embeddable_examples/public/book/book_embeddable.tsx @@ -26,7 +26,6 @@ import { EmbeddableOutput, SavedObjectEmbeddableInput, ReferenceOrValueEmbeddable, - Container, } from '../../../../src/plugins/embeddable/public'; import { BookSavedObjectAttributes } from '../../common'; import { BookEmbeddableComponent } from './book_component'; @@ -104,16 +103,13 @@ export class BookEmbeddable extends Embeddable => { - 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; + const input = this.attributeService.getExplicitInputFromEmbeddable(this); return this.attributeService.getInputAsValueType(input); }; getInputAsRefType = async (): Promise => { - return this.attributeService.getInputAsRefType(this.input, { showSaveModal: true }); + const input = this.attributeService.getExplicitInputFromEmbeddable(this); + return this.attributeService.getInputAsRefType(input, { showSaveModal: true }); }; public render(node: HTMLElement) { diff --git a/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx b/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx new file mode 100644 index 0000000000000..9fa7fff9ad087 --- /dev/null +++ b/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx @@ -0,0 +1,162 @@ +/* + * 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 { AddToLibraryAction } 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'); + } else { + embeddable = embeddablePluginMock.mockRefOrValEmbeddable< + ContactCardEmbeddable, + ContactCardEmbeddableInput + >(contactCardEmbeddable, { + mockedByReferenceInput: { savedObjectId: 'testSavedObjectId', id: contactCardEmbeddable.id }, + mockedByValueInput: { firstName: 'Kibanana', id: contactCardEmbeddable.id }, + }); + embeddable.updateInput({ viewMode: ViewMode.EDIT }); + } +}); + +test('Add to library is compatible when embeddable on dashboard has value type input', async () => { + const action = new AddToLibraryAction(); + embeddable.updateInput(await embeddable.getInputAsValueType()); + expect(await action.isCompatible({ embeddable })).toBe(true); +}); + +test('Add to library is not compatible when embeddable input is by reference', async () => { + const action = new AddToLibraryAction(); + embeddable.updateInput(await embeddable.getInputAsRefType()); + expect(await action.isCompatible({ embeddable })).toBe(false); +}); + +test('Add to library is not compatible when view mode is set to view', async () => { + const action = new AddToLibraryAction(); + embeddable.updateInput(await embeddable.getInputAsRefType()); + embeddable.updateInput({ viewMode: ViewMode.VIEW }); + expect(await action.isCompatible({ embeddable })).toBe(false); +}); + +test('Add to library 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 AddToLibraryAction(); + expect(await action.isCompatible({ embeddable: orphanContactCard })).toBe(false); +}); + +test('Add to library 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 AddToLibraryAction(); + 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('Add to library returns reference type input', 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 AddToLibraryAction(); + 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).toBeUndefined(); + expect(newPanel.explicitInput.savedObjectId).toBe('testSavedObjectId'); +}); diff --git a/src/plugins/dashboard/public/application/actions/add_to_library_action.tsx b/src/plugins/dashboard/public/application/actions/add_to_library_action.tsx new file mode 100644 index 0000000000000..3cc1a8a1dffe1 --- /dev/null +++ b/src/plugins/dashboard/public/application/actions/add_to_library_action.tsx @@ -0,0 +1,93 @@ +/* + * 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_ADD_TO_LIBRARY = 'addToFromLibrary'; + +export interface AddToLibraryActionContext { + embeddable: IEmbeddable; +} + +export class AddToLibraryAction implements ActionByType { + public readonly type = ACTION_ADD_TO_LIBRARY; + public readonly id = ACTION_ADD_TO_LIBRARY; + public order = 15; + + constructor() {} + + public getDisplayName({ embeddable }: AddToLibraryActionContext) { + if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) { + throw new IncompatibleActionError(); + } + return i18n.translate('dashboard.panel.AddToLibrary', { + defaultMessage: 'Add to library', + }); + } + + public getIconType({ embeddable }: AddToLibraryActionContext) { + if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) { + throw new IncompatibleActionError(); + } + return 'folderCheck'; + } + + public async isCompatible({ embeddable }: AddToLibraryActionContext) { + 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 }: AddToLibraryActionContext) { + if (!isReferenceOrValueEmbeddable(embeddable)) { + throw new IncompatibleActionError(); + } + + const newInput = await embeddable.getInputAsRefType(); + + 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/application/actions/index.ts b/src/plugins/dashboard/public/application/actions/index.ts index be183976c676f..4343a3409b696 100644 --- a/src/plugins/dashboard/public/application/actions/index.ts +++ b/src/plugins/dashboard/public/application/actions/index.ts @@ -33,7 +33,12 @@ export { ACTION_CLONE_PANEL, } from './clone_panel_action'; export { + AddToLibraryAction, + AddToLibraryActionContext, + ACTION_ADD_TO_LIBRARY, +} from './add_to_library_action'; +export { + UnlinkFromLibraryAction, UnlinkFromLibraryActionContext, ACTION_UNLINK_FROM_LIBRARY, - UnlinkFromLibraryAction, } from './unlink_from_library_action'; diff --git a/src/plugins/dashboard/public/attribute_service/attribute_service.tsx b/src/plugins/dashboard/public/attribute_service/attribute_service.tsx index c2f529fe399f3..fe5f6a0c8e2bd 100644 --- a/src/plugins/dashboard/public/attribute_service/attribute_service.tsx +++ b/src/plugins/dashboard/public/attribute_service/attribute_service.tsx @@ -30,13 +30,21 @@ import { SimpleSavedObject, I18nStart, NotificationsStart, + OverlayStart, } from '../../../../core/public'; import { SavedObjectSaveModal, showSaveModal, OnSaveProps, SaveResult, + checkForDuplicateTitle, } from '../../../saved_objects/public'; +import { + EmbeddableStart, + EmbeddableFactory, + EmbeddableFactoryNotFoundError, + Container, +} from '../../../embeddable/public'; /** * The attribute service is a shared, generic service that embeddables can use to provide the functionality @@ -49,12 +57,22 @@ export class AttributeService< ValType extends EmbeddableInput & { attributes: SavedObjectAttributes }, RefType extends SavedObjectEmbeddableInput > { + private embeddableFactory: EmbeddableFactory; + constructor( private type: string, private savedObjectsClient: SavedObjectsClientContract, + private overlays: OverlayStart, private i18nContext: I18nStart['Context'], - private toasts: NotificationsStart['toasts'] - ) {} + private toasts: NotificationsStart['toasts'], + getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory'] + ) { + const factory = getEmbeddableFactory(this.type); + if (!factory) { + throw new EmbeddableFactoryNotFoundError(this.type); + } + this.embeddableFactory = factory; + } public async unwrapAttributes(input: RefType | ValType): Promise { if (this.inputIsRefType(input)) { @@ -105,6 +123,15 @@ export class AttributeService< return isSavedObjectEmbeddableInput(input); }; + public getExplicitInputFromEmbeddable(embeddable: IEmbeddable): ValType | RefType { + return embeddable.getRoot() && + (embeddable.getRoot() as Container).getInput().panels[embeddable.id].explicitInput + ? ((embeddable.getRoot() as Container).getInput().panels[embeddable.id].explicitInput as + | ValType + | RefType) + : (embeddable.getInput() as ValType | RefType); + } + getInputAsValueType = async (input: ValType | RefType): Promise => { if (!this.inputIsRefType(input)) { return input; @@ -124,16 +151,31 @@ export class AttributeService< if (this.inputIsRefType(input)) { return input; } - return new Promise((resolve, reject) => { const onSave = async (props: OnSaveProps): Promise => { + await checkForDuplicateTitle( + { + title: props.newTitle, + copyOnSave: false, + lastSavedTitle: '', + getEsType: () => this.type, + getDisplayName: this.embeddableFactory.getDisplayName, + }, + props.isTitleDuplicateConfirmed, + props.onTitleDuplicate, + { + savedObjectsClient: this.savedObjectsClient, + overlays: this.overlays, + } + ); try { - input.attributes.title = props.newTitle; - const wrappedInput = (await this.wrapAttributes(input.attributes, true)) as RefType; + const newAttributes = { ...input.attributes }; + newAttributes.title = props.newTitle; + const wrappedInput = (await this.wrapAttributes(newAttributes, true)) as RefType; resolve(wrappedInput); return { id: wrappedInput.savedObjectId }; } catch (error) { - reject(); + reject(error); return { error }; } }; diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index d15bc350ec1f2..1ad94afdc0002 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -95,6 +95,11 @@ import { addEmbeddableToDashboardUrl } from './url_utils/url_helper'; import { PlaceholderEmbeddableFactory } from './application/embeddable/placeholder'; import { UrlGeneratorState } from '../../share/public'; import { AttributeService } from '.'; +import { + AddToLibraryAction, + ACTION_ADD_TO_LIBRARY, + AddToLibraryActionContext, +} from './application/actions/add_to_library_action'; declare module '../../share/public' { export interface UrlGeneratorStateMapping { @@ -155,6 +160,7 @@ declare module '../../../plugins/ui_actions/public' { [ACTION_EXPAND_PANEL]: ExpandPanelActionContext; [ACTION_REPLACE_PANEL]: ReplacePanelActionContext; [ACTION_CLONE_PANEL]: ClonePanelActionContext; + [ACTION_ADD_TO_LIBRARY]: AddToLibraryActionContext; [ACTION_UNLINK_FROM_LIBRARY]: UnlinkFromLibraryActionContext; } } @@ -406,6 +412,7 @@ export class DashboardPlugin const { uiActions, data: { indexPatterns, search }, + embeddable, } = plugins; const SavedObjectFinder = getSavedObjectFinder(core.savedObjects, core.uiSettings); @@ -424,6 +431,9 @@ export class DashboardPlugin uiActions.attachAction(CONTEXT_MENU_TRIGGER, clonePanelAction.id); if (this.dashboardFeatureFlagConfig?.allowByValueEmbeddables) { + const addToLibraryAction = new AddToLibraryAction(); + uiActions.registerAction(addToLibraryAction); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, addToLibraryAction.id); const unlinkFromLibraryAction = new UnlinkFromLibraryAction(); uiActions.registerAction(unlinkFromLibraryAction); uiActions.attachAction(CONTEXT_MENU_TRIGGER, unlinkFromLibraryAction.id); @@ -452,8 +462,10 @@ export class DashboardPlugin new AttributeService( type, core.savedObjects.client, + core.overlays, core.i18n.Context, - core.notifications.toasts + core.notifications.toasts, + embeddable.getEmbeddableFactory ), }; } diff --git a/src/plugins/embeddable/public/mocks.tsx b/src/plugins/embeddable/public/mocks.tsx index 7ec03ba659cda..2064236e9ae7f 100644 --- a/src/plugins/embeddable/public/mocks.tsx +++ b/src/plugins/embeddable/public/mocks.tsx @@ -25,6 +25,9 @@ import { EmbeddableStateTransfer, IEmbeddable, EmbeddablePanel, + EmbeddableInput, + SavedObjectEmbeddableInput, + ReferenceOrValueEmbeddable, } from '.'; import { EmbeddablePublicPlugin } from './plugin'; import { coreMock } from '../../../core/public/mocks'; @@ -35,7 +38,6 @@ 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;