From b3c450d2315699975dffe96bb88bfa696098c1fe Mon Sep 17 00:00:00 2001 From: Devon A Thomson Date: Thu, 30 Jul 2020 17:57:53 -0400 Subject: [PATCH 01/12] 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 d7a84fb79f6a..be183976c676 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 000000000000..ba128a7b0926 --- /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 000000000000..abf4b9578ac1 --- /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 f0b57fec169f..3f2d6b420443 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, From 171268be56cb13d566440b676a667a1d62143223 Mon Sep 17 00:00:00 2001 From: Devon A Thomson Date: Fri, 31 Jul 2020 12:17:59 -0400 Subject: [PATCH 02/12] fixed prettier, added more tests. Hid unlink action behind setting from #73870 --- .../unlink_from_library_action.test.tsx | 29 +++++++++++++++++-- .../actions/unlink_from_library_action.tsx | 2 +- src/plugins/dashboard/public/plugin.tsx | 15 ++++++---- 3 files changed, 38 insertions(+), 8 deletions(-) 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 index ba128a7b0926..d9b73f9d03cd 100644 --- 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 @@ -90,6 +90,31 @@ beforeEach(async () => { } }); +test('Unlink is not compatible when embeddable does not have a savedObjectId', async () => { + const action = new UnlinkFromLibraryAction(coreStart); + embeddable.updateInput({ savedObjectId: undefined }); + expect(await action.isCompatible({ embeddable })).toBe(false); +}); + +test('Unlink is not compatible when embeddable is not in a dashboard container', async () => { + const orphanContactCard = await container.addNewEmbeddable< + ContactCardEmbeddableInput, + ContactCardEmbeddableOutput, + ContactCardEmbeddable + >(CONTACT_CARD_EMBEDDABLE, { + firstName: 'Orphan', + }); + orphanContactCard.updateInput({ savedObjectId: 'coolestSavedObjectId' }); + const action = new UnlinkFromLibraryAction(coreStart); + expect(await action.isCompatible({ embeddable: orphanContactCard })).toBe(false); +}); + +test('Unlink is compatible when embeddable on dashboard has a savedObjectId', async () => { + const action = new UnlinkFromLibraryAction(coreStart); + embeddable.updateInput({ savedObjectId: undefined }); + expect(await action.isCompatible({ embeddable })).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; @@ -99,7 +124,7 @@ test('Unlink replaces embeddableId but retains panel count', async () => { expect(Object.keys(container.getInput().panels).length).toEqual(originalPanelCount); const newPanelId = Object.keys(container.getInput().panels).find( - key => !originalPanelKeySet.has(key) + (key) => !originalPanelKeySet.has(key) ); expect(newPanelId).toBeDefined(); const newPanel = container.getInput().panels[newPanelId!]; @@ -122,7 +147,7 @@ test('Unlink unwraps all attributes from savedObject', async () => { const action = new UnlinkFromLibraryAction(coreStart); await action.execute({ embeddable }); const newPanelId = Object.keys(container.getInput().panels).find( - key => !originalPanelKeySet.has(key) + (key) => !originalPanelKeySet.has(key) ); expect(newPanelId).toBeDefined(); const newPanel = container.getInput().panels[newPanelId!]; 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 index abf4b9578ac1..c23e09d22eb6 100644 --- a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx +++ b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx @@ -102,4 +102,4 @@ export class UnlinkFromLibraryAction implements ActionByType void) | undefined = undefined; private getActiveUrl: (() => string) | undefined = undefined; private currentHistory: ScopedHistory | undefined = undefined; + private dashboardFeatureFlagConfig?: DashboardFeatureFlagConfig; private dashboardUrlGenerator?: DashboardUrlGenerator; @@ -160,6 +161,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); @@ -405,10 +409,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); + if (this.dashboardFeatureFlagConfig?.allowByValueEmbeddables) { + const unlinkFromLibraryAction = new UnlinkFromLibraryAction(core); + uiActions.registerAction(unlinkFromLibraryAction); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, unlinkFromLibraryAction.id); + } const savedDashboardLoader = createSavedDashboardLoader({ savedObjectsClient: core.savedObjects.client, @@ -425,7 +430,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, }), From 14dcd6ae22767146cba9b16ed5d0be34bf02c212 Mon Sep 17 00:00:00 2001 From: Devon A Thomson Date: Tue, 4 Aug 2020 16:20:32 -0400 Subject: [PATCH 03/12] Added a first pass for an interface that determines if an embeddable can be treated as either by reference or by value --- .../public/book/book_embeddable.tsx | 17 +++++- .../public/book/book_embeddable_factory.tsx | 2 +- src/plugins/embeddable/public/index.ts | 2 + .../public/lib/embeddables/index.ts | 1 - src/plugins/embeddable/public/lib/index.ts | 1 + .../attribute_service.ts | 29 +++++++++- .../reference_or_value_embeddable/index.ts | 21 +++++++ .../reference_or_value_embeddable/types.ts | 56 +++++++++++++++++++ src/plugins/embeddable/public/plugin.tsx | 2 +- 9 files changed, 126 insertions(+), 5 deletions(-) rename src/plugins/embeddable/public/lib/{embeddables => reference_or_value_embeddable}/attribute_service.ts (77%) create mode 100644 src/plugins/embeddable/public/lib/reference_or_value_embeddable/index.ts create mode 100644 src/plugins/embeddable/public/lib/reference_or_value_embeddable/types.ts diff --git a/examples/embeddable_examples/public/book/book_embeddable.tsx b/examples/embeddable_examples/public/book/book_embeddable.tsx index d49bd3280d97..e0b1df808805 100644 --- a/examples/embeddable_examples/public/book/book_embeddable.tsx +++ b/examples/embeddable_examples/public/book/book_embeddable.tsx @@ -26,6 +26,8 @@ import { EmbeddableOutput, SavedObjectEmbeddableInput, AttributeService, + ReferenceOrValueEmbeddable, + isSavedObjectEmbeddableInput, } from '../../../../src/plugins/embeddable/public'; import { BookSavedObjectAttributes } from '../../common'; import { BookEmbeddableComponent } from './book_component'; @@ -59,7 +61,8 @@ function getHasMatch(search?: string, savedAttributes?: BookSavedObjectAttribute ); } -export class BookEmbeddable extends Embeddable { +export class BookEmbeddable extends Embeddable + implements ReferenceOrValueEmbeddable { public readonly type = BOOK_EMBEDDABLE; private subscription: Subscription; private node?: HTMLElement; @@ -96,6 +99,18 @@ export class BookEmbeddable extends Embeddable { + return this.attributeService.inputIsRefType(input); + }; + + getInputAsValueType = async (): Promise => { + return this.attributeService.getInputAsValueType(this.input); + }; + + getInputAsRefType = async (): Promise => { + return this.attributeService.getInputAsRefType(this.input); + }; + public render(node: HTMLElement) { if (this.node) { ReactDOM.unmountComponentAtNode(this.node); diff --git a/examples/embeddable_examples/public/book/book_embeddable_factory.tsx b/examples/embeddable_examples/public/book/book_embeddable_factory.tsx index f4a32fb498a2..30bbff480d2e 100644 --- a/examples/embeddable_examples/public/book/book_embeddable_factory.tsx +++ b/examples/embeddable_examples/public/book/book_embeddable_factory.tsx @@ -122,6 +122,6 @@ export class BookEmbeddableFactoryDefinition BookByReferenceInput >(this.type); } - return this.attributeService; + return this.attributeService!; } } diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index fafbdda148de..206aa9056a1c 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -29,6 +29,8 @@ export { Adapters, AddPanelAction, AttributeService, + ReferenceOrValueEmbeddable, + isReferenceOrValueEmbeddable, ChartActionContext, Container, ContainerInput, diff --git a/src/plugins/embeddable/public/lib/embeddables/index.ts b/src/plugins/embeddable/public/lib/embeddables/index.ts index 06cb6e322acf..5bab5ac27f3c 100644 --- a/src/plugins/embeddable/public/lib/embeddables/index.ts +++ b/src/plugins/embeddable/public/lib/embeddables/index.ts @@ -25,5 +25,4 @@ export { ErrorEmbeddable, isErrorEmbeddable } from './error_embeddable'; export { withEmbeddableSubscription } from './with_subscription'; export { EmbeddableRoot } from './embeddable_root'; export * from './saved_object_embeddable'; -export { AttributeService } from './attribute_service'; export { EmbeddableRenderer, EmbeddableRendererProps } from './embeddable_renderer'; diff --git a/src/plugins/embeddable/public/lib/index.ts b/src/plugins/embeddable/public/lib/index.ts index b757fa59a7f3..aef4c33ee107 100644 --- a/src/plugins/embeddable/public/lib/index.ts +++ b/src/plugins/embeddable/public/lib/index.ts @@ -25,3 +25,4 @@ export * from './triggers'; export * from './containers'; export * from './panel'; export * from './state_transfer'; +export * from './reference_or_value_embeddable'; diff --git a/src/plugins/embeddable/public/lib/embeddables/attribute_service.ts b/src/plugins/embeddable/public/lib/reference_or_value_embeddable/attribute_service.ts similarity index 77% rename from src/plugins/embeddable/public/lib/embeddables/attribute_service.ts rename to src/plugins/embeddable/public/lib/reference_or_value_embeddable/attribute_service.ts index a33f592350d9..b51553e05c8e 100644 --- a/src/plugins/embeddable/public/lib/embeddables/attribute_service.ts +++ b/src/plugins/embeddable/public/lib/reference_or_value_embeddable/attribute_service.ts @@ -23,7 +23,7 @@ import { isSavedObjectEmbeddableInput, EmbeddableInput, IEmbeddable, -} from '.'; +} from '../'; import { SimpleSavedObject } from '../../../../../core/public'; export class AttributeService< @@ -65,4 +65,31 @@ export class AttributeService< return { attributes: newAttributes } as ValType; } } + + inputIsRefType = (input: ValType | RefType): input is RefType => { + return isSavedObjectEmbeddableInput(input); + }; + + getInputAsValueType = async (input: ValType | RefType): Promise => { + if (!this.inputIsRefType(input)) { + return input; + } + const attributes = await this.unwrapAttributes(input); + return { + ...input, + savedObjectId: undefined, + attributes, + }; + }; + + getInputAsRefType = async (input: ValType | RefType): Promise => { + if (this.inputIsRefType(input)) { + return input; + } + const wrappedInput = await this.wrapAttributes(input.attributes, true); + return { + id: input.id, + ...wrappedInput, + } as RefType; + }; } diff --git a/src/plugins/embeddable/public/lib/reference_or_value_embeddable/index.ts b/src/plugins/embeddable/public/lib/reference_or_value_embeddable/index.ts new file mode 100644 index 000000000000..8ebb8664d6d0 --- /dev/null +++ b/src/plugins/embeddable/public/lib/reference_or_value_embeddable/index.ts @@ -0,0 +1,21 @@ +/* + * 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. + */ + +export { ReferenceOrValueEmbeddable, isReferenceOrValueEmbeddable } from './types'; +export { AttributeService } from './attribute_service'; diff --git a/src/plugins/embeddable/public/lib/reference_or_value_embeddable/types.ts b/src/plugins/embeddable/public/lib/reference_or_value_embeddable/types.ts new file mode 100644 index 000000000000..eaf5c94a0913 --- /dev/null +++ b/src/plugins/embeddable/public/lib/reference_or_value_embeddable/types.ts @@ -0,0 +1,56 @@ +/* + * 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 { EmbeddableInput, SavedObjectEmbeddableInput } from '..'; + +/** + * Any embeddable that implements this interface will be able to use input that is + * either by reference (backed by a saved object) OR by value, (provided + * by the container). + * @public + */ +export interface ReferenceOrValueEmbeddable< + ValTypeInput extends EmbeddableInput = EmbeddableInput, + RefTypeInput extends SavedObjectEmbeddableInput = SavedObjectEmbeddableInput +> { + /** + * determines whether the input is by value or by reference. + */ + inputIsRefType: (input: ValTypeInput | RefTypeInput) => input is RefTypeInput; + + /** + * Gets the embeddable's current input as its Value type + */ + getInputAsValueType: () => Promise; + + /** + * Gets the embeddable's current input as its Reference type + */ + getInputAsRefType: () => Promise; +} + +export function isReferenceOrValueEmbeddable( + incoming: unknown +): incoming is ReferenceOrValueEmbeddable { + return ( + !!(incoming as ReferenceOrValueEmbeddable).inputIsRefType && + !!(incoming as ReferenceOrValueEmbeddable).getInputAsValueType && + !!(incoming as ReferenceOrValueEmbeddable).getInputAsRefType + ); +} diff --git a/src/plugins/embeddable/public/plugin.tsx b/src/plugins/embeddable/public/plugin.tsx index 508c82c4247e..e9473d061301 100644 --- a/src/plugins/embeddable/public/plugin.tsx +++ b/src/plugins/embeddable/public/plugin.tsx @@ -49,7 +49,7 @@ import { isValueClickTriggerContext, } from './lib'; import { EmbeddableFactoryDefinition } from './lib/embeddables/embeddable_factory_definition'; -import { AttributeService } from './lib/embeddables/attribute_service'; +import { AttributeService } from './lib'; import { EmbeddableStateTransfer } from './lib/state_transfer'; export interface EmbeddableSetupDependencies { From 6ba38d05d942bad35f982f9abc84b8df97996b9f Mon Sep 17 00:00:00 2001 From: Devon A Thomson Date: Wed, 5 Aug 2020 11:03:33 -0400 Subject: [PATCH 04/12] type fix --- examples/embeddable_examples/public/book/book_embeddable.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/embeddable_examples/public/book/book_embeddable.tsx b/examples/embeddable_examples/public/book/book_embeddable.tsx index e0b1df808805..d01ac3f2a614 100644 --- a/examples/embeddable_examples/public/book/book_embeddable.tsx +++ b/examples/embeddable_examples/public/book/book_embeddable.tsx @@ -27,7 +27,6 @@ import { SavedObjectEmbeddableInput, AttributeService, ReferenceOrValueEmbeddable, - isSavedObjectEmbeddableInput, } from '../../../../src/plugins/embeddable/public'; import { BookSavedObjectAttributes } from '../../common'; import { BookEmbeddableComponent } from './book_component'; From d21daef32e0d590b10fbb18b70efa888ee9f7c87 Mon Sep 17 00:00:00 2001 From: Devon A Thomson Date: Thu, 6 Aug 2020 15:49:11 -0400 Subject: [PATCH 05/12] Moved attributeservice to dashboard plugin. Created add and unlink actions to test the referenceOrValueEmbeddable interface. --- examples/embeddable_examples/kibana.json | 2 +- .../book/add_book_to_library_action.tsx | 55 ++++++++++++++++ .../public/book/book_component.tsx | 32 +++++++-- .../public/book/book_embeddable.tsx | 4 +- .../public/book/book_embeddable_factory.tsx | 15 ++++- .../public/book/edit_book_action.tsx | 9 +-- .../book/unlink_book_from_library_action.tsx | 55 ++++++++++++++++ examples/embeddable_examples/public/plugin.ts | 24 ++++++- .../attribute_service/attribute_service.tsx} | 66 +++++++++++++++---- src/plugins/dashboard/public/index.ts | 1 + src/plugins/dashboard/public/plugin.tsx | 18 ++++- src/plugins/embeddable/public/index.ts | 1 - .../reference_or_value_embeddable/index.ts | 1 - src/plugins/embeddable/public/mocks.tsx | 1 - src/plugins/embeddable/public/plugin.tsx | 11 ---- 15 files changed, 248 insertions(+), 47 deletions(-) create mode 100644 examples/embeddable_examples/public/book/add_book_to_library_action.tsx create mode 100644 examples/embeddable_examples/public/book/unlink_book_from_library_action.tsx rename src/plugins/{embeddable/public/lib/reference_or_value_embeddable/attribute_service.ts => dashboard/public/attribute_service/attribute_service.tsx} (58%) diff --git a/examples/embeddable_examples/kibana.json b/examples/embeddable_examples/kibana.json index 771c19cfdbd3..0ac40ae1889d 100644 --- a/examples/embeddable_examples/kibana.json +++ b/examples/embeddable_examples/kibana.json @@ -4,7 +4,7 @@ "kibanaVersion": "kibana", "server": true, "ui": true, - "requiredPlugins": ["embeddable", "uiActions"], + "requiredPlugins": ["embeddable", "uiActions", "dashboard"], "optionalPlugins": [], "extraPublicDirs": ["public/todo", "public/hello_world", "public/todo/todo_ref_embeddable"], "requiredBundles": ["kibanaReact"] 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 new file mode 100644 index 000000000000..b74a1d564298 --- /dev/null +++ b/examples/embeddable_examples/public/book/add_book_to_library_action.tsx @@ -0,0 +1,55 @@ +/* + * 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 { createAction, IncompatibleActionError } from '../../../../src/plugins/ui_actions/public'; +import { BookEmbeddable, BOOK_EMBEDDABLE } from './book_embeddable'; +import { ViewMode, isReferenceOrValueEmbeddable } from '../../../../src/plugins/embeddable/public'; + +interface ActionContext { + embeddable: BookEmbeddable; +} + +export const ACTION_ADD_BOOK_TO_LIBRARY = 'ACTION_ADD_BOOK_TO_LIBRARY'; + +export const createAddBookToLibraryAction = () => + createAction({ + getDisplayName: () => + i18n.translate('embeddableExamples.book.addToLibrary', { + defaultMessage: 'Add Book To Library', + }), + type: ACTION_ADD_BOOK_TO_LIBRARY, + order: 100, + getIconType: () => 'folderCheck', + isCompatible: async ({ embeddable }: ActionContext) => { + return ( + embeddable.type === BOOK_EMBEDDABLE && + embeddable.getInput().viewMode === ViewMode.EDIT && + isReferenceOrValueEmbeddable(embeddable) && + !embeddable.inputIsRefType(embeddable.getInput()) + ); + }, + execute: async ({ embeddable }: ActionContext) => { + if (!isReferenceOrValueEmbeddable(embeddable)) { + throw new IncompatibleActionError(); + } + const newInput = await embeddable.getInputAsRefType(); + embeddable.updateInput(newInput); + }, + }); diff --git a/examples/embeddable_examples/public/book/book_component.tsx b/examples/embeddable_examples/public/book/book_component.tsx index 064e13c131a0..e46487641b91 100644 --- a/examples/embeddable_examples/public/book/book_component.tsx +++ b/examples/embeddable_examples/public/book/book_component.tsx @@ -20,7 +20,7 @@ import React from 'react'; import { EuiFlexItem, EuiFlexGroup, EuiIcon } from '@elastic/eui'; import { EuiText } from '@elastic/eui'; -import { EuiFlexGrid } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { withEmbeddableSubscription } from '../../../../src/plugins/embeddable/public'; import { BookEmbeddableInput, BookEmbeddableOutput, BookEmbeddable } from './book_embeddable'; @@ -44,26 +44,32 @@ function wrapSearchTerms(task?: string, search?: string) { ); } -export function BookEmbeddableComponentInner({ input: { search }, output: { attributes } }: Props) { +export function BookEmbeddableComponentInner({ + input: { search }, + output: { attributes }, + embeddable, +}: Props) { const title = attributes?.title; const author = attributes?.author; const readIt = attributes?.readIt; + const byReference = embeddable.inputIsRefType(embeddable.getInput()); + return ( - + {title ? ( -

