mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
Duplicate panel feature (#61367)
Added a new cloning feature for panels on a dashboard.
This commit is contained in:
parent
cf7da3cdb7
commit
3f98f0f849
13 changed files with 783 additions and 93 deletions
|
@ -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)'
|
||||
);
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -27,3 +27,8 @@ export {
|
|||
ReplacePanelActionContext,
|
||||
ACTION_REPLACE_PANEL,
|
||||
} from './replace_panel_action';
|
||||
export {
|
||||
ClonePanelAction,
|
||||
ClonePanelActionContext,
|
||||
ACTION_CLONE_PANEL,
|
||||
} from './clone_panel_action';
|
||||
|
|
|
@ -23,6 +23,7 @@ import { I18nProvider } from '@kbn/i18n/react';
|
|||
import { RefreshInterval, TimeRange, Query, Filter } from 'src/plugins/data/public';
|
||||
import { CoreStart } from 'src/core/public';
|
||||
import { Start as InspectorStartContract } from 'src/plugins/inspector/public';
|
||||
import uuid from 'uuid';
|
||||
import { UiActionsStart } from '../../ui_actions_plugin';
|
||||
import {
|
||||
Container,
|
||||
|
@ -32,6 +33,7 @@ import {
|
|||
EmbeddableFactory,
|
||||
IEmbeddable,
|
||||
EmbeddableStart,
|
||||
PanelState,
|
||||
} from '../../embeddable_plugin';
|
||||
import { DASHBOARD_CONTAINER_TYPE } from './dashboard_constants';
|
||||
import { createPanelState } from './panel';
|
||||
|
@ -42,6 +44,8 @@ import {
|
|||
KibanaReactContext,
|
||||
KibanaReactContextValue,
|
||||
} from '../../../../kibana_react/public';
|
||||
import { PLACEHOLDER_EMBEDDABLE } from './placeholder';
|
||||
import { PanelPlacementMethod, IPanelPlacementArgs } from './panel/dashboard_panel_placement';
|
||||
|
||||
export interface DashboardContainerInput extends ContainerInput {
|
||||
viewMode: ViewMode;
|
||||
|
@ -120,7 +124,61 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
|
|||
partial: Partial<TEmbeddableInput> = {}
|
||||
): DashboardPanelState<TEmbeddableInput> {
|
||||
const panelState = super.createNewPanelState(factory, partial);
|
||||
return createPanelState(panelState, Object.values(this.input.panels));
|
||||
return createPanelState(panelState, this.input.panels);
|
||||
}
|
||||
|
||||
public showPlaceholderUntil<TPlacementMethodArgs extends IPanelPlacementArgs>(
|
||||
newStateComplete: Promise<Partial<PanelState>>,
|
||||
placementMethod?: PanelPlacementMethod<TPlacementMethodArgs>,
|
||||
placementArgs?: TPlacementMethodArgs
|
||||
): void {
|
||||
const originalPanelState = {
|
||||
type: PLACEHOLDER_EMBEDDABLE,
|
||||
explicitInput: {
|
||||
id: uuid.v4(),
|
||||
disabledActions: [
|
||||
'ACTION_CUSTOMIZE_PANEL',
|
||||
'CUSTOM_TIME_RANGE',
|
||||
'clonePanel',
|
||||
'replacePanel',
|
||||
'togglePanel',
|
||||
],
|
||||
},
|
||||
} as PanelState<EmbeddableInput>;
|
||||
const placeholderPanelState = createPanelState(
|
||||
originalPanelState,
|
||||
this.input.panels,
|
||||
placementMethod,
|
||||
placementArgs
|
||||
);
|
||||
this.updateInput({
|
||||
panels: {
|
||||
...this.input.panels,
|
||||
[placeholderPanelState.explicitInput.id]: placeholderPanelState,
|
||||
},
|
||||
});
|
||||
newStateComplete.then((newPanelState: Partial<PanelState>) => {
|
||||
const finalPanels = { ...this.input.panels };
|
||||
delete finalPanels[placeholderPanelState.explicitInput.id];
|
||||
const newPanelId = newPanelState.explicitInput?.id
|
||||
? newPanelState.explicitInput.id
|
||||
: uuid.v4();
|
||||
finalPanels[newPanelId] = {
|
||||
...placeholderPanelState,
|
||||
...newPanelState,
|
||||
gridData: {
|
||||
...placeholderPanelState.gridData,
|
||||
i: newPanelId,
|
||||
},
|
||||
explicitInput: {
|
||||
...newPanelState.explicitInput,
|
||||
id: newPanelId,
|
||||
},
|
||||
};
|
||||
this.updateInput({
|
||||
panels: finalPanels,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public render(dom: HTMLElement) {
|
||||
|
|
|
@ -26,7 +26,7 @@ import { CONTACT_CARD_EMBEDDABLE } from '../../../embeddable_plugin_test_samples
|
|||
interface TestInput extends EmbeddableInput {
|
||||
test: string;
|
||||
}
|
||||
const panels: DashboardPanelState[] = [];
|
||||
const panels: { [key: string]: DashboardPanelState } = {};
|
||||
|
||||
test('createPanelState adds a new panel state in 0,0 position', () => {
|
||||
const panelState = createPanelState<TestInput>(
|
||||
|
@ -34,7 +34,7 @@ test('createPanelState adds a new panel state in 0,0 position', () => {
|
|||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
explicitInput: { test: 'hi', id: '123' },
|
||||
},
|
||||
[]
|
||||
panels
|
||||
);
|
||||
expect(panelState.explicitInput.test).toBe('hi');
|
||||
expect(panelState.type).toBe(CONTACT_CARD_EMBEDDABLE);
|
||||
|
@ -44,7 +44,7 @@ test('createPanelState adds a new panel state in 0,0 position', () => {
|
|||
expect(panelState.gridData.h).toBe(DEFAULT_PANEL_HEIGHT);
|
||||
expect(panelState.gridData.w).toBe(DEFAULT_PANEL_WIDTH);
|
||||
|
||||
panels.push(panelState);
|
||||
panels[panelState.explicitInput.id] = panelState;
|
||||
});
|
||||
|
||||
test('createPanelState adds a second new panel state', () => {
|
||||
|
@ -58,7 +58,7 @@ test('createPanelState adds a second new panel state', () => {
|
|||
expect(panelState.gridData.h).toBe(DEFAULT_PANEL_HEIGHT);
|
||||
expect(panelState.gridData.w).toBe(DEFAULT_PANEL_WIDTH);
|
||||
|
||||
panels.push(panelState);
|
||||
panels[panelState.explicitInput.id] = panelState;
|
||||
});
|
||||
|
||||
test('createPanelState adds a third new panel state', () => {
|
||||
|
@ -74,17 +74,17 @@ test('createPanelState adds a third new panel state', () => {
|
|||
expect(panelState.gridData.h).toBe(DEFAULT_PANEL_HEIGHT);
|
||||
expect(panelState.gridData.w).toBe(DEFAULT_PANEL_WIDTH);
|
||||
|
||||
panels.push(panelState);
|
||||
panels[panelState.explicitInput.id] = panelState;
|
||||
});
|
||||
|
||||
test('createPanelState adds a new panel state in the top most position', () => {
|
||||
const panelsWithEmptySpace = panels.filter(panel => panel.gridData.x === 0);
|
||||
delete panels['456'];
|
||||
const panelState = createPanelState<TestInput>(
|
||||
{
|
||||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
explicitInput: { test: 'bye', id: '987' },
|
||||
},
|
||||
panelsWithEmptySpace
|
||||
panels
|
||||
);
|
||||
expect(panelState.gridData.x).toBe(DEFAULT_PANEL_WIDTH);
|
||||
expect(panelState.gridData.y).toBe(0);
|
||||
|
|
|
@ -19,98 +19,45 @@
|
|||
|
||||
import _ from 'lodash';
|
||||
import { PanelState, EmbeddableInput } from '../../../embeddable_plugin';
|
||||
import {
|
||||
DASHBOARD_GRID_COLUMN_COUNT,
|
||||
DEFAULT_PANEL_HEIGHT,
|
||||
DEFAULT_PANEL_WIDTH,
|
||||
} from '../dashboard_constants';
|
||||
import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from '../dashboard_constants';
|
||||
import { DashboardPanelState } from '../types';
|
||||
|
||||
// Look for the smallest y and x value where the default panel will fit.
|
||||
function findTopLeftMostOpenSpace(
|
||||
width: number,
|
||||
height: number,
|
||||
currentPanels: DashboardPanelState[]
|
||||
) {
|
||||
let maxY = -1;
|
||||
|
||||
currentPanels.forEach(panel => {
|
||||
maxY = Math.max(panel.gridData.y + panel.gridData.h, maxY);
|
||||
});
|
||||
|
||||
// Handle case of empty grid.
|
||||
if (maxY < 0) {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
const grid = new Array(maxY);
|
||||
for (let y = 0; y < maxY; y++) {
|
||||
grid[y] = new Array(DASHBOARD_GRID_COLUMN_COUNT).fill(0);
|
||||
}
|
||||
|
||||
currentPanels.forEach(panel => {
|
||||
for (let x = panel.gridData.x; x < panel.gridData.x + panel.gridData.w; x++) {
|
||||
for (let y = panel.gridData.y; y < panel.gridData.y + panel.gridData.h; y++) {
|
||||
const row = grid[y];
|
||||
if (row === undefined) {
|
||||
throw new Error(
|
||||
`Attempted to access a row that doesn't exist at ${y} for panel ${JSON.stringify(
|
||||
panel
|
||||
)}`
|
||||
);
|
||||
}
|
||||
grid[y][x] = 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (let y = 0; y < maxY; y++) {
|
||||
for (let x = 0; x < DASHBOARD_GRID_COLUMN_COUNT; x++) {
|
||||
if (grid[y][x] === 1) {
|
||||
// Space is filled
|
||||
continue;
|
||||
} else {
|
||||
for (let h = y; h < Math.min(y + height, maxY); h++) {
|
||||
for (let w = x; w < Math.min(x + width, DASHBOARD_GRID_COLUMN_COUNT); w++) {
|
||||
const spaceIsEmpty = grid[h][w] === 0;
|
||||
const fitsPanelWidth = w === x + width - 1;
|
||||
// If the panel is taller than any other panel in the current grid, it can still fit in the space, hence
|
||||
// we check the minimum of maxY and the panel height.
|
||||
const fitsPanelHeight = h === Math.min(y + height - 1, maxY - 1);
|
||||
|
||||
if (spaceIsEmpty && fitsPanelWidth && fitsPanelHeight) {
|
||||
// Found space
|
||||
return { x, y };
|
||||
} else if (grid[h][w] === 1) {
|
||||
// x, y spot doesn't work, break.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { x: 0, y: maxY };
|
||||
}
|
||||
import {
|
||||
IPanelPlacementArgs,
|
||||
findTopLeftMostOpenSpace,
|
||||
PanelPlacementMethod,
|
||||
} from './dashboard_panel_placement';
|
||||
|
||||
/**
|
||||
* Creates and initializes a basic panel state.
|
||||
*/
|
||||
export function createPanelState<TEmbeddableInput extends EmbeddableInput>(
|
||||
export function createPanelState<
|
||||
TEmbeddableInput extends EmbeddableInput,
|
||||
TPlacementMethodArgs extends IPanelPlacementArgs = IPanelPlacementArgs
|
||||
>(
|
||||
panelState: PanelState<TEmbeddableInput>,
|
||||
currentPanels: DashboardPanelState[]
|
||||
currentPanels: { [key: string]: DashboardPanelState },
|
||||
placementMethod?: PanelPlacementMethod<TPlacementMethodArgs>,
|
||||
placementArgs?: TPlacementMethodArgs
|
||||
): DashboardPanelState<TEmbeddableInput> {
|
||||
const { x, y } = findTopLeftMostOpenSpace(
|
||||
DEFAULT_PANEL_WIDTH,
|
||||
DEFAULT_PANEL_HEIGHT,
|
||||
currentPanels
|
||||
);
|
||||
const defaultPlacementArgs = {
|
||||
width: DEFAULT_PANEL_WIDTH,
|
||||
height: DEFAULT_PANEL_HEIGHT,
|
||||
currentPanels,
|
||||
};
|
||||
const finalPlacementArgs = placementArgs
|
||||
? {
|
||||
...defaultPlacementArgs,
|
||||
...placementArgs,
|
||||
}
|
||||
: defaultPlacementArgs;
|
||||
|
||||
const gridDataLocation = placementMethod
|
||||
? placementMethod(finalPlacementArgs as TPlacementMethodArgs)
|
||||
: findTopLeftMostOpenSpace(defaultPlacementArgs);
|
||||
|
||||
return {
|
||||
gridData: {
|
||||
w: DEFAULT_PANEL_WIDTH,
|
||||
h: DEFAULT_PANEL_HEIGHT,
|
||||
x,
|
||||
y,
|
||||
...gridDataLocation,
|
||||
i: panelState.explicitInput.id,
|
||||
},
|
||||
...panelState,
|
||||
|
|
|
@ -0,0 +1,167 @@
|
|||
/*
|
||||
* 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 { PanelNotFoundError } from '../../../embeddable_plugin';
|
||||
import { DashboardPanelState, GridData, DASHBOARD_GRID_COLUMN_COUNT } from '..';
|
||||
|
||||
export type PanelPlacementMethod<PlacementArgs extends IPanelPlacementArgs> = (
|
||||
args: PlacementArgs
|
||||
) => Omit<GridData, 'i'>;
|
||||
|
||||
export interface IPanelPlacementArgs {
|
||||
width: number;
|
||||
height: number;
|
||||
currentPanels: { [key: string]: DashboardPanelState };
|
||||
}
|
||||
|
||||
export interface IPanelPlacementBesideArgs extends IPanelPlacementArgs {
|
||||
placeBesideId: string;
|
||||
}
|
||||
|
||||
// Look for the smallest y and x value where the default panel will fit.
|
||||
export function findTopLeftMostOpenSpace({
|
||||
width,
|
||||
height,
|
||||
currentPanels,
|
||||
}: IPanelPlacementArgs): Omit<GridData, 'i'> {
|
||||
let maxY = -1;
|
||||
|
||||
const currentPanelsArray = Object.values(currentPanels);
|
||||
currentPanelsArray.forEach(panel => {
|
||||
maxY = Math.max(panel.gridData.y + panel.gridData.h, maxY);
|
||||
});
|
||||
|
||||
// Handle case of empty grid.
|
||||
if (maxY < 0) {
|
||||
return { x: 0, y: 0, w: width, h: height };
|
||||
}
|
||||
|
||||
const grid = new Array(maxY);
|
||||
for (let y = 0; y < maxY; y++) {
|
||||
grid[y] = new Array(DASHBOARD_GRID_COLUMN_COUNT).fill(0);
|
||||
}
|
||||
|
||||
currentPanelsArray.forEach(panel => {
|
||||
for (let x = panel.gridData.x; x < panel.gridData.x + panel.gridData.w; x++) {
|
||||
for (let y = panel.gridData.y; y < panel.gridData.y + panel.gridData.h; y++) {
|
||||
const row = grid[y];
|
||||
if (row === undefined) {
|
||||
throw new Error(
|
||||
`Attempted to access a row that doesn't exist at ${y} for panel ${JSON.stringify(
|
||||
panel
|
||||
)}`
|
||||
);
|
||||
}
|
||||
grid[y][x] = 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (let y = 0; y < maxY; y++) {
|
||||
for (let x = 0; x < DASHBOARD_GRID_COLUMN_COUNT; x++) {
|
||||
if (grid[y][x] === 1) {
|
||||
// Space is filled
|
||||
continue;
|
||||
} else {
|
||||
for (let h = y; h < Math.min(y + height, maxY); h++) {
|
||||
for (let w = x; w < Math.min(x + width, DASHBOARD_GRID_COLUMN_COUNT); w++) {
|
||||
const spaceIsEmpty = grid[h][w] === 0;
|
||||
const fitsPanelWidth = w === x + width - 1;
|
||||
// If the panel is taller than any other panel in the current grid, it can still fit in the space, hence
|
||||
// we check the minimum of maxY and the panel height.
|
||||
const fitsPanelHeight = h === Math.min(y + height - 1, maxY - 1);
|
||||
|
||||
if (spaceIsEmpty && fitsPanelWidth && fitsPanelHeight) {
|
||||
// Found space
|
||||
return { x, y, w: width, h: height };
|
||||
} else if (grid[h][w] === 1) {
|
||||
// x, y spot doesn't work, break.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { x: 0, y: maxY, w: width, h: height };
|
||||
}
|
||||
|
||||
interface IplacementDirection {
|
||||
grid: Omit<GridData, 'i'>;
|
||||
fits: boolean;
|
||||
}
|
||||
|
||||
export function placePanelBeside({
|
||||
width,
|
||||
height,
|
||||
currentPanels,
|
||||
placeBesideId,
|
||||
}: IPanelPlacementBesideArgs): Omit<GridData, 'i'> {
|
||||
// const clonedPanels = _.cloneDeep(currentPanels);
|
||||
if (!placeBesideId) {
|
||||
throw new Error('Place beside method called without placeBesideId');
|
||||
}
|
||||
const panelToPlaceBeside = currentPanels[placeBesideId];
|
||||
if (!panelToPlaceBeside) {
|
||||
throw new PanelNotFoundError();
|
||||
}
|
||||
const beside = panelToPlaceBeside.gridData;
|
||||
const otherPanels: GridData[] = [];
|
||||
_.forOwn(currentPanels, (panel: DashboardPanelState, key: string | undefined) => {
|
||||
otherPanels.push(panel.gridData);
|
||||
});
|
||||
|
||||
const possiblePlacementDirections: IplacementDirection[] = [
|
||||
{ grid: { x: beside.x + beside.w, y: beside.y, w: width, h: height }, fits: true }, // right
|
||||
{ grid: { x: beside.x - width, y: beside.y, w: width, h: height }, fits: true }, // left
|
||||
{ grid: { x: beside.x, y: beside.y + beside.h, w: width, h: height }, fits: true }, // bottom
|
||||
];
|
||||
|
||||
for (const direction of possiblePlacementDirections) {
|
||||
if (
|
||||
direction.grid.x >= 0 &&
|
||||
direction.grid.x + direction.grid.w <= DASHBOARD_GRID_COLUMN_COUNT &&
|
||||
direction.grid.y >= 0
|
||||
) {
|
||||
const intersection = otherPanels.some((currentPanelGrid: GridData) => {
|
||||
return (
|
||||
direction.grid.x + direction.grid.w > currentPanelGrid.x &&
|
||||
direction.grid.x < currentPanelGrid.x + currentPanelGrid.w &&
|
||||
direction.grid.y < currentPanelGrid.y + currentPanelGrid.h &&
|
||||
direction.grid.y + direction.grid.h > currentPanelGrid.y
|
||||
);
|
||||
});
|
||||
if (!intersection) {
|
||||
return direction.grid;
|
||||
}
|
||||
} else {
|
||||
direction.fits = false;
|
||||
}
|
||||
}
|
||||
// if we get here that means there is no blank space around the panel we are placing beside. This means it's time to mess up the dashboard's groove. Fun!
|
||||
const [, , bottomPlacement] = possiblePlacementDirections;
|
||||
for (const currentPanelGrid of otherPanels) {
|
||||
if (bottomPlacement.grid.y <= currentPanelGrid.y) {
|
||||
const movedPanel = _.cloneDeep(currentPanels[currentPanelGrid.i]);
|
||||
movedPanel.gridData.y = movedPanel.gridData.y + bottomPlacement.grid.h;
|
||||
currentPanels[currentPanelGrid.i] = movedPanel;
|
||||
}
|
||||
}
|
||||
return bottomPlacement.grid;
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export * from './placeholder_embeddable';
|
||||
export * from './placeholder_embeddable_factory';
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { EuiLoadingChart } from '@elastic/eui';
|
||||
import classNames from 'classnames';
|
||||
import { Embeddable, EmbeddableInput, IContainer } from '../../../embeddable_plugin';
|
||||
|
||||
export const PLACEHOLDER_EMBEDDABLE = 'placeholder';
|
||||
|
||||
export class PlaceholderEmbeddable extends Embeddable {
|
||||
public readonly type = PLACEHOLDER_EMBEDDABLE;
|
||||
private node?: HTMLElement;
|
||||
|
||||
constructor(initialInput: EmbeddableInput, parent?: IContainer) {
|
||||
super(initialInput, {}, parent);
|
||||
this.input = initialInput;
|
||||
}
|
||||
public render(node: HTMLElement) {
|
||||
if (this.node) {
|
||||
ReactDOM.unmountComponentAtNode(this.node);
|
||||
}
|
||||
this.node = node;
|
||||
|
||||
const classes = classNames('embPanel', 'embPanel-isLoading');
|
||||
ReactDOM.render(
|
||||
<div className={classes}>
|
||||
<EuiLoadingChart size="l" mono />
|
||||
</div>,
|
||||
node
|
||||
);
|
||||
}
|
||||
|
||||
public reload() {}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* 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 {
|
||||
EmbeddableFactoryDefinition,
|
||||
EmbeddableInput,
|
||||
IContainer,
|
||||
} from '../../../embeddable_plugin';
|
||||
import { PlaceholderEmbeddable, PLACEHOLDER_EMBEDDABLE } from './placeholder_embeddable';
|
||||
|
||||
export class PlaceholderEmbeddableFactory implements EmbeddableFactoryDefinition {
|
||||
public readonly type = PLACEHOLDER_EMBEDDABLE;
|
||||
|
||||
public async isEditable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
public async create(initialInput: EmbeddableInput, parent?: IContainer) {
|
||||
return new PlaceholderEmbeddable(initialInput, parent);
|
||||
}
|
||||
|
||||
public getDisplayName() {
|
||||
return i18n.translate('dashboard.placeholder.factory.displayName', {
|
||||
defaultMessage: 'placeholder',
|
||||
});
|
||||
}
|
||||
}
|
|
@ -67,9 +67,12 @@ import {
|
|||
ExpandPanelActionContext,
|
||||
ReplacePanelAction,
|
||||
ReplacePanelActionContext,
|
||||
ClonePanelAction,
|
||||
ClonePanelActionContext,
|
||||
ACTION_EXPAND_PANEL,
|
||||
ACTION_REPLACE_PANEL,
|
||||
RenderDeps,
|
||||
ACTION_CLONE_PANEL,
|
||||
} from './application';
|
||||
import {
|
||||
DashboardAppLinkGeneratorState,
|
||||
|
@ -78,6 +81,7 @@ import {
|
|||
} from './url_generator';
|
||||
import { createSavedDashboardLoader } from './saved_dashboards';
|
||||
import { DashboardConstants } from './dashboard_constants';
|
||||
import { PlaceholderEmbeddableFactory } from './application/embeddable/placeholder';
|
||||
|
||||
declare module '../../share/public' {
|
||||
export interface UrlGeneratorStateMapping {
|
||||
|
@ -115,6 +119,7 @@ declare module '../../../plugins/ui_actions/public' {
|
|||
export interface ActionContextMapping {
|
||||
[ACTION_EXPAND_PANEL]: ExpandPanelActionContext;
|
||||
[ACTION_REPLACE_PANEL]: ReplacePanelActionContext;
|
||||
[ACTION_CLONE_PANEL]: ClonePanelActionContext;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -173,6 +178,9 @@ export class DashboardPlugin
|
|||
const factory = new DashboardContainerFactory(getStartServices);
|
||||
embeddable.registerEmbeddableFactory(factory.type, factory);
|
||||
|
||||
const placeholderFactory = new PlaceholderEmbeddableFactory();
|
||||
embeddable.registerEmbeddableFactory(placeholderFactory.type, placeholderFactory);
|
||||
|
||||
const { appMounted, appUnMounted, stop: stopUrlTracker } = createKbnUrlTracker({
|
||||
baseUrl: core.http.basePath.prepend('/app/kibana'),
|
||||
defaultSubUrl: `#${DashboardConstants.LANDING_PAGE_PATH}`,
|
||||
|
@ -297,6 +305,11 @@ export class DashboardPlugin
|
|||
);
|
||||
uiActions.registerAction(changeViewAction);
|
||||
uiActions.attachAction(CONTEXT_MENU_TRIGGER, changeViewAction);
|
||||
|
||||
const clonePanelAction = new ClonePanelAction(core);
|
||||
uiActions.registerAction(clonePanelAction);
|
||||
uiActions.attachAction(CONTEXT_MENU_TRIGGER, clonePanelAction);
|
||||
|
||||
const savedDashboardLoader = createSavedDashboardLoader({
|
||||
savedObjectsClient: core.savedObjects.client,
|
||||
indexPatterns,
|
||||
|
|
|
@ -113,6 +113,50 @@ export default function({ getService, getPageObjects }) {
|
|||
});
|
||||
});
|
||||
|
||||
describe('panel cloning', function() {
|
||||
before(async () => {
|
||||
await PageObjects.dashboard.clickNewDashboard();
|
||||
await PageObjects.timePicker.setHistoricalDataRange();
|
||||
await dashboardAddPanel.addVisualization(PIE_CHART_VIS_NAME);
|
||||
});
|
||||
|
||||
after(async function() {
|
||||
await PageObjects.dashboard.gotoDashboardLandingPage();
|
||||
});
|
||||
|
||||
it('clones a panel', async () => {
|
||||
const initialPanelTitles = await PageObjects.dashboard.getPanelTitles();
|
||||
await dashboardPanelActions.clonePanelByTitle(PIE_CHART_VIS_NAME);
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.dashboard.waitForRenderComplete();
|
||||
const postPanelTitles = await PageObjects.dashboard.getPanelTitles();
|
||||
expect(postPanelTitles.length).to.equal(initialPanelTitles.length + 1);
|
||||
});
|
||||
|
||||
it('appends a clone title tag', async () => {
|
||||
const panelTitles = await PageObjects.dashboard.getPanelTitles();
|
||||
expect(panelTitles[1]).to.equal(PIE_CHART_VIS_NAME + ' (copy)');
|
||||
});
|
||||
|
||||
it('retains original panel dimensions', async () => {
|
||||
const panelDimensions = await PageObjects.dashboard.getPanelDimensions();
|
||||
expect(panelDimensions[0]).to.eql(panelDimensions[1]);
|
||||
});
|
||||
|
||||
it('gives a correct title to the clone of a clone', async () => {
|
||||
const initialPanelTitles = await PageObjects.dashboard.getPanelTitles();
|
||||
const clonedPanelName = initialPanelTitles[initialPanelTitles.length - 1];
|
||||
await dashboardPanelActions.clonePanelByTitle(clonedPanelName);
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.dashboard.waitForRenderComplete();
|
||||
const postPanelTitles = await PageObjects.dashboard.getPanelTitles();
|
||||
expect(postPanelTitles.length).to.equal(initialPanelTitles.length + 1);
|
||||
expect(postPanelTitles[postPanelTitles.length - 1]).to.equal(
|
||||
PIE_CHART_VIS_NAME + ' (copy 1)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('panel edit controls', function() {
|
||||
before(async () => {
|
||||
await PageObjects.dashboard.clickNewDashboard();
|
||||
|
@ -137,6 +181,7 @@ export default function({ getService, getPageObjects }) {
|
|||
|
||||
await dashboardPanelActions.expectExistsEditPanelAction();
|
||||
await dashboardPanelActions.expectExistsReplacePanelAction();
|
||||
await dashboardPanelActions.expectExistsDuplicatePanelAction();
|
||||
await dashboardPanelActions.expectExistsRemovePanelAction();
|
||||
});
|
||||
|
||||
|
@ -151,6 +196,7 @@ export default function({ getService, getPageObjects }) {
|
|||
await dashboardPanelActions.openContextMenu();
|
||||
await dashboardPanelActions.expectExistsEditPanelAction();
|
||||
await dashboardPanelActions.expectExistsReplacePanelAction();
|
||||
await dashboardPanelActions.expectExistsDuplicatePanelAction();
|
||||
await dashboardPanelActions.expectExistsRemovePanelAction();
|
||||
|
||||
// Get rid of the timestamp in the url.
|
||||
|
@ -166,6 +212,7 @@ export default function({ getService, getPageObjects }) {
|
|||
await dashboardPanelActions.openContextMenu();
|
||||
await dashboardPanelActions.expectMissingEditPanelAction();
|
||||
await dashboardPanelActions.expectMissingReplacePanelAction();
|
||||
await dashboardPanelActions.expectMissingDuplicatePanelAction();
|
||||
await dashboardPanelActions.expectMissingRemovePanelAction();
|
||||
});
|
||||
|
||||
|
@ -174,6 +221,7 @@ export default function({ getService, getPageObjects }) {
|
|||
await dashboardPanelActions.openContextMenu();
|
||||
await dashboardPanelActions.expectExistsEditPanelAction();
|
||||
await dashboardPanelActions.expectExistsReplacePanelAction();
|
||||
await dashboardPanelActions.expectExistsDuplicatePanelAction();
|
||||
await dashboardPanelActions.expectMissingRemovePanelAction();
|
||||
await dashboardPanelActions.clickExpandPanelToggle();
|
||||
});
|
||||
|
|
|
@ -20,6 +20,7 @@
|
|||
const REMOVE_PANEL_DATA_TEST_SUBJ = 'embeddablePanelAction-deletePanel';
|
||||
const EDIT_PANEL_DATA_TEST_SUBJ = 'embeddablePanelAction-editPanel';
|
||||
const REPLACE_PANEL_DATA_TEST_SUBJ = 'embeddablePanelAction-replacePanel';
|
||||
const CLONE_PANEL_DATA_TEST_SUBJ = 'embeddablePanelAction-clonePanel';
|
||||
const TOGGLE_EXPAND_PANEL_DATA_TEST_SUBJ = 'embeddablePanelAction-togglePanel';
|
||||
const CUSTOMIZE_PANEL_DATA_TEST_SUBJ = 'embeddablePanelAction-ACTION_CUSTOMIZE_PANEL';
|
||||
const OPEN_CONTEXT_MENU_ICON_DATA_TEST_SUBJ = 'embeddablePanelToggleMenuIcon';
|
||||
|
@ -97,6 +98,16 @@ export function DashboardPanelActionsProvider({ getService, getPageObjects }) {
|
|||
await testSubjects.click(REPLACE_PANEL_DATA_TEST_SUBJ);
|
||||
}
|
||||
|
||||
async clonePanelByTitle(title) {
|
||||
log.debug(`clonePanel(${title})`);
|
||||
let panelOptions = null;
|
||||
if (title) {
|
||||
panelOptions = await this.getPanelHeading(title);
|
||||
}
|
||||
await this.openContextMenu(panelOptions);
|
||||
await testSubjects.click(CLONE_PANEL_DATA_TEST_SUBJ);
|
||||
}
|
||||
|
||||
async openInspectorByTitle(title) {
|
||||
const header = await this.getPanelHeading(title);
|
||||
await this.openInspector(header);
|
||||
|
@ -123,7 +134,12 @@ export function DashboardPanelActionsProvider({ getService, getPageObjects }) {
|
|||
}
|
||||
|
||||
async expectExistsReplacePanelAction() {
|
||||
log.debug('expectExistsEditPanelAction');
|
||||
log.debug('expectExistsReplacePanelAction');
|
||||
await testSubjects.existOrFail(REPLACE_PANEL_DATA_TEST_SUBJ);
|
||||
}
|
||||
|
||||
async expectExistsDuplicatePanelAction() {
|
||||
log.debug('expectExistsDuplicatePanelAction');
|
||||
await testSubjects.existOrFail(REPLACE_PANEL_DATA_TEST_SUBJ);
|
||||
}
|
||||
|
||||
|
@ -133,7 +149,12 @@ export function DashboardPanelActionsProvider({ getService, getPageObjects }) {
|
|||
}
|
||||
|
||||
async expectMissingReplacePanelAction() {
|
||||
log.debug('expectMissingEditPanelAction');
|
||||
log.debug('expectMissingReplacePanelAction');
|
||||
await testSubjects.missingOrFail(REPLACE_PANEL_DATA_TEST_SUBJ);
|
||||
}
|
||||
|
||||
async expectMissingDuplicatePanelAction() {
|
||||
log.debug('expectMissingDuplicatePanelAction');
|
||||
await testSubjects.missingOrFail(REPLACE_PANEL_DATA_TEST_SUBJ);
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue