[Dashboard First] Genericize Attribute Service (#76057)

Genericized attribute service, with custom save and unwrap methods and added unit tests.
This commit is contained in:
Devon Thomson 2020-09-04 13:30:50 -04:00 committed by GitHub
parent b3eaf9b629
commit 260643483e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 349 additions and 77 deletions

View file

@ -71,11 +71,7 @@ export class BookEmbeddable
constructor(
initialInput: BookEmbeddableInput,
private attributeService: AttributeService<
BookSavedObjectAttributes,
BookByValueInput,
BookByReferenceInput
>,
private attributeService: AttributeService<BookSavedObjectAttributes>,
{
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<BookByValueInput> => {
readonly getInputAsValueType = async (): Promise<BookByValueInput> => {
const input = this.attributeService.getExplicitInputFromEmbeddable(this);
return this.attributeService.getInputAsValueType(input);
};
getInputAsRefType = async (): Promise<BookByReferenceInput> => {
readonly getInputAsRefType = async (): Promise<BookByReferenceInput> => {
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) {

View file

@ -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<BookSavedObjectAttributes>;
constructor(private getStartServices: () => Promise<StartServices>) {}
@ -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!;

View file

@ -57,13 +57,13 @@ export const createEditBookAction = (getStartServices: () => Promise<StartServic
},
execute: async ({ embeddable }: ActionContext) => {
const { openModal, getAttributeService } = await getStartServices();
const attributeService = getAttributeService<
BookSavedObjectAttributes,
BookByValueInput,
BookByReferenceInput
>(BOOK_SAVED_OBJECT);
const attributeService = getAttributeService<BookSavedObjectAttributes>(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;

View file

@ -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<TestAttributes>(defaultTestType);
const customAttributeService = mockAttributeService<TestAttributes, TestByValueInput>(
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<TestAttributes>(
defaultTestType,
undefined,
core
);
expect(await attributeService.unwrapAttributes(byReferenceInput)).toEqual(attributes);
});
it('returns attributes when when given value type input', async () => {
const attributeService = mockAttributeService<TestAttributes>(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<TestAttributes>(
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<TestAttributes>(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<TestAttributes>(
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<TestAttributes>(
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<TestAttributes>(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<TestAttributes>(defaultTestType, {
customSaveMethod,
});
expect(await attributeService.wrapAttributes(attributes, true)).toEqual({
savedObjectId: '678',
});
expect(customSaveMethod).toHaveBeenCalledWith(defaultTestType, attributes, undefined);
});
});
});

View file

@ -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<A extends { title: string }> {
customSaveMethod?: (
type: string,
attributes: A,
savedObjectId?: string
) => Promise<{ id: string }>;
customUnwrapMethod?: (savedObject: SimpleSavedObject<A>) => 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<SavedObjectAttributes>
) {
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<SavedObjectAttributes> {
@ -79,43 +97,54 @@ export class AttributeService<
const savedObject: SimpleSavedObject<SavedObjectAttributes> = 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<Omit<ValType | RefType, 'id'>> {
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<RefType> => {
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(
<SavedObjectSaveModal
onSave={onSave}
onClose={() => reject()}
title={input.attributes.title}
title={get(saveOptions, 'saveModalTitle', input[ATTRIBUTE_SERVICE_KEY].title)}
showCopyOnSave={false}
objectType={this.type}
showDescription={false}

View file

@ -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<A>,
customCore?: jest.Mocked<CoreStart>
): AttributeService<A, V, R> => {
const core = customCore ? customCore : coreMock.createStart();
const service = new AttributeService<A, V, R>(
type,
jest.fn(),
core.savedObjects.client,
core.overlays,
core.i18n.Context,
core.notifications.toasts,
jest.fn().mockReturnValue(() => ({ getDisplayName: () => type })),
options
);
return service;
};

View file

@ -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);

View file

@ -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<typeof createDashboardContainerByValueRenderer>;
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<A>
) => AttributeService<A, V, R>;
}
@ -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
),
};
}