diff --git a/examples/embeddable_examples/public/book/book_embeddable.tsx b/examples/embeddable_examples/public/book/book_embeddable.tsx index b033fe86cd1c7..48b81c27e8b8d 100644 --- a/examples/embeddable_examples/public/book/book_embeddable.tsx +++ b/examples/embeddable_examples/public/book/book_embeddable.tsx @@ -71,11 +71,7 @@ export class BookEmbeddable constructor( initialInput: BookEmbeddableInput, - private attributeService: AttributeService< - BookSavedObjectAttributes, - BookByValueInput, - BookByReferenceInput - >, + private attributeService: AttributeService, { parent, }: { @@ -99,18 +95,21 @@ export class BookEmbeddable }); } - inputIsRefType = (input: BookEmbeddableInput): input is BookByReferenceInput => { + readonly inputIsRefType = (input: BookEmbeddableInput): input is BookByReferenceInput => { return this.attributeService.inputIsRefType(input); }; - getInputAsValueType = async (): Promise => { + readonly getInputAsValueType = async (): Promise => { const input = this.attributeService.getExplicitInputFromEmbeddable(this); return this.attributeService.getInputAsValueType(input); }; - getInputAsRefType = async (): Promise => { + readonly getInputAsRefType = async (): Promise => { const input = this.attributeService.getExplicitInputFromEmbeddable(this); - return this.attributeService.getInputAsRefType(input, { showSaveModal: true }); + return this.attributeService.getInputAsRefType(input, { + showSaveModal: true, + saveModalTitle: this.getTitle(), + }); }; 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 4c144c3843c47..292261ee16c59 100644 --- a/examples/embeddable_examples/public/book/book_embeddable_factory.tsx +++ b/examples/embeddable_examples/public/book/book_embeddable_factory.tsx @@ -31,8 +31,6 @@ import { BOOK_EMBEDDABLE, BookEmbeddableInput, BookEmbeddableOutput, - BookByValueInput, - BookByReferenceInput, } from './book_embeddable'; import { CreateEditBookComponent } from './create_edit_book_component'; import { OverlayStart } from '../../../../src/core/public'; @@ -66,11 +64,7 @@ export class BookEmbeddableFactoryDefinition getIconForSavedObject: () => 'pencil', }; - private attributeService?: AttributeService< - BookSavedObjectAttributes, - BookByValueInput, - BookByReferenceInput - >; + private attributeService?: AttributeService; constructor(private getStartServices: () => Promise) {} @@ -126,9 +120,7 @@ export class BookEmbeddableFactoryDefinition private async getAttributeService() { if (!this.attributeService) { this.attributeService = await (await this.getStartServices()).getAttributeService< - BookSavedObjectAttributes, - BookByValueInput, - BookByReferenceInput + BookSavedObjectAttributes >(this.type); } return this.attributeService!; diff --git a/examples/embeddable_examples/public/book/edit_book_action.tsx b/examples/embeddable_examples/public/book/edit_book_action.tsx index 5b14dc85b1fc7..3541ace1e5e7e 100644 --- a/examples/embeddable_examples/public/book/edit_book_action.tsx +++ b/examples/embeddable_examples/public/book/edit_book_action.tsx @@ -57,13 +57,13 @@ export const createEditBookAction = (getStartServices: () => Promise { const { openModal, getAttributeService } = await getStartServices(); - const attributeService = getAttributeService< - BookSavedObjectAttributes, - BookByValueInput, - BookByReferenceInput - >(BOOK_SAVED_OBJECT); + const attributeService = getAttributeService(BOOK_SAVED_OBJECT); const onSave = async (attributes: BookSavedObjectAttributes, useRefType: boolean) => { - const newInput = await attributeService.wrapAttributes(attributes, useRefType, embeddable); + const newInput = await attributeService.wrapAttributes( + attributes, + useRefType, + attributeService.getExplicitInputFromEmbeddable(embeddable) + ); if (!useRefType && (embeddable.getInput() as SavedObjectEmbeddableInput).savedObjectId) { // Set the saved object ID to null so that update input will remove the existing savedObjectId... (newInput as BookByValueInput & { savedObjectId: unknown }).savedObjectId = null; diff --git a/src/plugins/dashboard/public/attribute_service/attribute_service.test.ts b/src/plugins/dashboard/public/attribute_service/attribute_service.test.ts new file mode 100644 index 0000000000000..06f380ca3862b --- /dev/null +++ b/src/plugins/dashboard/public/attribute_service/attribute_service.test.ts @@ -0,0 +1,193 @@ +/* + * 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 { ATTRIBUTE_SERVICE_KEY } from './attribute_service'; +import { mockAttributeService } from './attribute_service_mock'; +import { coreMock } from '../../../../core/public/mocks'; + +interface TestAttributes { + title: string; + testAttr1?: string; + testAttr2?: { array: unknown[]; testAttr3: string }; +} + +interface TestByValueInput { + id: string; + [ATTRIBUTE_SERVICE_KEY]: TestAttributes; +} + +describe('attributeService', () => { + const defaultTestType = 'defaultTestType'; + let attributes: TestAttributes; + let byValueInput: TestByValueInput; + let byReferenceInput: { id: string; savedObjectId: string }; + + beforeEach(() => { + attributes = { + title: 'ultra title', + testAttr1: 'neat first attribute', + testAttr2: { array: [1, 2, 3], testAttr3: 'super attribute' }, + }; + byValueInput = { + id: '456', + attributes, + }; + byReferenceInput = { + id: '456', + savedObjectId: '123', + }; + }); + + describe('determining input type', () => { + const defaultAttributeService = mockAttributeService(defaultTestType); + const customAttributeService = mockAttributeService( + defaultTestType + ); + + it('can determine input type given default types', () => { + expect( + defaultAttributeService.inputIsRefType({ id: '456', savedObjectId: '123' }) + ).toBeTruthy(); + expect( + defaultAttributeService.inputIsRefType({ + id: '456', + attributes: { title: 'wow I am by value' }, + }) + ).toBeFalsy(); + }); + it('can determine input type given custom types', () => { + expect( + customAttributeService.inputIsRefType({ id: '456', savedObjectId: '123' }) + ).toBeTruthy(); + expect( + customAttributeService.inputIsRefType({ + id: '456', + [ATTRIBUTE_SERVICE_KEY]: { title: 'wow I am by value' }, + }) + ).toBeFalsy(); + }); + }); + + describe('unwrapping attributes', () => { + it('can unwrap all default attributes when given reference type input', async () => { + const core = coreMock.createStart(); + core.savedObjects.client.get = jest.fn().mockResolvedValueOnce({ + attributes, + }); + const attributeService = mockAttributeService( + defaultTestType, + undefined, + core + ); + expect(await attributeService.unwrapAttributes(byReferenceInput)).toEqual(attributes); + }); + + it('returns attributes when when given value type input', async () => { + const attributeService = mockAttributeService(defaultTestType); + expect(await attributeService.unwrapAttributes(byValueInput)).toEqual(attributes); + }); + + it('runs attributes through a custom unwrap method', async () => { + const core = coreMock.createStart(); + core.savedObjects.client.get = jest.fn().mockResolvedValueOnce({ + attributes, + }); + const attributeService = mockAttributeService( + defaultTestType, + { + customUnwrapMethod: (savedObject) => ({ + ...savedObject.attributes, + testAttr2: { array: [1, 2, 3, 4, 5], testAttr3: 'kibanana' }, + }), + }, + core + ); + expect(await attributeService.unwrapAttributes(byReferenceInput)).toEqual({ + ...attributes, + testAttr2: { array: [1, 2, 3, 4, 5], testAttr3: 'kibanana' }, + }); + }); + }); + + describe('wrapping attributes', () => { + it('returns given attributes when use ref type is false', async () => { + const attributeService = mockAttributeService(defaultTestType); + expect(await attributeService.wrapAttributes(attributes, false)).toEqual({ attributes }); + }); + + it('updates existing saved object with new attributes when given id', async () => { + const core = coreMock.createStart(); + const attributeService = mockAttributeService( + defaultTestType, + undefined, + core + ); + expect(await attributeService.wrapAttributes(attributes, true, byReferenceInput)).toEqual( + byReferenceInput + ); + expect(core.savedObjects.client.update).toHaveBeenCalledWith( + defaultTestType, + '123', + attributes + ); + }); + + it('creates new saved object with attributes when given no id', async () => { + const core = coreMock.createStart(); + core.savedObjects.client.create = jest.fn().mockResolvedValueOnce({ + id: '678', + }); + const attributeService = mockAttributeService( + defaultTestType, + undefined, + core + ); + expect(await attributeService.wrapAttributes(attributes, true)).toEqual({ + savedObjectId: '678', + }); + expect(core.savedObjects.client.create).toHaveBeenCalledWith(defaultTestType, attributes); + }); + + it('uses custom save method when given an id', async () => { + const customSaveMethod = jest.fn().mockReturnValue({ id: '123' }); + const attributeService = mockAttributeService(defaultTestType, { + customSaveMethod, + }); + expect(await attributeService.wrapAttributes(attributes, true, byReferenceInput)).toEqual( + byReferenceInput + ); + expect(customSaveMethod).toHaveBeenCalledWith( + defaultTestType, + attributes, + byReferenceInput.savedObjectId + ); + }); + + it('uses custom save method given no id', async () => { + const customSaveMethod = jest.fn().mockReturnValue({ id: '678' }); + const attributeService = mockAttributeService(defaultTestType, { + customSaveMethod, + }); + expect(await attributeService.wrapAttributes(attributes, true)).toEqual({ + savedObjectId: '678', + }); + expect(customSaveMethod).toHaveBeenCalledWith(defaultTestType, attributes, undefined); + }); + }); +}); diff --git a/src/plugins/dashboard/public/attribute_service/attribute_service.tsx b/src/plugins/dashboard/public/attribute_service/attribute_service.tsx index fe5f6a0c8e2bd..a36363d22d87d 100644 --- a/src/plugins/dashboard/public/attribute_service/attribute_service.tsx +++ b/src/plugins/dashboard/public/attribute_service/attribute_service.tsx @@ -19,11 +19,16 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; +import { get } from 'lodash'; import { EmbeddableInput, SavedObjectEmbeddableInput, isSavedObjectEmbeddableInput, IEmbeddable, + Container, + EmbeddableStart, + EmbeddableFactory, + EmbeddableFactoryNotFoundError, } from '../embeddable_plugin'; import { SavedObjectsClientContract, @@ -34,17 +39,10 @@ import { } 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 @@ -52,26 +50,46 @@ import { * 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 const ATTRIBUTE_SERVICE_KEY = 'attributes'; + +export interface AttributeServiceOptions { + customSaveMethod?: ( + type: string, + attributes: A, + savedObjectId?: string + ) => Promise<{ id: string }>; + customUnwrapMethod?: (savedObject: SimpleSavedObject) => A; +} + export class AttributeService< SavedObjectAttributes extends { title: string }, - ValType extends EmbeddableInput & { attributes: SavedObjectAttributes }, - RefType extends SavedObjectEmbeddableInput + ValType extends EmbeddableInput & { + [ATTRIBUTE_SERVICE_KEY]: SavedObjectAttributes; + } = EmbeddableInput & { [ATTRIBUTE_SERVICE_KEY]: SavedObjectAttributes }, + RefType extends SavedObjectEmbeddableInput = SavedObjectEmbeddableInput > { - private embeddableFactory: EmbeddableFactory; + private embeddableFactory?: EmbeddableFactory; constructor( private type: string, + private showSaveModal: ( + saveModal: React.ReactElement, + I18nContext: I18nStart['Context'] + ) => void, private savedObjectsClient: SavedObjectsClientContract, private overlays: OverlayStart, private i18nContext: I18nStart['Context'], private toasts: NotificationsStart['toasts'], - getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory'] + getEmbeddableFactory?: EmbeddableStart['getEmbeddableFactory'], + private options?: AttributeServiceOptions ) { - const factory = getEmbeddableFactory(this.type); - if (!factory) { - throw new EmbeddableFactoryNotFoundError(this.type); + if (getEmbeddableFactory) { + const factory = getEmbeddableFactory(this.type); + if (!factory) { + throw new EmbeddableFactoryNotFoundError(this.type); + } + this.embeddableFactory = factory; } - this.embeddableFactory = factory; } public async unwrapAttributes(input: RefType | ValType): Promise { @@ -79,43 +97,54 @@ export class AttributeService< const savedObject: SimpleSavedObject = await this.savedObjectsClient.get< SavedObjectAttributes >(this.type, input.savedObjectId); - return savedObject.attributes; + return this.options?.customUnwrapMethod + ? this.options?.customUnwrapMethod(savedObject) + : { ...savedObject.attributes }; } - return input.attributes; + return input[ATTRIBUTE_SERVICE_KEY]; } public async wrapAttributes( newAttributes: SavedObjectAttributes, useRefType: boolean, - embeddable?: IEmbeddable + input?: ValType | RefType ): Promise> { + const originalInput = input ? input : {}; const savedObjectId = - embeddable && isSavedObjectEmbeddableInput(embeddable.getInput()) - ? (embeddable.getInput() as SavedObjectEmbeddableInput).savedObjectId + input && this.inputIsRefType(input) + ? (input as SavedObjectEmbeddableInput).savedObjectId : undefined; 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 }); + return { [ATTRIBUTE_SERVICE_KEY]: newAttributes } as ValType; + } + try { + if (this.options?.customSaveMethod) { + const savedItem = await this.options.customSaveMethod( + this.type, + newAttributes, + savedObjectId + ); + return { ...originalInput, savedObjectId: savedItem.id } as RefType; + } + + if (savedObjectId) { + await this.savedObjectsClient.update(this.type, savedObjectId, newAttributes); + return { ...originalInput, savedObjectId } as RefType; } + + const savedItem = await this.savedObjectsClient.create(this.type, newAttributes); + return { ...originalInput, 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 }); } } @@ -146,7 +175,7 @@ export class AttributeService< getInputAsRefType = async ( input: ValType | RefType, - saveOptions?: { showSaveModal: boolean } | { title: string } + saveOptions?: { showSaveModal: boolean; saveModalTitle?: string } | { title: string } ): Promise => { if (this.inputIsRefType(input)) { return input; @@ -159,7 +188,7 @@ export class AttributeService< copyOnSave: false, lastSavedTitle: '', getEsType: () => this.type, - getDisplayName: this.embeddableFactory.getDisplayName, + getDisplayName: this.embeddableFactory?.getDisplayName || (() => this.type), }, props.isTitleDuplicateConfirmed, props.onTitleDuplicate, @@ -169,7 +198,7 @@ export class AttributeService< } ); try { - const newAttributes = { ...input.attributes }; + const newAttributes = { ...input[ATTRIBUTE_SERVICE_KEY] }; newAttributes.title = props.newTitle; const wrappedInput = (await this.wrapAttributes(newAttributes, true)) as RefType; resolve(wrappedInput); @@ -181,11 +210,11 @@ export class AttributeService< }; if (saveOptions && (saveOptions as { showSaveModal: boolean }).showSaveModal) { - showSaveModal( + this.showSaveModal( reject()} - title={input.attributes.title} + title={get(saveOptions, 'saveModalTitle', input[ATTRIBUTE_SERVICE_KEY].title)} showCopyOnSave={false} objectType={this.type} showDescription={false} diff --git a/src/plugins/dashboard/public/attribute_service/attribute_service_mock.tsx b/src/plugins/dashboard/public/attribute_service/attribute_service_mock.tsx new file mode 100644 index 0000000000000..321a53361fc7a --- /dev/null +++ b/src/plugins/dashboard/public/attribute_service/attribute_service_mock.tsx @@ -0,0 +1,49 @@ +/* + * 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 '../embeddable_plugin'; +import { coreMock } from '../../../../core/public/mocks'; +import { AttributeServiceOptions } from './attribute_service'; +import { CoreStart } from '../../../../core/public'; +import { AttributeService, ATTRIBUTE_SERVICE_KEY } from '..'; + +export const mockAttributeService = < + A extends { title: string }, + V extends EmbeddableInput & { [ATTRIBUTE_SERVICE_KEY]: A } = EmbeddableInput & { + [ATTRIBUTE_SERVICE_KEY]: A; + }, + R extends SavedObjectEmbeddableInput = SavedObjectEmbeddableInput +>( + type: string, + options?: AttributeServiceOptions, + customCore?: jest.Mocked +): AttributeService => { + const core = customCore ? customCore : coreMock.createStart(); + const service = new AttributeService( + type, + jest.fn(), + core.savedObjects.client, + core.overlays, + core.i18n.Context, + core.notifications.toasts, + jest.fn().mockReturnValue(() => ({ getDisplayName: () => type })), + options + ); + return service; +}; diff --git a/src/plugins/dashboard/public/index.ts b/src/plugins/dashboard/public/index.ts index 8a9954cc77a2e..e22d1f038a456 100644 --- a/src/plugins/dashboard/public/index.ts +++ b/src/plugins/dashboard/public/index.ts @@ -40,7 +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 { AttributeService, ATTRIBUTE_SERVICE_KEY } 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 3df52f4e7a205..0ce6f9489ea02 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -52,6 +52,7 @@ import { getSavedObjectFinder, SavedObjectLoader, SavedObjectsStart, + showSaveModal, } from '../../saved_objects/public'; import { ExitFullScreenButton as ExitFullScreenButtonUi, @@ -102,6 +103,10 @@ import { addEmbeddableToDashboardUrl } from './url_utils/url_helper'; import { PlaceholderEmbeddableFactory } from './application/embeddable/placeholder'; import { UrlGeneratorState } from '../../share/public'; import { AttributeService } from '.'; +import { + AttributeServiceOptions, + ATTRIBUTE_SERVICE_KEY, +} from './attribute_service/attribute_service'; declare module '../../share/public' { export interface UrlGeneratorStateMapping { @@ -150,10 +155,13 @@ export interface DashboardStart { DashboardContainerByValueRenderer: ReturnType; getAttributeService: < A extends { title: string }, - V extends EmbeddableInput & { attributes: A }, - R extends SavedObjectEmbeddableInput + V extends EmbeddableInput & { [ATTRIBUTE_SERVICE_KEY]: A } = EmbeddableInput & { + [ATTRIBUTE_SERVICE_KEY]: A; + }, + R extends SavedObjectEmbeddableInput = SavedObjectEmbeddableInput >( - type: string + type: string, + options?: AttributeServiceOptions ) => AttributeService; } @@ -465,14 +473,16 @@ export class DashboardPlugin DashboardContainerByValueRenderer: createDashboardContainerByValueRenderer({ factory: dashboardContainerFactory, }), - getAttributeService: (type: string) => + getAttributeService: (type: string, options) => new AttributeService( type, + showSaveModal, core.savedObjects.client, core.overlays, core.i18n.Context, core.notifications.toasts, - embeddable.getEmbeddableFactory + embeddable.getEmbeddableFactory, + options ), }; }