Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
5deadd3
created POC for panel duplication on dashboard
ThomThomson Mar 23, 2020
fbf8ad3
Merge branch 'master' of github.com:elastic/kibana into dashboard/dup…
ThomThomson Mar 24, 2020
79ad2e4
experimenting with shuffling around other panels to make room for dup…
ThomThomson Mar 24, 2020
7f96f87
removed panel shuffling code and added toasts
ThomThomson Mar 25, 2020
9c2f822
Merge branch 'master' of github.com:elastic/kibana into dashboard/dup…
ThomThomson Mar 26, 2020
12ba4f4
Panels now only shuffle when a collision is detected with another pan…
ThomThomson Mar 26, 2020
201e44f
Merge branch 'master' of github.com:elastic/kibana into dashboard/dup…
ThomThomson Mar 27, 2020
0f306f5
made copying embeddable input more generic. Made isCompatible less ge…
ThomThomson Mar 27, 2020
16aa09e
Merge branch 'master' of github.com:elastic/kibana into dashboard/dup…
ThomThomson Mar 30, 2020
7e85407
Made the clone action work on embeddables without a savedObjectId. Be…
ThomThomson Apr 1, 2020
c9145bd
Merge branch 'master' of github.com:elastic/kibana into dashboard/dup…
ThomThomson Apr 1, 2020
3300ec9
added a test with a mocked savedObjectId
ThomThomson Apr 1, 2020
a55022c
Merge branch 'master' of github.com:elastic/kibana into dashboard/dup…
ThomThomson Apr 2, 2020
d8e509f
Merge branch 'master' of github.com:elastic/kibana into dashboard/dup…
ThomThomson Apr 2, 2020
5a07f4c
Added tests for unique titles
ThomThomson Apr 2, 2020
44b86fb
Merge branch 'master' of github.com:elastic/kibana into dashboard/dup…
ThomThomson Apr 3, 2020
69753b2
Added functional tests for panel duplication
ThomThomson Apr 3, 2020
f1cf077
Merge branch 'master' of github.com:elastic/kibana into dashboard/dup…
ThomThomson Apr 6, 2020
47fff39
updated imports
ThomThomson Apr 6, 2020
1441054
Merge branch 'master' of github.com:elastic/kibana into dashboard/dup…
ThomThomson Apr 7, 2020
9d83349
Merge branch 'master' of github.com:elastic/kibana into dashboard/dup…
ThomThomson Apr 9, 2020
c9e7ca7
Merge branch 'master' of github.com:elastic/kibana into dashboard/dup…
ThomThomson Apr 13, 2020
0c2c545
Finished adding a placeholder embeddable for feedback before panel cl…
ThomThomson Apr 14, 2020
9f40508
Changed panel placement system. Now the panels attempt to find blank …
ThomThomson Apr 15, 2020
747d326
fixed functional and unit tests, made duplicating a panel when there …
ThomThomson Apr 15, 2020
df4edfa
Merge branch 'master' of github.com:elastic/kibana into dashboard/dup…
ThomThomson Apr 15, 2020
1a7bb9d
small change to allow cloning to work with #61692
ThomThomson Apr 15, 2020
69cf92f
simplified isCompatible, removed unused i8n value
ThomThomson Apr 16, 2020
d9cc01f
Merge branch 'master' of github.com:elastic/kibana into dashboard/dup…
ThomThomson Apr 16, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What was the ts-error here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the cloneEmbeddable method is set to private

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)'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I understand this - shouldn't the next title be copy 41?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The titling uses a system like file copies in windows, the order looks like this:

OG Title
OG Title (copy)
OG Title (copy 1)

That means that the next title is always the number of similar titles - 1. So if there were 41 similar titles, the next title would be OG Title (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');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What exactly is the purpose of these regexes?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are used to remove any cloning artefacts from the name of the savedObject, so that you don't end up with a title like "Hello World (copy) (copy 1) (copy 2)"

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(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this be better suited to live on the embeddable itself? In case we have other uses-cases like this? Just thinking out loud, and definitely doesn't need to be made as part of this PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method currently seems to work on every different type of embeddable, it would be pretty cool though if embeddables had a way to override it.

Additionally, it could later be moved onto the dashboard embeddable so that it could be re-used if we do need it!

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