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..9f1d5cf5390ac 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,11 +104,13 @@ export class BookEmbeddable extends Embeddable => { - return this.attributeService.getInputAsValueType(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/config.ts b/src/plugins/dashboard/config.ts index ff968a51679e0..da3f8a61306b8 100644 --- a/src/plugins/dashboard/config.ts +++ b/src/plugins/dashboard/config.ts @@ -20,7 +20,7 @@ import { schema, TypeOf } from '@kbn/config-schema'; export const configSchema = schema.object({ - allowByValueEmbeddables: schema.boolean({ defaultValue: false }), + allowByValueEmbeddables: schema.boolean({ defaultValue: true }), }); export type ConfigSchema = TypeOf; 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 d7a84fb79f6af..400d2adebd055 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 { + AddToLibraryAction, + AddToLibraryActionContext, + ACTION_ADD_TO_LIBRARY, +} from './add_to_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..e86b7df38a13d 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; @@ -127,6 +154,21 @@ export class AttributeService< 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; diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index 3b0863a9f4651..885efa53e243a 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -92,6 +92,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 { @@ -152,6 +157,7 @@ declare module '../../../plugins/ui_actions/public' { [ACTION_EXPAND_PANEL]: ExpandPanelActionContext; [ACTION_REPLACE_PANEL]: ReplacePanelActionContext; [ACTION_CLONE_PANEL]: ClonePanelActionContext; + [ACTION_ADD_TO_LIBRARY]: AddToLibraryActionContext; } } @@ -163,6 +169,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 +177,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); @@ -398,6 +408,7 @@ export class DashboardPlugin const { uiActions, data: { indexPatterns, search }, + embeddable, } = plugins; const SavedObjectFinder = getSavedObjectFinder(core.savedObjects, core.uiSettings); @@ -415,6 +426,12 @@ export class DashboardPlugin uiActions.registerAction(clonePanelAction); 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 savedDashboardLoader = createSavedDashboardLoader({ savedObjectsClient: core.savedObjects.client, indexPatterns, @@ -430,7 +447,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, }), @@ -438,8 +455,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 fa79af909a427..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'; @@ -84,6 +87,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 +148,5 @@ export const embeddablePluginMock = { createSetupContract, createStartContract, createInstance, + mockRefOrValEmbeddable, };