Skip to content

Commit

Permalink
[Dashboard First] Add to Library Action (#75098)
Browse files Browse the repository at this point in the history
* created an add to library action that turns 'by value' embeddables into 'by reference' embeddables
  • Loading branch information
ThomThomson authored Aug 20, 2020
1 parent 954b5b5 commit 056038c
Show file tree
Hide file tree
Showing 7 changed files with 328 additions and 16 deletions.
10 changes: 3 additions & 7 deletions examples/embeddable_examples/public/book/book_embeddable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import {
EmbeddableOutput,
SavedObjectEmbeddableInput,
ReferenceOrValueEmbeddable,
Container,
} from '../../../../src/plugins/embeddable/public';
import { BookSavedObjectAttributes } from '../../common';
import { BookEmbeddableComponent } from './book_component';
Expand Down Expand Up @@ -104,16 +103,13 @@ export class BookEmbeddable extends Embeddable<BookEmbeddableInput, BookEmbeddab
};

getInputAsValueType = async (): Promise<BookByValueInput> => {
const input =
this.getRoot() && (this.getRoot() as Container).getInput().panels[this.id].explicitInput
? ((this.getRoot() as Container).getInput().panels[this.id]
.explicitInput as BookEmbeddableInput)
: this.input;
const input = this.attributeService.getExplicitInputFromEmbeddable(this);
return this.attributeService.getInputAsValueType(input);
};

getInputAsRefType = async (): Promise<BookByReferenceInput> => {
return this.attributeService.getInputAsRefType(this.input, { showSaveModal: true });
const input = this.attributeService.getExplicitInputFromEmbeddable(this);
return this.attributeService.getInputAsRefType(input, { showSaveModal: true });
};

public render(node: HTMLElement) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { isErrorEmbeddable, IContainer, ReferenceOrValueEmbeddable } from '../../embeddable_plugin';
import { DashboardContainer } from '../embeddable';
import { getSampleDashboardInput } from '../test_helpers';
import {
CONTACT_CARD_EMBEDDABLE,
ContactCardEmbeddableFactory,
ContactCardEmbeddable,
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
} from '../../embeddable_plugin_test_samples';
import { coreMock } from '../../../../../core/public/mocks';
import { CoreStart } from 'kibana/public';
import { AddToLibraryAction } from '.';
import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks';
import { ViewMode } from '../../../../embeddable/public';

const { setup, doStart } = embeddablePluginMock.createInstance();
setup.registerEmbeddableFactory(
CONTACT_CARD_EMBEDDABLE,
new ContactCardEmbeddableFactory((() => null) as any, {} as any)
);
const start = doStart();

let container: DashboardContainer;
let embeddable: ContactCardEmbeddable & ReferenceOrValueEmbeddable;
let coreStart: CoreStart;
beforeEach(async () => {
coreStart = coreMock.createStart();

const containerOptions = {
ExitFullScreenButton: () => null,
SavedObjectFinder: () => null,
application: {} as any,
embeddable: start,
inspector: {} as any,
notifications: {} as any,
overlays: coreStart.overlays,
savedObjectMetaData: {} as any,
uiActions: {} as any,
};

container = new DashboardContainer(getSampleDashboardInput(), containerOptions);

const contactCardEmbeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddable
>(CONTACT_CARD_EMBEDDABLE, {
firstName: 'Kibanana',
});

if (isErrorEmbeddable(contactCardEmbeddable)) {
throw new Error('Failed to create embeddable');
} else {
embeddable = embeddablePluginMock.mockRefOrValEmbeddable<
ContactCardEmbeddable,
ContactCardEmbeddableInput
>(contactCardEmbeddable, {
mockedByReferenceInput: { savedObjectId: 'testSavedObjectId', id: contactCardEmbeddable.id },
mockedByValueInput: { firstName: 'Kibanana', id: contactCardEmbeddable.id },
});
embeddable.updateInput({ viewMode: ViewMode.EDIT });
}
});

test('Add to library is compatible when embeddable on dashboard has value type input', async () => {
const action = new AddToLibraryAction();
embeddable.updateInput(await embeddable.getInputAsValueType());
expect(await action.isCompatible({ embeddable })).toBe(true);
});

test('Add to library is not compatible when embeddable input is by reference', async () => {
const action = new AddToLibraryAction();
embeddable.updateInput(await embeddable.getInputAsRefType());
expect(await action.isCompatible({ embeddable })).toBe(false);
});

