Skip to content

Commit 197b7a5

Browse files
authored
Duplicate panel feature (#61367) (#63969)
Added a new cloning feature for panels on a dashboard.
1 parent 37e9dce commit 197b7a5

File tree

13 files changed

+783
-93
lines changed

13 files changed

+783
-93
lines changed
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/*
2+
* Licensed to Elasticsearch B.V. under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch B.V. licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
import { isErrorEmbeddable, IContainer } from '../../embeddable_plugin';
20+
import { DashboardContainer, DashboardPanelState } from '../embeddable';
21+
import { getSampleDashboardInput, getSampleDashboardPanel } from '../test_helpers';
22+
import {
23+
CONTACT_CARD_EMBEDDABLE,
24+
ContactCardEmbeddableFactory,
25+
ContactCardEmbeddable,
26+
ContactCardEmbeddableInput,
27+
ContactCardEmbeddableOutput,
28+
} from '../../embeddable_plugin_test_samples';
29+
import { coreMock } from '../../../../../core/public/mocks';
30+
import { CoreStart } from 'kibana/public';
31+
import { ClonePanelAction } from '.';
32+
33+
// eslint-disable-next-line
34+
import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks';
35+
36+
const { setup, doStart } = embeddablePluginMock.createInstance();
37+
setup.registerEmbeddableFactory(
38+
CONTACT_CARD_EMBEDDABLE,
39+
new ContactCardEmbeddableFactory((() => null) as any, {} as any)
40+
);
41+
const start = doStart();
42+
43+
let container: DashboardContainer;
44+
let embeddable: ContactCardEmbeddable;
45+
let coreStart: CoreStart;
46+
beforeEach(async () => {
47+
coreStart = coreMock.createStart();
48+
coreStart.savedObjects.client = {
49+
...coreStart.savedObjects.client,
50+
get: jest.fn().mockImplementation(() => ({ attributes: { title: 'Holy moly' } })),
51+
find: jest.fn().mockImplementation(() => ({ total: 15 })),
52+
create: jest.fn().mockImplementation(() => ({ id: 'brandNewSavedObject' })),
53+
};
54+
55+
const options = {
56+
ExitFullScreenButton: () => null,
57+
SavedObjectFinder: () => null,
58+
application: {} as any,
59+
embeddable: start,
60+
inspector: {} as any,
61+
notifications: {} as any,
62+
overlays: coreStart.overlays,
63+
savedObjectMetaData: {} as any,
64+
uiActions: {} as any,
65+
};
66+
const input = getSampleDashboardInput({
67+
panels: {
68+
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
69+
explicitInput: { firstName: 'Kibanana', id: '123' },
70+
type: CONTACT_CARD_EMBEDDABLE,
71+
}),
72+
},
73+
});
74+
container = new DashboardContainer(input, options);
75+
76+
const contactCardEmbeddable = await container.addNewEmbeddable<
77+
ContactCardEmbeddableInput,
78+
ContactCardEmbeddableOutput,
79+
ContactCardEmbeddable
80+
>(CONTACT_CARD_EMBEDDABLE, {
81+
firstName: 'Kibana',
82+
});
83+
84+
if (isErrorEmbeddable(contactCardEmbeddable)) {
85+
throw new Error('Failed to create embeddable');
86+
} else {
87+
embeddable = contactCardEmbeddable;
88+
}
89+
});
90+
91+
test('Clone adds a new embeddable', async () => {
92+
const dashboard = embeddable.getRoot() as IContainer;
93+
const originalPanelCount = Object.keys(dashboard.getInput().panels).length;
94+
const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels));
95+
const action = new ClonePanelAction(coreStart);
96+
await action.execute({ embeddable });
97+
expect(Object.keys(container.getInput().panels).length).toEqual(originalPanelCount + 1);
98+
const newPanelId = Object.keys(container.getInput().panels).find(
99+
key => !originalPanelKeySet.has(key)
100+
);
101+
expect(newPanelId).toBeDefined();
102+
const newPanel = container.getInput().panels[newPanelId!];
103+
expect(newPanel.type).toEqual(embeddable.type);
104+
});
105+
106+
test('Clones an embeddable without a saved object ID', async () => {
107+
const dashboard = embeddable.getRoot() as IContainer;
108+
const panel = dashboard.getInput().panels[embeddable.id] as DashboardPanelState;
109+
const action = new ClonePanelAction(coreStart);
110+
// @ts-ignore
111+
const newPanel = await action.cloneEmbeddable(panel, embeddable.type);
112+
expect(newPanel.type).toEqual(embeddable.type);
113+
});
114+
115+
test('Clones an embeddable with a saved object ID', async () => {
116+
const dashboard = embeddable.getRoot() as IContainer;
117+
const panel = dashboard.getInput().panels[embeddable.id] as DashboardPanelState;
118+
panel.explicitInput.savedObjectId = 'holySavedObjectBatman';
119+
const action = new ClonePanelAction(coreStart);
120+
// @ts-ignore
121+
const newPanel = await action.cloneEmbeddable(panel, embeddable.type);
122+
expect(coreStart.savedObjects.client.get).toHaveBeenCalledTimes(1);
123+
expect(coreStart.savedObjects.client.find).toHaveBeenCalledTimes(1);
124+
expect(coreStart.savedObjects.client.create).toHaveBeenCalledTimes(1);
125+
expect(newPanel.type).toEqual(embeddable.type);
126+
});
127+
128+
test('Gets a unique title ', async () => {
129+
coreStart.savedObjects.client.find = jest.fn().mockImplementation(({ search }) => {
130+
if (search === '"testFirstTitle"') return { total: 1 };
131+
else if (search === '"testSecondTitle"') return { total: 41 };
132+
else if (search === '"testThirdTitle"') return { total: 90 };
133+
});
134+
const action = new ClonePanelAction(coreStart);
135+
// @ts-ignore
136+
expect(await action.getUniqueTitle('testFirstTitle', embeddable.type)).toEqual(
137+
'testFirstTitle (copy)'
138+
);
139+
// @ts-ignore
140+
expect(await action.getUniqueTitle('testSecondTitle (copy 39)', embeddable.type)).toEqual(
141+
'testSecondTitle (copy 40)'
142+
);
143+
// @ts-ignore
144+
expect(await action.getUniqueTitle('testSecondTitle (copy 20)', embeddable.type)).toEqual(
145+
'testSecondTitle (copy 40)'
146+
);
147+
// @ts-ignore
148+
expect(await action.getUniqueTitle('testThirdTitle', embeddable.type)).toEqual(
149+
'testThirdTitle (copy 89)'
150+
);
151+
// @ts-ignore
152+
expect(await action.getUniqueTitle('testThirdTitle (copy 10000)', embeddable.type)).toEqual(
153+
'testThirdTitle (copy 89)'
154+
);
155+
});
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/*
2+
* Licensed to Elasticsearch B.V. under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch B.V. licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import { i18n } from '@kbn/i18n';
21+
import { CoreStart } from 'src/core/public';
22+
import uuid from 'uuid';
23+
import { ActionByType, IncompatibleActionError } from '../../ui_actions_plugin';
24+
import { ViewMode, PanelState, IEmbeddable } from '../../embeddable_plugin';
25+
import { SavedObject } from '../../../../saved_objects/public';
26+
import { PanelNotFoundError, EmbeddableInput } from '../../../../embeddable/public';
27+
import {
28+
placePanelBeside,
29+
IPanelPlacementBesideArgs,
30+
} from '../embeddable/panel/dashboard_panel_placement';
31+
import { DashboardPanelState, DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '..';
32+
33+
export const ACTION_CLONE_PANEL = 'clonePanel';
34+
35+
export interface ClonePanelActionContext {
36+
embeddable: IEmbeddable;
37+
}
38+
39+
export class ClonePanelAction implements ActionByType<typeof ACTION_CLONE_PANEL> {
40+
public readonly type = ACTION_CLONE_PANEL;
41+
public readonly id = ACTION_CLONE_PANEL;
42+
public order = 11;
43+
44+
constructor(private core: CoreStart) {}
45+
46+
public getDisplayName({ embeddable }: ClonePanelActionContext) {
47+
if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) {
48+
throw new IncompatibleActionError();
49+
}
50+
return i18n.translate('dashboard.panel.clonePanel', {
51+
defaultMessage: 'Clone panel',
52+
});
53+
}
54+
55+
public getIconType({ embeddable }: ClonePanelActionContext) {
56+
if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) {
57+
throw new IncompatibleActionError();
58+
}
59+
return 'copy';
60+
}
61+
62+
public async isCompatible({ embeddable }: ClonePanelActionContext) {
63+
return Boolean(
64+
embeddable.getInput()?.viewMode !== ViewMode.VIEW &&
65+
embeddable.getRoot() &&
66+
embeddable.getRoot().isContainer &&
67+
embeddable.getRoot().type === DASHBOARD_CONTAINER_TYPE
68+
);
69+
}
70+
71+
public async execute({ embeddable }: ClonePanelActionContext) {
72+
if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) {
73+
throw new IncompatibleActionError();
74+
}
75+
76+
const dashboard = embeddable.getRoot() as DashboardContainer;
77+
const panelToClone = dashboard.getInput().panels[embeddable.id] as DashboardPanelState;
78+
if (!panelToClone) {
79+
throw new PanelNotFoundError();
80+
}
81+
82+
dashboard.showPlaceholderUntil(
83+
this.cloneEmbeddable(panelToClone, embeddable.type),
84+
placePanelBeside,
85+
{
86+
width: panelToClone.gridData.w,
87+
height: panelToClone.gridData.h,
88+
currentPanels: dashboard.getInput().panels,
89+
placeBesideId: panelToClone.explicitInput.id,
90+
} as IPanelPlacementBesideArgs
91+
);
92+
}
93+
94+
private async getUniqueTitle(rawTitle: string, embeddableType: string): Promise<string> {
95+
const clonedTag = i18n.translate('dashboard.panel.title.clonedTag', {
96+
defaultMessage: 'copy',
97+
});
98+
const cloneRegex = new RegExp(`\\(${clonedTag}\\)`, 'g');
99+
const cloneNumberRegex = new RegExp(`\\(${clonedTag} [0-9]+\\)`, 'g');
100+
const baseTitle = rawTitle
101+
.replace(cloneNumberRegex, '')
102+
.replace(cloneRegex, '')
103+
.trim();
104+
105+
const similarSavedObjects = await this.core.savedObjects.client.find<SavedObject>({
106+
type: embeddableType,
107+
perPage: 0,
108+
fields: ['title'],
109+
searchFields: ['title'],
110+
search: `"${baseTitle}"`,
111+
});
112+
const similarBaseTitlesCount: number = similarSavedObjects.total - 1;
113+
114+
return similarBaseTitlesCount <= 0
115+
? baseTitle + ` (${clonedTag})`
116+
: baseTitle + ` (${clonedTag} ${similarBaseTitlesCount})`;
117+
}
118+
119+
private async cloneEmbeddable(
120+
panelToClone: DashboardPanelState,
121+
embeddableType: string
122+
): Promise<Partial<PanelState>> {
123+
const panelState: PanelState<EmbeddableInput> = {
124+
type: embeddableType,
125+
explicitInput: {
126+
...panelToClone.explicitInput,
127+
id: uuid.v4(),
128+
},
129+
};
130+
let newTitle: string = '';
131+
if (panelToClone.explicitInput.savedObjectId) {
132+
// Fetch existing saved object
133+
const savedObjectToClone = await this.core.savedObjects.client.get<SavedObject>(
134+
embeddableType,
135+
panelToClone.explicitInput.savedObjectId
136+
);
137+
138+
// Clone the saved object
139+
newTitle = await this.getUniqueTitle(savedObjectToClone.attributes.title, embeddableType);
140+
const clonedSavedObject = await this.core.savedObjects.client.create(
141+
embeddableType,
142+
{
143+
..._.cloneDeep(savedObjectToClone.attributes),
144+
title: newTitle,
145+
},
146+
{ references: _.cloneDeep(savedObjectToClone.references) }
147+
);
148+
panelState.explicitInput.savedObjectId = clonedSavedObject.id;
149+
}
150+
this.core.notifications.toasts.addSuccess({
151+
title: i18n.translate('dashboard.panel.clonedToast', {
152+
defaultMessage: 'Cloned panel',
153+
}),
154+
'data-test-subj': 'addObjectToContainerSuccess',
155+
});
156+
return panelState;
157+
}
158+
}

src/plugins/dashboard/public/application/actions/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,8 @@ export {
2727
ReplacePanelActionContext,
2828
ACTION_REPLACE_PANEL,
2929
} from './replace_panel_action';
30+
export {
31+
ClonePanelAction,
32+
ClonePanelActionContext,
33+
ACTION_CLONE_PANEL,
34+
} from './clone_panel_action';

0 commit comments

Comments
 (0)