Duplicate panel feature (#61367)

Added a new cloning feature for panels on a dashboard.
This commit is contained in:
Devon Thomson 2020-04-17 11:35:27 -04:00 committed by GitHub
parent cf7da3cdb7
commit 3f98f0f849
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 783 additions and 93 deletions

View file

@ -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)'
);
});

View file

@ -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;
}
}

View file

@ -27,3 +27,8 @@ export {
ReplacePanelActionContext,
ACTION_REPLACE_PANEL,
} from './replace_panel_action';
export {
ClonePanelAction,
ClonePanelActionContext,
ACTION_CLONE_PANEL,
} from './clone_panel_action';

View file

@ -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) {

View file

@ -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);

View file

@ -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,

View file

@ -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;
}

View file

@ -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';

View file

@ -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() {}
}

View file

@ -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',
});
}
}

View file

@ -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,

View file

@ -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();
});

View file

@ -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);
}