test('Add to library is not compatible when view mode is set to view', async () => {
const action = new AddToLibraryAction();
embeddable.updateInput(await embeddable.getInputAsRefType());
embeddable.updateInput({ viewMode: ViewMode.VIEW });
expect(await action.isCompatible({ embeddable })).toBe(false);
});

test('Add to library is not compatible when embeddable is not in a dashboard container', async () => {
let orphanContactCard = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddable
>(CONTACT_CARD_EMBEDDABLE, {
firstName: 'Orphan',
});
orphanContactCard = embeddablePluginMock.mockRefOrValEmbeddable<
ContactCardEmbeddable,
ContactCardEmbeddableInput
>(orphanContactCard, {
mockedByReferenceInput: { savedObjectId: 'test', id: orphanContactCard.id },
mockedByValueInput: { firstName: 'Kibanana', id: orphanContactCard.id },
});
const action = new AddToLibraryAction();
expect(await action.isCompatible({ embeddable: orphanContactCard })).toBe(false);
});

test('Add to library replaces embeddableId but retains panel count', async () => {
const dashboard = embeddable.getRoot() as IContainer;
const originalPanelCount = Object.keys(dashboard.getInput().panels).length;
const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels));
const action = new AddToLibraryAction();
await action.execute({ embeddable });
expect(Object.keys(container.getInput().panels).length).toEqual(originalPanelCount);

const newPanelId = Object.keys(container.getInput().panels).find(
(key) => !originalPanelKeySet.has(key)
);
expect(newPanelId).toBeDefined();
const newPanel = container.getInput().panels[newPanelId!];
expect(newPanel.type).toEqual(embeddable.type);
});

test('Add to library returns reference type input', async () => {
const complicatedAttributes = {
attribute1: 'The best attribute',
attribute2: 22,
attribute3: ['array', 'of', 'strings'],
attribute4: { nestedattribute: 'hello from the nest' },
};

embeddable = embeddablePluginMock.mockRefOrValEmbeddable<ContactCardEmbeddable>(embeddable, {
mockedByReferenceInput: { savedObjectId: 'testSavedObjectId', id: embeddable.id },
mockedByValueInput: { attributes: complicatedAttributes, id: embeddable.id },
});
const dashboard = embeddable.getRoot() as IContainer;
const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels));
const action = new AddToLibraryAction();
await action.execute({ embeddable });
const newPanelId = Object.keys(container.getInput().panels).find(
(key) => !originalPanelKeySet.has(key)
);
expect(newPanelId).toBeDefined();
const newPanel = container.getInput().panels[newPanelId!];
expect(newPanel.type).toEqual(embeddable.type);
expect(newPanel.explicitInput.attributes).toBeUndefined();
expect(newPanel.explicitInput.savedObjectId).toBe('testSavedObjectId');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { i18n } from '@kbn/i18n';
import _ from 'lodash';
import uuid from 'uuid';
import { ActionByType, IncompatibleActionError } from '../../ui_actions_plugin';
import { ViewMode, PanelState, IEmbeddable } from '../../embeddable_plugin';
import {
PanelNotFoundError,
EmbeddableInput,
isReferenceOrValueEmbeddable,
} from '../../../../embeddable/public';
import { DashboardPanelState, DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '..';

export const ACTION_ADD_TO_LIBRARY = 'addToFromLibrary';

export interface AddToLibraryActionContext {
embeddable: IEmbeddable;
}

export class AddToLibraryAction implements ActionByType<typeof ACTION_ADD_TO_LIBRARY> {
public readonly type = ACTION_ADD_TO_LIBRARY;
public readonly id = ACTION_ADD_TO_LIBRARY;
public order = 15;

constructor() {}

public getDisplayName({ embeddable }: AddToLibraryActionContext) {
if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) {
throw new IncompatibleActionError();
}
return i18n.translate('dashboard.panel.AddToLibrary', {
defaultMessage: 'Add to library',
});
}

public getIconType({ embeddable }: AddToLibraryActionContext) {
if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) {
throw new IncompatibleActionError();
}
return 'folderCheck';
}

public async isCompatible({ embeddable }: AddToLibraryActionContext) {
return Boolean(
embeddable.getInput()?.viewMode !== ViewMode.VIEW &&
embeddable.getRoot() &&
embeddable.getRoot().isContainer &&
embeddable.getRoot().type === DASHBOARD_CONTAINER_TYPE &&
isReferenceOrValueEmbeddable(embeddable) &&
!embeddable.inputIsRefType(embeddable.getInput())
);
}