{wrapSearchTerms(title, search)},

+

{wrapSearchTerms(title, search)}

) : null} {author ? ( -
-{wrapSearchTerms(author, search)}
+ -{wrapSearchTerms(author, search)}
) : null} @@ -76,7 +82,21 @@ export function BookEmbeddableComponentInner({ input: { search }, output: { attr
)} - +
+ + + + {' '} + + {byReference + ? i18n.translate('embeddableExamples.book.byReferenceLabel', { + defaultMessage: 'Book is By Reference', + }) + : i18n.translate('embeddableExamples.book.byValueLabel', { + defaultMessage: 'Book is By Value', + })} + + ); diff --git a/examples/embeddable_examples/public/book/book_embeddable.tsx b/examples/embeddable_examples/public/book/book_embeddable.tsx index d01ac3f2a614..ba71222694fc 100644 --- a/examples/embeddable_examples/public/book/book_embeddable.tsx +++ b/examples/embeddable_examples/public/book/book_embeddable.tsx @@ -25,11 +25,11 @@ import { IContainer, EmbeddableOutput, SavedObjectEmbeddableInput, - AttributeService, ReferenceOrValueEmbeddable, } from '../../../../src/plugins/embeddable/public'; import { BookSavedObjectAttributes } from '../../common'; import { BookEmbeddableComponent } from './book_component'; +import { AttributeService } from '../../../../src/plugins/dashboard/public'; export const BOOK_EMBEDDABLE = 'book'; export type BookEmbeddableInput = BookByValueInput | BookByReferenceInput; @@ -107,7 +107,7 @@ export class BookEmbeddable extends Embeddable => { - return this.attributeService.getInputAsRefType(this.input); + return this.attributeService.getInputAsRefType(this.input, { showSaveModal: true }); }; public render(node: HTMLElement) { diff --git a/examples/embeddable_examples/public/book/book_embeddable_factory.tsx b/examples/embeddable_examples/public/book/book_embeddable_factory.tsx index 30bbff480d2e..4c144c3843c4 100644 --- a/examples/embeddable_examples/public/book/book_embeddable_factory.tsx +++ b/examples/embeddable_examples/public/book/book_embeddable_factory.tsx @@ -23,9 +23,7 @@ import { BookSavedObjectAttributes, BOOK_SAVED_OBJECT } from '../../common'; import { toMountPoint } from '../../../../src/plugins/kibana_react/public'; import { EmbeddableFactoryDefinition, - EmbeddableStart, IContainer, - AttributeService, EmbeddableFactory, } from '../../../../src/plugins/embeddable/public'; import { @@ -38,9 +36,10 @@ import { } from './book_embeddable'; import { CreateEditBookComponent } from './create_edit_book_component'; import { OverlayStart } from '../../../../src/core/public'; +import { DashboardStart, AttributeService } from '../../../../src/plugins/dashboard/public'; interface StartServices { - getAttributeService: EmbeddableStart['getAttributeService']; + getAttributeService: DashboardStart['getAttributeService']; openModal: OverlayStart['openModal']; } @@ -85,6 +84,16 @@ export class BookEmbeddableFactoryDefinition }); } + // This is currently required due to the distinction in container.ts and the + // default error implementation in default_embeddable_factory_provider.ts + public async createFromSavedObject( + savedObjectId: string, + input: BookEmbeddableInput, + parent?: IContainer + ) { + return this.create(input, parent); + } + public getDisplayName() { return i18n.translate('embeddableExamples.book.displayName', { defaultMessage: 'Book', diff --git a/examples/embeddable_examples/public/book/edit_book_action.tsx b/examples/embeddable_examples/public/book/edit_book_action.tsx index 222f70e0be60..b31d69696598 100644 --- a/examples/embeddable_examples/public/book/edit_book_action.tsx +++ b/examples/embeddable_examples/public/book/edit_book_action.tsx @@ -22,11 +22,7 @@ import { i18n } from '@kbn/i18n'; import { BookSavedObjectAttributes, BOOK_SAVED_OBJECT } from '../../common'; import { createAction } from '../../../../src/plugins/ui_actions/public'; import { toMountPoint } from '../../../../src/plugins/kibana_react/public'; -import { - ViewMode, - EmbeddableStart, - SavedObjectEmbeddableInput, -} from '../../../../src/plugins/embeddable/public'; +import { ViewMode, SavedObjectEmbeddableInput } from '../../../../src/plugins/embeddable/public'; import { BookEmbeddable, BOOK_EMBEDDABLE, @@ -34,10 +30,11 @@ import { BookByValueInput, } from './book_embeddable'; import { CreateEditBookComponent } from './create_edit_book_component'; +import { DashboardStart } from '../../../../src/plugins/dashboard/public'; interface StartServices { openModal: OverlayStart['openModal']; - getAttributeService: EmbeddableStart['getAttributeService']; + getAttributeService: DashboardStart['getAttributeService']; } interface ActionContext { 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 new file mode 100644 index 000000000000..cef77092a642 --- /dev/null +++ b/examples/embeddable_examples/public/book/unlink_book_from_library_action.tsx @@ -0,0 +1,55 @@ +/* + * 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 { createAction, IncompatibleActionError } from '../../../../src/plugins/ui_actions/public'; +import { BookEmbeddable, BOOK_EMBEDDABLE } from './book_embeddable'; +import { ViewMode, isReferenceOrValueEmbeddable } from '../../../../src/plugins/embeddable/public'; + +interface ActionContext { + embeddable: BookEmbeddable; +} + +export const ACTION_UNLINK_BOOK_FROM_LIBRARY = 'ACTION_UNLINK_BOOK_FROM_LIBRARY'; + +export const createUnlinkBookFromLibraryAction = () => + createAction({ + getDisplayName: () => + i18n.translate('embeddableExamples.book.unlinkFromLibrary', { + defaultMessage: 'Unlink Book from Library Item', + }), + type: ACTION_UNLINK_BOOK_FROM_LIBRARY, + order: 100, + getIconType: () => 'folderExclamation', + isCompatible: async ({ embeddable }: ActionContext) => { + return ( + embeddable.type === BOOK_EMBEDDABLE && + embeddable.getInput().viewMode === ViewMode.EDIT && + isReferenceOrValueEmbeddable(embeddable) && + embeddable.inputIsRefType(embeddable.getInput()) + ); + }, + execute: async ({ embeddable }: ActionContext) => { + if (!isReferenceOrValueEmbeddable(embeddable)) { + throw new IncompatibleActionError(); + } + const newInput = await embeddable.getInputAsValueType(); + embeddable.updateInput(newInput); + }, + }); diff --git a/examples/embeddable_examples/public/plugin.ts b/examples/embeddable_examples/public/plugin.ts index 95f4f5b41e19..0c6ed1eb3be4 100644 --- a/examples/embeddable_examples/public/plugin.ts +++ b/examples/embeddable_examples/public/plugin.ts @@ -58,6 +58,15 @@ import { BookEmbeddableFactoryDefinition, } from './book/book_embeddable_factory'; import { UiActionsStart } from '../../../src/plugins/ui_actions/public'; +import { + ACTION_ADD_BOOK_TO_LIBRARY, + createAddBookToLibraryAction, +} from './book/add_book_to_library_action'; +import { DashboardStart } from '../../../src/plugins/dashboard/public'; +import { + ACTION_UNLINK_BOOK_FROM_LIBRARY, + createUnlinkBookFromLibraryAction, +} from './book/unlink_book_from_library_action'; export interface EmbeddableExamplesSetupDependencies { embeddable: EmbeddableSetup; @@ -66,6 +75,7 @@ export interface EmbeddableExamplesSetupDependencies { export interface EmbeddableExamplesStartDependencies { embeddable: EmbeddableStart; + dashboard: DashboardStart; } interface ExampleEmbeddableFactories { @@ -86,6 +96,8 @@ export interface EmbeddableExamplesStart { declare module '../../../src/plugins/ui_actions/public' { export interface ActionContextMapping { [ACTION_EDIT_BOOK]: { embeddable: BookEmbeddable }; + [ACTION_ADD_BOOK_TO_LIBRARY]: { embeddable: BookEmbeddable }; + [ACTION_UNLINK_BOOK_FROM_LIBRARY]: { embeddable: BookEmbeddable }; } } @@ -144,17 +156,25 @@ export class EmbeddableExamplesPlugin this.exampleEmbeddableFactories.getBookEmbeddableFactory = deps.embeddable.registerEmbeddableFactory( BOOK_EMBEDDABLE, new BookEmbeddableFactoryDefinition(async () => ({ - getAttributeService: (await core.getStartServices())[1].embeddable.getAttributeService, + getAttributeService: (await core.getStartServices())[1].dashboard.getAttributeService, openModal: (await core.getStartServices())[0].overlays.openModal, })) ); const editBookAction = createEditBookAction(async () => ({ - getAttributeService: (await core.getStartServices())[1].embeddable.getAttributeService, + getAttributeService: (await core.getStartServices())[1].dashboard.getAttributeService, openModal: (await core.getStartServices())[0].overlays.openModal, })); deps.uiActions.registerAction(editBookAction); deps.uiActions.attachAction(CONTEXT_MENU_TRIGGER, editBookAction.id); + + const addBookToLibraryAction = createAddBookToLibraryAction(); + deps.uiActions.registerAction(addBookToLibraryAction); + deps.uiActions.attachAction(CONTEXT_MENU_TRIGGER, addBookToLibraryAction.id); + + const unlinkBookFromLibraryAction = createUnlinkBookFromLibraryAction(); + deps.uiActions.registerAction(unlinkBookFromLibraryAction); + deps.uiActions.attachAction(CONTEXT_MENU_TRIGGER, unlinkBookFromLibraryAction.id); } public start( diff --git a/src/plugins/embeddable/public/lib/reference_or_value_embeddable/attribute_service.ts b/src/plugins/dashboard/public/attribute_service/attribute_service.tsx similarity index 58% rename from src/plugins/embeddable/public/lib/reference_or_value_embeddable/attribute_service.ts rename to src/plugins/dashboard/public/attribute_service/attribute_service.tsx index b51553e05c8e..53c7ee2e74e1 100644 --- a/src/plugins/embeddable/public/lib/reference_or_value_embeddable/attribute_service.ts +++ b/src/plugins/dashboard/public/attribute_service/attribute_service.tsx @@ -17,24 +17,40 @@ * under the License. */ -import { SavedObjectsClientContract } from '../../../../../core/public'; +import React from 'react'; import { + EmbeddableInput, SavedObjectEmbeddableInput, isSavedObjectEmbeddableInput, - EmbeddableInput, IEmbeddable, -} from '../'; -import { SimpleSavedObject } from '../../../../../core/public'; +} from '../embeddable_plugin'; +import { SavedObjectsClientContract, SimpleSavedObject, I18nStart } from '../../../../core/public'; +import { + SavedObjectSaveModal, + showSaveModal, + OnSaveProps, + SaveResult, +} from '../../../saved_objects/public'; +/** + * The attribute service is a shared, generic service that embeddables can use to provide the functionality + * required to fulfill the requirements of the ReferenceOrValueEmbeddable interface. The attribute_service + * can also be used as a higher level wrapper to transform an embeddable input shape that references a saved object + * into an embeddable input shape that contains that saved object's attributes by value. + */ export class AttributeService< SavedObjectAttributes, ValType extends EmbeddableInput & { attributes: SavedObjectAttributes }, RefType extends SavedObjectEmbeddableInput > { - constructor(private type: string, private savedObjectsClient: SavedObjectsClientContract) {} + constructor( + private type: string, + private savedObjectsClient: SavedObjectsClientContract, + private i18nContext: I18nStart['Context'] + ) {} public async unwrapAttributes(input: RefType | ValType): Promise { - if (isSavedObjectEmbeddableInput(input)) { + if (this.inputIsRefType(input)) { const savedObject: SimpleSavedObject = await this.savedObjectsClient.get< SavedObjectAttributes >(this.type, input.savedObjectId); @@ -82,14 +98,40 @@ export class AttributeService< }; }; - getInputAsRefType = async (input: ValType | RefType): Promise => { + getInputAsRefType = async ( + input: ValType | RefType, + saveOptions?: { showSaveModal: boolean } | { title: string } + ): Promise => { if (this.inputIsRefType(input)) { return input; } - const wrappedInput = await this.wrapAttributes(input.attributes, true); - return { - id: input.id, - ...wrappedInput, - } as RefType; + + return new Promise((resolve, reject) => { + const onSave = async (props: OnSaveProps): Promise => { + try { + const wrappedInput = (await this.wrapAttributes(input.attributes, true)) as RefType; + wrappedInput.title = props.newTitle; + resolve(wrappedInput); + return { id: wrappedInput.savedObjectId }; + } catch (error) { + reject(); + return { error }; + } + }; + + if (saveOptions && (saveOptions as { showSaveModal: boolean }).showSaveModal) { + showSaveModal( + reject()} + title={input.title || ''} + showCopyOnSave={false} + objectType={this.type} + showDescription={false} + />, + this.i18nContext + ); + } + }); }; } diff --git a/src/plugins/dashboard/public/index.ts b/src/plugins/dashboard/public/index.ts index dcfde67cd9f1..8a9954cc77a2 100644 --- a/src/plugins/dashboard/public/index.ts +++ b/src/plugins/dashboard/public/index.ts @@ -40,6 +40,7 @@ export { export { addEmbeddableToDashboardUrl } from './url_utils/url_helper'; export { SavedObjectDashboard } from './saved_dashboards'; export { SavedDashboardPanel } from './types'; +export { AttributeService } from './attribute_service/attribute_service'; export function plugin(initializerContext: PluginInitializerContext) { return new DashboardPlugin(initializerContext); diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index f1319665d258..487b35a65227 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -34,7 +34,13 @@ import { ScopedHistory, } from 'src/core/public'; import { UsageCollectionSetup } from '../../usage_collection/public'; -import { CONTEXT_MENU_TRIGGER, EmbeddableSetup, EmbeddableStart } from '../../embeddable/public'; +import { + CONTEXT_MENU_TRIGGER, + EmbeddableSetup, + EmbeddableStart, + SavedObjectEmbeddableInput, + EmbeddableInput, +} from '../../embeddable/public'; import { DataPublicPluginSetup, DataPublicPluginStart, esFilters } from '../../data/public'; import { SharePluginSetup, SharePluginStart, UrlGeneratorContract } from '../../share/public'; import { UiActionsSetup, UiActionsStart } from '../../ui_actions/public'; @@ -85,6 +91,7 @@ import { DashboardConstants } from './dashboard_constants'; import { addEmbeddableToDashboardUrl } from './url_utils/url_helper'; import { PlaceholderEmbeddableFactory } from './application/embeddable/placeholder'; import { UrlGeneratorState } from '../../share/public'; +import { AttributeService } from '.'; declare module '../../share/public' { export interface UrlGeneratorStateMapping { @@ -131,6 +138,13 @@ export interface DashboardStart { dashboardUrlGenerator?: DashboardUrlGenerator; dashboardFeatureFlagConfig: DashboardFeatureFlagConfig; DashboardContainerByValueRenderer: ReturnType; + getAttributeService: < + A, + V extends EmbeddableInput & { attributes: A }, + R extends SavedObjectEmbeddableInput + >( + type: string + ) => AttributeService; } declare module '../../../plugins/ui_actions/public' { @@ -420,6 +434,8 @@ export class DashboardPlugin DashboardContainerByValueRenderer: createDashboardContainerByValueRenderer({ factory: dashboardContainerFactory, }), + getAttributeService: (type: string) => + new AttributeService(type, core.savedObjects.client, core.i18n.Context), }; } diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index 206aa9056a1c..57253c1f741a 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -28,7 +28,6 @@ export { ACTION_EDIT_PANEL, Adapters, AddPanelAction, - AttributeService, ReferenceOrValueEmbeddable, isReferenceOrValueEmbeddable, ChartActionContext, diff --git a/src/plugins/embeddable/public/lib/reference_or_value_embeddable/index.ts b/src/plugins/embeddable/public/lib/reference_or_value_embeddable/index.ts index 8ebb8664d6d0..e9b8521a35ba 100644 --- a/src/plugins/embeddable/public/lib/reference_or_value_embeddable/index.ts +++ b/src/plugins/embeddable/public/lib/reference_or_value_embeddable/index.ts @@ -18,4 +18,3 @@ */ export { ReferenceOrValueEmbeddable, isReferenceOrValueEmbeddable } from './types'; -export { AttributeService } from './attribute_service'; diff --git a/src/plugins/embeddable/public/mocks.tsx b/src/plugins/embeddable/public/mocks.tsx index 48e548312470..efd0ccdc4553 100644 --- a/src/plugins/embeddable/public/mocks.tsx +++ b/src/plugins/embeddable/public/mocks.tsx @@ -99,7 +99,6 @@ const createStartContract = (): Start => { getEmbeddableFactories: jest.fn(), getEmbeddableFactory: jest.fn(), EmbeddablePanel: jest.fn(), - getAttributeService: jest.fn(), getEmbeddablePanel: jest.fn(), getStateTransfer: jest.fn(() => createEmbeddableStateTransferMock() as EmbeddableStateTransfer), filtersAndTimeRangeFromContext: jest.fn(), diff --git a/src/plugins/embeddable/public/plugin.tsx b/src/plugins/embeddable/public/plugin.tsx index e9473d061301..4f7d6b30c8fe 100644 --- a/src/plugins/embeddable/public/plugin.tsx +++ b/src/plugins/embeddable/public/plugin.tsx @@ -43,13 +43,11 @@ import { defaultEmbeddableFactoryProvider, IEmbeddable, EmbeddablePanel, - SavedObjectEmbeddableInput, ChartActionContext, isRangeSelectTriggerContext, isValueClickTriggerContext, } from './lib'; import { EmbeddableFactoryDefinition } from './lib/embeddables/embeddable_factory_definition'; -import { AttributeService } from './lib'; import { EmbeddableStateTransfer } from './lib/state_transfer'; export interface EmbeddableSetupDependencies { @@ -84,14 +82,6 @@ export interface EmbeddableStart { embeddableFactoryId: string ) => EmbeddableFactory | undefined; getEmbeddableFactories: () => IterableIterator; - getAttributeService: < - A, - V extends EmbeddableInput & { attributes: A }, - R extends SavedObjectEmbeddableInput - >( - type: string - ) => AttributeService; - /** * Given {@link ChartActionContext} returns a list of `data` plugin {@link Filter} entries. */ @@ -215,7 +205,6 @@ export class EmbeddablePublicPlugin implements Plugin new AttributeService(type, core.savedObjects.client), filtersFromContext, filtersAndTimeRangeFromContext, getStateTransfer: (history?: ScopedHistory) => { From 19b2bc06d3d6244c4e318093518002a0220e83ec Mon Sep 17 00:00:00 2001 From: Devon A Thomson Date: Thu, 6 Aug 2020 17:35:08 -0400 Subject: [PATCH 06/12] type fix --- src/plugins/embeddable/public/plugin.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/plugins/embeddable/public/plugin.tsx b/src/plugins/embeddable/public/plugin.tsx index 3303bfa2bed0..3cbd49279564 100644 --- a/src/plugins/embeddable/public/plugin.tsx +++ b/src/plugins/embeddable/public/plugin.tsx @@ -37,7 +37,6 @@ import { defaultEmbeddableFactoryProvider, IEmbeddable, EmbeddablePanel, - SavedObjectEmbeddableInput, } from './lib'; import { EmbeddableFactoryDefinition } from './lib/embeddables/embeddable_factory_definition'; import { EmbeddableStateTransfer } from './lib/state_transfer'; From 230341db12c7a24b11851cc0848e1e9b94ee8a60 Mon Sep 17 00:00:00 2001 From: Devon A Thomson Date: Mon, 10 Aug 2020 13:45:37 -0400 Subject: [PATCH 07/12] Added error handling to the attribute_service save methods --- .../attribute_service/attribute_service.tsx | 44 ++++++++++++++----- src/plugins/dashboard/public/plugin.tsx | 7 ++- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/src/plugins/dashboard/public/attribute_service/attribute_service.tsx b/src/plugins/dashboard/public/attribute_service/attribute_service.tsx index 53c7ee2e74e1..da5171fccd8c 100644 --- a/src/plugins/dashboard/public/attribute_service/attribute_service.tsx +++ b/src/plugins/dashboard/public/attribute_service/attribute_service.tsx @@ -18,13 +18,19 @@ */ import React from 'react'; +import { i18n } from '@kbn/i18n'; import { EmbeddableInput, SavedObjectEmbeddableInput, isSavedObjectEmbeddableInput, IEmbeddable, } from '../embeddable_plugin'; -import { SavedObjectsClientContract, SimpleSavedObject, I18nStart } from '../../../../core/public'; +import { + SavedObjectsClientContract, + SimpleSavedObject, + I18nStart, + NotificationsStart, +} from '../../../../core/public'; import { SavedObjectSaveModal, showSaveModal, @@ -46,7 +52,8 @@ export class AttributeService< constructor( private type: string, private savedObjectsClient: SavedObjectsClientContract, - private i18nContext: I18nStart['Context'] + private i18nContext: I18nStart['Context'], + private toasts: NotificationsStart['toasts'] ) {} public async unwrapAttributes(input: RefType | ValType): Promise { @@ -68,17 +75,29 @@ export class AttributeService< embeddable && isSavedObjectEmbeddableInput(embeddable.getInput()) ? (embeddable.getInput() as SavedObjectEmbeddableInput).savedObjectId : undefined; - - if (useRefType) { - if (savedObjectId) { - await this.savedObjectsClient.update(this.type, savedObjectId, newAttributes); - return { savedObjectId } as RefType; - } else { - const savedItem = await this.savedObjectsClient.create(this.type, newAttributes); - return { savedObjectId: savedItem.id } as RefType; - } - } else { + if (!useRefType) { return { attributes: newAttributes } as ValType; + } else { + try { + if (savedObjectId) { + await this.savedObjectsClient.update(this.type, savedObjectId, newAttributes); + return { savedObjectId } as RefType; + } else { + const savedItem = await this.savedObjectsClient.create(this.type, newAttributes); + return { savedObjectId: savedItem.id } as RefType; + } + } catch (error) { + this.toasts.addDanger({ + title: i18n.translate('dashboard.attributeService.saveToLibraryError', { + defaultMessage: `Panel was not saved to the library. Error: {errorMessage}`, + values: { + errorMessage: error.message, + }, + }), + 'data-test-subj': 'saveDashboardFailure', + }); + return Promise.reject({ error }); + } } } @@ -112,6 +131,7 @@ export class AttributeService< const wrappedInput = (await this.wrapAttributes(input.attributes, true)) as RefType; wrappedInput.title = props.newTitle; resolve(wrappedInput); + throw new Error(); return { id: wrappedInput.savedObjectId }; } catch (error) { reject(); diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index 487b35a65227..008d51f1447c 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -435,7 +435,12 @@ export class DashboardPlugin factory: dashboardContainerFactory, }), getAttributeService: (type: string) => - new AttributeService(type, core.savedObjects.client, core.i18n.Context), + new AttributeService( + type, + core.savedObjects.client, + core.i18n.Context, + core.notifications.toasts + ), }; } From ed0cb93eb09591eaea914471fea86686332ea982 Mon Sep 17 00:00:00 2001 From: Devon A Thomson Date: Wed, 12 Aug 2020 13:30:40 -0400 Subject: [PATCH 08/12] Sorted out title issue --- .../embeddable_examples/public/book/book_embeddable.tsx | 4 ++++ .../public/attribute_service/attribute_service.tsx | 7 +++---- src/plugins/dashboard/public/plugin.tsx | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/examples/embeddable_examples/public/book/book_embeddable.tsx b/examples/embeddable_examples/public/book/book_embeddable.tsx index ba71222694fc..dd9418c0e859 100644 --- a/examples/embeddable_examples/public/book/book_embeddable.tsx +++ b/examples/embeddable_examples/public/book/book_embeddable.tsx @@ -127,6 +127,10 @@ export class BookEmbeddable extends Embeddable { @@ -128,10 +128,9 @@ export class AttributeService< return new Promise((resolve, reject) => { const onSave = async (props: OnSaveProps): Promise => { try { + input.attributes.title = props.newTitle; const wrappedInput = (await this.wrapAttributes(input.attributes, true)) as RefType; - wrappedInput.title = props.newTitle; resolve(wrappedInput); - throw new Error(); return { id: wrappedInput.savedObjectId }; } catch (error) { reject(); @@ -144,7 +143,7 @@ export class AttributeService< reject()} - title={input.title || ''} + title={input.attributes.title} showCopyOnSave={false} objectType={this.type} showDescription={false} diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index 008d51f1447c..3b0863a9f465 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -139,7 +139,7 @@ export interface DashboardStart { dashboardFeatureFlagConfig: DashboardFeatureFlagConfig; DashboardContainerByValueRenderer: ReturnType; getAttributeService: < - A, + A extends { title: string }, V extends EmbeddableInput & { attributes: A }, R extends SavedObjectEmbeddableInput >( From 0ac821c129cdab1f8c8b34564201fac30cdd5a8b Mon Sep 17 00:00:00 2001 From: Devon A Thomson Date: Wed, 12 Aug 2020 16:08:41 -0400 Subject: [PATCH 09/12] Upgraded unlink from library action to work with the ReferenceOrValue interface --- .../unlink_from_library_action.test.tsx | 73 +++++++++++-------- .../actions/unlink_from_library_action.tsx | 25 ++----- .../embeddables/contact_card/contact_card.tsx | 3 +- src/plugins/embeddable/public/mocks.tsx | 21 ++++++ 4 files changed, 72 insertions(+), 50 deletions(-) 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 index d9b73f9d03cd..8a6b49c4fe70 100644 --- 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 @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { isErrorEmbeddable, IContainer } from '../../embeddable_plugin'; +import { isErrorEmbeddable, IContainer, ReferenceOrValueEmbeddable } from '../../embeddable_plugin'; import { DashboardContainer } from '../embeddable'; import { getSampleDashboardInput, getSampleDashboardPanel } from '../test_helpers'; import { @@ -29,9 +29,8 @@ import { 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'; +import { ViewMode } from '../../../../embeddable/public'; const { setup, doStart } = embeddablePluginMock.createInstance(); setup.registerEmbeddableFactory( @@ -41,7 +40,7 @@ setup.registerEmbeddableFactory( const start = doStart(); let container: DashboardContainer; -let embeddable: ContactCardEmbeddable; +let embeddable: ContactCardEmbeddable & ReferenceOrValueEmbeddable; let coreStart: CoreStart; beforeEach(async () => { coreStart = coreMock.createStart(); @@ -52,7 +51,7 @@ beforeEach(async () => { create: jest.fn().mockImplementation(() => ({ id: 'brandNewSavedObject' })), }; - const options = { + const containerOptions = { ExitFullScreenButton: () => null, SavedObjectFinder: () => null, application: {} as any, @@ -63,58 +62,69 @@ beforeEach(async () => { 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); + + container = new DashboardContainer(getSampleDashboardInput(), containerOptions); const contactCardEmbeddable = await container.addNewEmbeddable< ContactCardEmbeddableInput, ContactCardEmbeddableOutput, ContactCardEmbeddable >(CONTACT_CARD_EMBEDDABLE, { - firstName: 'Kibana', + firstName: 'Kibanana', }); - contactCardEmbeddable.updateInput({ savedObjectId: 'coolestSavedObjectId' }); - if (isErrorEmbeddable(contactCardEmbeddable)) { throw new Error('Failed to create embeddable'); } else { - embeddable = contactCardEmbeddable; + 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 not compatible when embeddable does not have a savedObjectId', async () => { +test('Unlink is compatible when embeddable on dashboard has reference type input', async () => { const action = new UnlinkFromLibraryAction(coreStart); - embeddable.updateInput({ savedObjectId: undefined }); + 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(coreStart); + 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(coreStart); + 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 () => { - const orphanContactCard = await container.addNewEmbeddable< + let orphanContactCard = await container.addNewEmbeddable< ContactCardEmbeddableInput, ContactCardEmbeddableOutput, ContactCardEmbeddable >(CONTACT_CARD_EMBEDDABLE, { firstName: 'Orphan', }); - orphanContactCard.updateInput({ savedObjectId: 'coolestSavedObjectId' }); + orphanContactCard = embeddablePluginMock.mockRefOrValEmbeddable< + ContactCardEmbeddable, + ContactCardEmbeddableInput + >(orphanContactCard, { + mockedByReferenceInput: { savedObjectId: 'test', id: orphanContactCard.id }, + mockedByValueInput: { firstName: 'Kibanana', id: orphanContactCard.id }, + }); const action = new UnlinkFromLibraryAction(coreStart); expect(await action.isCompatible({ embeddable: orphanContactCard })).toBe(false); }); -test('Unlink is compatible when embeddable on dashboard has a savedObjectId', async () => { - const action = new UnlinkFromLibraryAction(coreStart); - embeddable.updateInput({ savedObjectId: undefined }); - expect(await action.isCompatible({ embeddable })).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; @@ -139,9 +149,10 @@ test('Unlink unwraps all attributes from savedObject', async () => { attribute4: { nestedattribute: 'hello from the nest' }, }; - coreStart.savedObjects.client.get = jest.fn().mockImplementation(() => ({ - attributes: complicatedAttributes, - })); + 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(coreStart); 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 index c23e09d22eb6..1be80e1f6395 100644 --- a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx +++ b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx @@ -27,6 +27,7 @@ import { PanelNotFoundError, EmbeddableInput, SavedObjectEmbeddableInput, + isReferenceOrValueEmbeddable, } from '../../../../embeddable/public'; import { DashboardPanelState, DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '..'; @@ -65,25 +66,18 @@ export class UnlinkFromLibraryAction implements ActionByType = { type: embeddable.type, - explicitInput: { - ...panelToReplace.explicitInput, - savedObjectId: undefined, - id: uuid.v4(), - attributes: savedObject.attributes, - }, + explicitInput: { ...newInput, id: uuid.v4() }, }; dashboard.replacePanel(panelToReplace, newPanel); } diff --git a/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card.tsx b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card.tsx index 01228c778754..3459eb5fc076 100644 --- a/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card.tsx +++ b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card.tsx @@ -25,6 +25,7 @@ import * as Rx from 'rxjs'; import { UiActionsStart } from '../../../../../../ui_actions/public'; import { ContactCardEmbeddable, CONTACT_USER_TRIGGER } from './contact_card_embeddable'; import { EmbeddableContext } from '../../../triggers'; +import { ContactCardByRefOrValEmbeddable } from '../ref_or_val_contact_card/contact_card_ref_or_val_embeddable'; declare module '../../../../../../ui_actions/public' { export interface TriggerContextMapping { @@ -33,7 +34,7 @@ declare module '../../../../../../ui_actions/public' { } interface Props { - embeddable: ContactCardEmbeddable; + embeddable: ContactCardEmbeddable | ContactCardByRefOrValEmbeddable; execTrigger: UiActionsStart['executeTriggerActions']; } diff --git a/src/plugins/embeddable/public/mocks.tsx b/src/plugins/embeddable/public/mocks.tsx index fa79af909a42..7ec03ba659cd 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, }; From 957b7d366605488521f38225b5bd1fa9c86038f0 Mon Sep 17 00:00:00 2001 From: Devon A Thomson Date: Wed, 12 Aug 2020 16:50:02 -0400 Subject: [PATCH 10/12] Upgraded unlink from library action to work with the ReferenceOrValue interface --- .../public/book/add_book_to_library_action.tsx | 3 +++ .../public/book/book_embeddable.tsx | 8 +++++++- .../book/unlink_book_from_library_action.tsx | 3 +++ .../unlink_from_library_action.test.tsx | 18 ++++++------------ .../actions/unlink_from_library_action.tsx | 6 ++---- 5 files changed, 21 insertions(+), 17 deletions(-) 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 b74a1d564298..4ae3a545df0d 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 dd9418c0e859..73b1629d985b 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 cef77092a642..ebab2c483c62 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/unlink_from_library_action.test.tsx b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx index 8a6b49c4fe70..f7cc6dd30c4c 100644 --- 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 @@ -44,12 +44,6 @@ let embeddable: 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 containerOptions = { ExitFullScreenButton: () => null, @@ -88,19 +82,19 @@ beforeEach(async () => { }); test('Unlink is compatible when embeddable on dashboard has reference type input', async () => { - const action = new UnlinkFromLibraryAction(coreStart); + 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(coreStart); + 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(coreStart); + const action = new UnlinkFromLibraryAction(); embeddable.updateInput(await embeddable.getInputAsRefType()); embeddable.updateInput({ viewMode: ViewMode.VIEW }); expect(await action.isCompatible({ embeddable })).toBe(false); @@ -121,7 +115,7 @@ test('Unlink is not compatible when embeddable is not in a dashboard container', mockedByReferenceInput: { savedObjectId: 'test', id: orphanContactCard.id }, mockedByValueInput: { firstName: 'Kibanana', id: orphanContactCard.id }, }); - const action = new UnlinkFromLibraryAction(coreStart); + const action = new UnlinkFromLibraryAction(); expect(await action.isCompatible({ embeddable: orphanContactCard })).toBe(false); }); @@ -129,7 +123,7 @@ 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); + const action = new UnlinkFromLibraryAction(); await action.execute({ embeddable }); expect(Object.keys(container.getInput().panels).length).toEqual(originalPanelCount); @@ -155,7 +149,7 @@ test('Unlink unwraps all attributes from savedObject', async () => { }); const dashboard = embeddable.getRoot() as IContainer; const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels)); - const action = new UnlinkFromLibraryAction(coreStart); + const action = new UnlinkFromLibraryAction(); await action.execute({ embeddable }); const newPanelId = Object.keys(container.getInput().panels).find( (key) => !originalPanelKeySet.has(key) 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 index 1be80e1f6395..e2a6ec7dd394 100644 --- a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx +++ b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx @@ -18,7 +18,6 @@ */ 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'; @@ -26,7 +25,6 @@ import { ViewMode, PanelState, IEmbeddable } from '../../embeddable_plugin'; import { PanelNotFoundError, EmbeddableInput, - SavedObjectEmbeddableInput, isReferenceOrValueEmbeddable, } from '../../../../embeddable/public'; import { DashboardPanelState, DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '..'; @@ -42,14 +40,14 @@ export class UnlinkFromLibraryAction implements ActionByType Date: Wed, 12 Aug 2020 17:37:53 -0400 Subject: [PATCH 11/12] type fix --- .../application/actions/unlink_from_library_action.test.tsx | 2 +- src/plugins/dashboard/public/plugin.tsx | 2 +- .../lib/test_samples/embeddables/contact_card/contact_card.tsx | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) 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 index f7cc6dd30c4c..11bac6fa1e04 100644 --- 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 @@ -18,7 +18,7 @@ */ import { isErrorEmbeddable, IContainer, ReferenceOrValueEmbeddable } from '../../embeddable_plugin'; import { DashboardContainer } from '../embeddable'; -import { getSampleDashboardInput, getSampleDashboardPanel } from '../test_helpers'; +import { getSampleDashboardInput } from '../test_helpers'; import { CONTACT_CARD_EMBEDDABLE, ContactCardEmbeddableFactory, diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index bf4cbeff17a9..2a36f2d80185 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -424,7 +424,7 @@ export class DashboardPlugin uiActions.attachAction(CONTEXT_MENU_TRIGGER, clonePanelAction.id); if (this.dashboardFeatureFlagConfig?.allowByValueEmbeddables) { - const unlinkFromLibraryAction = new UnlinkFromLibraryAction(core); + const unlinkFromLibraryAction = new UnlinkFromLibraryAction(); uiActions.registerAction(unlinkFromLibraryAction); uiActions.attachAction(CONTEXT_MENU_TRIGGER, unlinkFromLibraryAction.id); } diff --git a/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card.tsx b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card.tsx index 3459eb5fc076..01228c778754 100644 --- a/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card.tsx +++ b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card.tsx @@ -25,7 +25,6 @@ import * as Rx from 'rxjs'; import { UiActionsStart } from '../../../../../../ui_actions/public'; import { ContactCardEmbeddable, CONTACT_USER_TRIGGER } from './contact_card_embeddable'; import { EmbeddableContext } from '../../../triggers'; -import { ContactCardByRefOrValEmbeddable } from '../ref_or_val_contact_card/contact_card_ref_or_val_embeddable'; declare module '../../../../../../ui_actions/public' { export interface TriggerContextMapping { @@ -34,7 +33,7 @@ declare module '../../../../../../ui_actions/public' { } interface Props { - embeddable: ContactCardEmbeddable | ContactCardByRefOrValEmbeddable; + embeddable: ContactCardEmbeddable; execTrigger: UiActionsStart['executeTriggerActions']; } From 10609146bed9724469901707e96baacea367fe86 Mon Sep 17 00:00:00 2001 From: Devon A Thomson Date: Mon, 17 Aug 2020 13:19:23 -0400 Subject: [PATCH 12/12] small cleanup --- .../actions/unlink_from_library_action.test.tsx | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) 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 index 11bac6fa1e04..681a6a734a53 100644 --- 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 @@ -69,16 +69,15 @@ beforeEach(async () => { 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 }); } + 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 () => {