Skip to content

Commit

Permalink
Duplicate panel feature (#61367) (#63969)
Browse files Browse the repository at this point in the history
Added a new cloning feature for panels on a dashboard.
  • Loading branch information
ThomThomson authored Apr 20, 2020
1 parent 37e9dce commit 197b7a5
Show file tree
Hide file tree
Showing 13 changed files with 783 additions and 93 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/*
* 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, DashboardPanelState } 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 { ClonePanelAction } 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<ContactCardEmbeddableInput>({
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',
});

if (isErrorEmbeddable(contactCardEmbeddable)) {
throw new Error('Failed to create embeddable');
} else {
embeddable = contactCardEmbeddable;
}
});

test('Clone adds a new embeddable', 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 ClonePanelAction(coreStart);
await action.execute({ embeddable });
expect(Object.keys(container.getInput().panels).length).toEqual(originalPanelCount + 1);
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('Clones an embeddable without a saved object ID', async () => {
const dashboard = embeddable.getRoot() as IContainer;
const panel = dashboard.getInput().panels[embeddable.id] as DashboardPanelState;
const action = new ClonePanelAction(coreStart);
// @ts-ignore
const newPanel = await action.cloneEmbeddable(panel, embeddable.type);
expect(newPanel.type).toEqual(embeddable.type);
});

test('Clones an embeddable with a saved object ID', async () => {
const dashboard = embeddable.getRoot() as IContainer;
const panel = dashboard.getInput().panels[embeddable.id] as DashboardPanelState;
panel.explicitInput.savedObjectId = 'holySavedObjectBatman';
const action = new ClonePanelAction(coreStart);
// @ts-ignore
const newPanel = await action.cloneEmbeddable(panel, embeddable.type);
expect(coreStart.savedObjects.client.get).toHaveBeenCalledTimes(1);
expect(coreStart.savedObjects.client.find).toHaveBeenCalledTimes(1);
expect(coreStart.savedObjects.client.create).toHaveBeenCalledTimes(1);
expect(newPanel.type).toEqual(embeddable.type);
});

test('Gets a unique title ', async () => {
coreStart.savedObjects.client.find = jest.fn().mockImplementation(({ search }) => {
if (search === '"testFirstTitle"') return { total: 1 };
else if (search === '"testSecondTitle"') return { total: 41 };
else if (search === '"testThirdTitle"') return { total: 90 };
});
const action = new ClonePanelAction(coreStart);
// @ts-ignore
expect(await action.getUniqueTitle('testFirstTitle', embeddable.type)).toEqual(
'testFirstTitle (copy)'
);
// @ts-ignore
expect(await action.getUniqueTitle('testSecondTitle (copy 39)', embeddable.type)).toEqual(
'testSecondTitle (copy 40)'
);
// @ts-ignore
expect(await action.getUniqueTitle('testSecondTitle (copy 20)', embeddable.type)).toEqual(
'testSecondTitle (copy 40)'
);
// @ts-ignore
expect(await action.getUniqueTitle('testThirdTitle', embeddable.type)).toEqual(
'testThirdTitle (copy 89)'
);
// @ts-ignore
expect(await action.getUniqueTitle('testThirdTitle (copy 10000)', embeddable.type)).toEqual(
'testThirdTitle (copy 89)'
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/*
* 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 } from 'src/core/public';
import uuid from 'uuid';
import { ActionByType, IncompatibleActionError } from '../../ui_actions_plugin';
import { ViewMode, PanelState, IEmbeddable } from '../../embeddable_plugin';
import { SavedObject } from '../../../../saved_objects/public';
import { PanelNotFoundError, EmbeddableInput } from '../../../../embeddable/public';
import {
placePanelBeside,
IPanelPlacementBesideArgs,
} from '../embeddable/panel/dashboard_panel_placement';
import { DashboardPanelState, DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '..';

export const ACTION_CLONE_PANEL = 'clonePanel';

export interface ClonePanelActionContext {
embeddable: IEmbeddable;
}

export class ClonePanelAction implements ActionByType<typeof ACTION_CLONE_PANEL> {
public readonly type = ACTION_CLONE_PANEL;
public readonly id = ACTION_CLONE_PANEL;
public order = 11;

constructor(private core: CoreStart) {}

public getDisplayName({ embeddable }: ClonePanelActionContext) {
if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) {
throw new IncompatibleActionError();
}
return i18n.translate('dashboard.panel.clonePanel', {
defaultMessage: 'Clone panel',
});
}

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

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

public async execute({ embeddable }: ClonePanelActionContext) {
if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) {
throw new IncompatibleActionError();
}

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

dashboard.showPlaceholderUntil(
this.cloneEmbeddable(panelToClone, embeddable.type),
placePanelBeside,
{
width: panelToClone.gridData.w,
height: panelToClone.gridData.h,
currentPanels: dashboard.getInput().panels,
placeBesideId: panelToClone.explicitInput.id,
} as IPanelPlacementBesideArgs
);
}

private async getUniqueTitle(rawTitle: string, embeddableType: string): Promise<string> {
const clonedTag = i18n.translate('dashboard.panel.title.clonedTag', {
defaultMessage: 'copy',
});
const cloneRegex = new RegExp(`\\(${clonedTag}\\)`, 'g');
const cloneNumberRegex = new RegExp(`\\(${clonedTag} [0-9]+\\)`, 'g');
const baseTitle = rawTitle
.replace(cloneNumberRegex, '')
.replace(cloneRegex, '')
.trim();

const similarSavedObjects = await this.core.savedObjects.client.find<SavedObject>({
type: embeddableType,
perPage: 0,
fields: ['title'],
searchFields: ['title'],
search: `"${baseTitle}"`,
});
const similarBaseTitlesCount: number = similarSavedObjects.total - 1;

return similarBaseTitlesCount <= 0
? baseTitle + ` (${clonedTag})`
: baseTitle + ` (${clonedTag} ${similarBaseTitlesCount})`;
}

private async cloneEmbeddable(
panelToClone: DashboardPanelState,
embeddableType: string
): Promise<Partial<PanelState>> {
const panelState: PanelState<EmbeddableInput> = {
type: embeddableType,
explicitInput: {
...panelToClone.explicitInput,
id: uuid.v4(),
},
};
let newTitle: string = '';
if (panelToClone.explicitInput.savedObjectId) {
// Fetch existing saved object
const savedObjectToClone = await this.core.savedObjects.client.get<SavedObject>(
embeddableType,
panelToClone.explicitInput.savedObjectId
);

// Clone the saved object
newTitle = await this.getUniqueTitle(savedObjectToClone.attributes.title, embeddableType);
const clonedSavedObject = await this.core.savedObjects.client.create(
embeddableType,
{
..._.cloneDeep(savedObjectToClone.attributes),
title: newTitle,
},
{ references: _.cloneDeep(savedObjectToClone.references) }
);
panelState.explicitInput.savedObjectId = clonedSavedObject.id;
}
this.core.notifications.toasts.addSuccess({
title: i18n.translate('dashboard.panel.clonedToast', {
defaultMessage: 'Cloned panel',
}),
'data-test-subj': 'addObjectToContainerSuccess',
});
return panelState;
}
}
5 changes: 5 additions & 0 deletions src/plugins/dashboard/public/application/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,8 @@ export {
ReplacePanelActionContext,
ACTION_REPLACE_PANEL,
} from './replace_panel_action';
export {
ClonePanelAction,
ClonePanelActionContext,
ACTION_CLONE_PANEL,
} from './clone_panel_action';
Loading

0 comments on commit 197b7a5

Please sign in to comment.