public async execute({ embeddable }: AddToLibraryActionContext) {
if (!isReferenceOrValueEmbeddable(embeddable)) {
throw new IncompatibleActionError();
}

const newInput = await embeddable.getInputAsRefType();

embeddable.updateInput(newInput);

const dashboard = embeddable.getRoot() as DashboardContainer;
const panelToReplace = dashboard.getInput().panels[embeddable.id] as DashboardPanelState;
if (!panelToReplace) {
throw new PanelNotFoundError();
}

const newPanel: PanelState<EmbeddableInput> = {
type: embeddable.type,
explicitInput: { ...newInput, id: uuid.v4() },
};
dashboard.replacePanel(panelToReplace, newPanel);
}
}
7 changes: 6 additions & 1 deletion src/plugins/dashboard/public/application/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,12 @@ export {
ACTION_CLONE_PANEL,
} from './clone_panel_action';
export {
AddToLibraryAction,
AddToLibraryActionContext,
ACTION_ADD_TO_LIBRARY,
} from './add_to_library_action';
export {
UnlinkFromLibraryAction,
UnlinkFromLibraryActionContext,
ACTION_UNLINK_FROM_LIBRARY,
UnlinkFromLibraryAction,
} from './unlink_from_library_action';
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,21 @@ import {
SimpleSavedObject,
I18nStart,
NotificationsStart,
OverlayStart,
} from '../../../../core/public';
import {
SavedObjectSaveModal,
showSaveModal,
OnSaveProps,
SaveResult,
checkForDuplicateTitle,
} from '../../../saved_objects/public';
import {
EmbeddableStart,
EmbeddableFactory,
EmbeddableFactoryNotFoundError,
Container,
} from '../../../embeddable/public';

/**
* The attribute service is a shared, generic service that embeddables can use to provide the functionality
Expand All @@ -49,12 +57,22 @@ export class AttributeService<
ValType extends EmbeddableInput & { attributes: SavedObjectAttributes },
RefType extends SavedObjectEmbeddableInput
> {
private embeddableFactory: EmbeddableFactory;

constructor(
private type: string,
private savedObjectsClient: SavedObjectsClientContract,
private overlays: OverlayStart,
private i18nContext: I18nStart['Context'],
private toasts: NotificationsStart['toasts']
) {}
private toasts: NotificationsStart['toasts'],
getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory']
) {
const factory = getEmbeddableFactory(this.type);
if (!factory) {
throw new EmbeddableFactoryNotFoundError(this.type);
}
this.embeddableFactory = factory;
}

public async unwrapAttributes(input: RefType | ValType): Promise<SavedObjectAttributes> {
if (this.inputIsRefType(input)) {
Expand Down Expand Up @@ -105,6 +123,15 @@ export class AttributeService<
return isSavedObjectEmbeddableInput(input);
};

public getExplicitInputFromEmbeddable(embeddable: IEmbeddable): ValType | RefType {
return embeddable.getRoot() &&
(embeddable.getRoot() as Container).getInput().panels[embeddable.id].explicitInput
? ((embeddable.getRoot() as Container).getInput().panels[embeddable.id].explicitInput as
| ValType
| RefType)
: (embeddable.getInput() as ValType | RefType);
}

getInputAsValueType = async (input: ValType | RefType): Promise<ValType> => {
if (!this.inputIsRefType(input)) {
return input;
Expand All @@ -124,16 +151,31 @@ export class AttributeService<
if (this.inputIsRefType(input)) {
return input;
}

return new Promise<RefType>((resolve, reject) => {
const onSave = async (props: OnSaveProps): Promise<SaveResult> => {
await checkForDuplicateTitle(
{
title: props.newTitle,
copyOnSave: false,
lastSavedTitle: '',
getEsType: () => this.type,
getDisplayName: this.embeddableFactory.getDisplayName,
},
props.isTitleDuplicateConfirmed,
props.onTitleDuplicate,
{
savedObjectsClient: this.savedObjectsClient,
overlays: this.overlays,
}
);
try {
input.attributes.title = props.newTitle;
const wrappedInput = (await this.wrapAttributes(input.attributes, true)) as RefType;
const newAttributes = { ...input.attributes };
newAttributes.title = props.newTitle;
const wrappedInput = (await this.wrapAttributes(newAttributes, true)) as RefType;
resolve(wrappedInput);
return { id: wrappedInput.savedObjectId };
} catch (error) {
reject();
reject(error);
return { error };
}
};
Expand Down
Loading

0 comments on commit 056038c

Please sign in to comment.