[Dashboard First] Unlink from Library Action With ReferenceOrValueEmbeddable (#74905)

* Added an unlink from library action which works with the ReferenceOrValue interface. Once
This commit is contained in:
Devon Thomson 2020-08-18 10:31:55 -04:00 committed by GitHub
parent 196cb7f865
commit 2af2b60b00
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 306 additions and 2 deletions

View file

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

View file

@ -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<BookEmbeddableInput, BookEmbeddab
};
getInputAsValueType = async (): Promise<BookByValueInput> => {
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<BookByReferenceInput> => {

View file

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

View file

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

View file

@ -0,0 +1,160 @@
/*
* 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 { UnlinkFromLibraryAction } 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');
}
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 () => {
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();
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();
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 () => {
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 UnlinkFromLibraryAction();
expect(await action.isCompatible({ embeddable: orphanContactCard })).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;
const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels));
const action = new UnlinkFromLibraryAction();
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' },
};
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 UnlinkFromLibraryAction();
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);
});

View file

@ -0,0 +1,92 @@
/*
* 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_UNLINK_FROM_LIBRARY = 'unlinkFromLibrary';
export interface UnlinkFromLibraryActionContext {
embeddable: IEmbeddable;
}
export class UnlinkFromLibraryAction implements ActionByType<typeof ACTION_UNLINK_FROM_LIBRARY> {
public readonly type = ACTION_UNLINK_FROM_LIBRARY;
public readonly id = ACTION_UNLINK_FROM_LIBRARY;
public order = 15;
constructor() {}
public getDisplayName({ embeddable }: UnlinkFromLibraryActionContext) {
if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) {
throw new IncompatibleActionError();
}
return i18n.translate('dashboard.panel.unlinkFromLibrary', {
defaultMessage: 'Unlink from library item',
});
}
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 &&
isReferenceOrValueEmbeddable(embeddable) &&
embeddable.inputIsRefType(embeddable.getInput())
);
}
public async execute({ embeddable }: UnlinkFromLibraryActionContext) {
if (!isReferenceOrValueEmbeddable(embeddable)) {
throw new IncompatibleActionError();
}
const newInput = await embeddable.getInputAsValueType();
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);
}
}

View file

@ -80,6 +80,9 @@ import {
RenderDeps,
ReplacePanelAction,
ReplacePanelActionContext,
ACTION_UNLINK_FROM_LIBRARY,
UnlinkFromLibraryActionContext,
UnlinkFromLibraryAction,
} from './application';
import {
createDashboardUrlGenerator,
@ -152,6 +155,7 @@ declare module '../../../plugins/ui_actions/public' {
[ACTION_EXPAND_PANEL]: ExpandPanelActionContext;
[ACTION_REPLACE_PANEL]: ReplacePanelActionContext;
[ACTION_CLONE_PANEL]: ClonePanelActionContext;
[ACTION_UNLINK_FROM_LIBRARY]: UnlinkFromLibraryActionContext;
}
}
@ -163,6 +167,7 @@ export class DashboardPlugin
private stopUrlTracking: (() => void) | undefined = undefined;
private getActiveUrl: (() => string) | undefined = undefined;
private currentHistory: ScopedHistory | undefined = undefined;
private dashboardFeatureFlagConfig?: DashboardFeatureFlagConfig;
private dashboardUrlGenerator?: DashboardUrlGenerator;
@ -170,6 +175,9 @@ export class DashboardPlugin
core: CoreSetup<StartDependencies, DashboardStart>,
{ 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);
@ -415,6 +423,12 @@ export class DashboardPlugin
uiActions.registerAction(clonePanelAction);
uiActions.attachAction(CONTEXT_MENU_TRIGGER, clonePanelAction.id);
if (this.dashboardFeatureFlagConfig?.allowByValueEmbeddables) {
const unlinkFromLibraryAction = new UnlinkFromLibraryAction();
uiActions.registerAction(unlinkFromLibraryAction);
uiActions.attachAction(CONTEXT_MENU_TRIGGER, unlinkFromLibraryAction.id);
}
const savedDashboardLoader = createSavedDashboardLoader({
savedObjectsClient: core.savedObjects.client,
indexPatterns,
@ -430,7 +444,7 @@ export class DashboardPlugin
getSavedDashboardLoader: () => savedDashboardLoader,
addEmbeddableToDashboard: this.addEmbeddableToDashboard.bind(this, core),
dashboardUrlGenerator: this.dashboardUrlGenerator,
dashboardFeatureFlagConfig: this.initializerContext.config.get<DashboardFeatureFlagConfig>(),
dashboardFeatureFlagConfig: this.dashboardFeatureFlagConfig!,
DashboardContainerByValueRenderer: createDashboardContainerByValueRenderer({
factory: dashboardContainerFactory,
}),

View file

@ -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<EmbeddableSetup>;
export type Start = jest.Mocked<EmbeddableStart>;
@ -84,6 +85,25 @@ export const createEmbeddableStateTransferMock = (): Partial<EmbeddableStateTran
};
};
export const mockRefOrValEmbeddable = <
OriginalEmbeddableType,
ValTypeInput extends EmbeddableInput = EmbeddableInput,
RefTypeInput extends SavedObjectEmbeddableInput = SavedObjectEmbeddableInput
>(
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,
};