-
Notifications
You must be signed in to change notification settings - Fork 8.5k
Duplicate panel feature #61367
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Duplicate panel feature #61367
Changes from all commits
5deadd3
fbf8ad3
79ad2e4
7f96f87
9c2f822
12ba4f4
201e44f
0f306f5
16aa09e
7e85407
c9145bd
3300ec9
a55022c
d8e509f
5a07f4c
44b86fb
69753b2
f1cf077
47fff39
1441054
9d83349
c9e7ca7
0c2c545
9f40508
747d326
df4edfa
1a7bb9d
69cf92f
d9cc01f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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)' | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure I understand this - shouldn't the next title be
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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'); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What exactly is the purpose of these regexes?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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