mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Canvas: add multi layer functionality (#41326)
This commit is contained in:
@@ -7,7 +7,6 @@ import { CanvasGroupOptions } from 'app/features/canvas';
|
||||
import { Scene } from 'app/features/canvas/runtime/scene';
|
||||
import { PanelContext, PanelContextRoot } from '@grafana/ui';
|
||||
import { ElementState } from 'app/features/canvas/runtime/element';
|
||||
import { GroupState } from 'app/features/canvas/runtime/group';
|
||||
|
||||
interface Props extends PanelProps<PanelOptions> {}
|
||||
|
||||
@@ -18,7 +17,6 @@ interface State {
|
||||
export interface InstanceState {
|
||||
scene: Scene;
|
||||
selected: ElementState[];
|
||||
layer: GroupState;
|
||||
}
|
||||
|
||||
export class CanvasPanel extends Component<Props, State> {
|
||||
|
||||
@@ -6,14 +6,17 @@ import { config } from '@grafana/runtime';
|
||||
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
|
||||
|
||||
import { PanelOptions } from '../models.gen';
|
||||
import { InstanceState } from '../CanvasPanel';
|
||||
import { LayerActionID } from '../types';
|
||||
import { CanvasElementOptions, canvasElementRegistry } from 'app/features/canvas';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { ElementState } from 'app/features/canvas/runtime/element';
|
||||
import { notFoundItem } from 'app/features/canvas/elements/notFound';
|
||||
import { GroupState } from 'app/features/canvas/runtime/group';
|
||||
import { LayerEditorProps } from './layerEditor';
|
||||
import { SelectionParams } from 'app/features/canvas/runtime/scene';
|
||||
import { ShowConfirmModalEvent } from 'app/types/events';
|
||||
|
||||
type Props = StandardEditorProps<any, InstanceState, PanelOptions>;
|
||||
type Props = StandardEditorProps<any, LayerEditorProps, PanelOptions>;
|
||||
|
||||
export class LayerElementListEditor extends PureComponent<Props> {
|
||||
style = getLayerDragStyles(config.theme);
|
||||
@@ -40,9 +43,23 @@ export class LayerElementListEditor extends PureComponent<Props> {
|
||||
onSelect = (item: any) => {
|
||||
const { settings } = this.props.item;
|
||||
|
||||
if (settings?.scene && settings?.scene?.selecto) {
|
||||
if (settings?.scene) {
|
||||
try {
|
||||
settings.scene.selecto.clickTarget(item, item?.div);
|
||||
let selection: SelectionParams = { targets: [] };
|
||||
if (item instanceof GroupState) {
|
||||
const targetElements: HTMLDivElement[] = [];
|
||||
item.elements.forEach((element: ElementState) => {
|
||||
targetElements.push(element.div!);
|
||||
});
|
||||
|
||||
selection.targets = targetElements;
|
||||
selection.group = item;
|
||||
settings.scene.select(selection);
|
||||
} else if (item instanceof ElementState) {
|
||||
const targetElement = [item?.div!];
|
||||
selection.targets = targetElement;
|
||||
settings.scene.select(selection);
|
||||
}
|
||||
} catch (error) {
|
||||
appEvents.emit(AppEvents.alertError, ['Unable to select element, try selecting element in panel instead']);
|
||||
}
|
||||
@@ -84,6 +101,79 @@ export class LayerElementListEditor extends PureComponent<Props> {
|
||||
layer.reorder(src, dst);
|
||||
};
|
||||
|
||||
goUpLayer = () => {
|
||||
const settings = this.props.item.settings;
|
||||
|
||||
if (!settings?.layer || !settings?.scene) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scene, layer } = settings;
|
||||
|
||||
if (layer.parent) {
|
||||
scene.updateCurrentLayer(layer.parent);
|
||||
}
|
||||
};
|
||||
|
||||
private decoupleGroup = () => {
|
||||
const settings = this.props.item.settings;
|
||||
|
||||
if (!settings?.layer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { layer } = settings;
|
||||
|
||||
layer.elements.forEach((element: ElementState) => {
|
||||
layer.parent?.doAction(LayerActionID.Duplicate, element);
|
||||
});
|
||||
this.deleteGroup();
|
||||
};
|
||||
|
||||
private onDecoupleGroup = () => {
|
||||
appEvents.publish(
|
||||
new ShowConfirmModalEvent({
|
||||
title: 'Decouple group',
|
||||
text: `Are you sure you want to decouple this group?`,
|
||||
text2: 'This will remove the group and push nested elements in the next level up.',
|
||||
confirmText: 'Yes',
|
||||
yesText: 'Decouple',
|
||||
onConfirm: async () => {
|
||||
this.decoupleGroup();
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
private deleteGroup = () => {
|
||||
const settings = this.props.item.settings;
|
||||
|
||||
if (!settings?.layer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { layer } = settings;
|
||||
|
||||
layer.parent?.doAction(LayerActionID.Delete, layer);
|
||||
this.goUpLayer();
|
||||
};
|
||||
|
||||
private onDeleteGroup = () => {
|
||||
appEvents.publish(
|
||||
new ShowConfirmModalEvent({
|
||||
title: 'Delete group',
|
||||
text: `Are you sure you want to delete this group?`,
|
||||
text2: 'This will delete the group and all nested elements.',
|
||||
icon: 'trash-alt',
|
||||
confirmText: 'Delete',
|
||||
yesText: 'Delete',
|
||||
onConfirm: async () => {
|
||||
this.deleteGroup();
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const settings = this.props.item.settings;
|
||||
if (!settings) {
|
||||
@@ -98,6 +188,22 @@ export class LayerElementListEditor extends PureComponent<Props> {
|
||||
const selection: number[] = settings.selected ? settings.selected.map((v) => v.UID) : [];
|
||||
return (
|
||||
<>
|
||||
{!layer.isRoot() && (
|
||||
<>
|
||||
<Button icon="angle-up" size="sm" variant="secondary" onClick={this.goUpLayer}>
|
||||
Go Up Level
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => this.onSelect(layer)}>
|
||||
Select Group
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => this.onDecoupleGroup()}>
|
||||
Decouple Group
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => this.onDeleteGroup()}>
|
||||
Delete Group
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<DragDropContext onDragEnd={this.onDragEnd}>
|
||||
<Droppable droppableId="droppable">
|
||||
{(provided, snapshot) => (
|
||||
@@ -122,27 +228,31 @@ export class LayerElementListEditor extends PureComponent<Props> {
|
||||
{element.UID} ({i})
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
name="copy"
|
||||
title={'Duplicate'}
|
||||
className={styles.actionIcon}
|
||||
onClick={() => layer.doAction(LayerActionID.Duplicate, element)}
|
||||
surface="header"
|
||||
/>
|
||||
{element.item.id !== 'group' && (
|
||||
<>
|
||||
<IconButton
|
||||
name="copy"
|
||||
title={'Duplicate'}
|
||||
className={styles.actionIcon}
|
||||
onClick={() => layer.doAction(LayerActionID.Duplicate, element)}
|
||||
surface="header"
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
name="trash-alt"
|
||||
title={'Remove'}
|
||||
className={cx(styles.actionIcon, styles.dragIcon)}
|
||||
onClick={() => layer.doAction(LayerActionID.Delete, element)}
|
||||
surface="header"
|
||||
/>
|
||||
<Icon
|
||||
title="Drag and drop to reorder"
|
||||
name="draggabledots"
|
||||
size="lg"
|
||||
className={styles.dragIcon}
|
||||
/>
|
||||
<IconButton
|
||||
name="trash-alt"
|
||||
title={'Remove'}
|
||||
className={cx(styles.actionIcon, styles.dragIcon)}
|
||||
onClick={() => layer.doAction(LayerActionID.Delete, element)}
|
||||
surface="header"
|
||||
/>
|
||||
<Icon
|
||||
title="Drag and drop to reorder"
|
||||
name="draggabledots"
|
||||
size="lg"
|
||||
className={styles.dragIcon}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import React, { FC } from 'react';
|
||||
import { Button } from '@grafana/ui';
|
||||
import { StandardEditorProps } from '@grafana/data';
|
||||
|
||||
import { InstanceState } from '../CanvasPanel';
|
||||
import { PanelOptions } from '../models.gen';
|
||||
import { GroupState } from 'app/features/canvas/runtime/group';
|
||||
import { ElementState } from 'app/features/canvas/runtime/element';
|
||||
import { LayerActionID } from '../types';
|
||||
|
||||
export const MultiSelectionEditor: FC<StandardEditorProps<any, InstanceState, PanelOptions>> = ({ context }) => {
|
||||
const createNewLayer = () => {
|
||||
const currentSelectedElements = context?.instanceState.selected;
|
||||
const currentLayer = currentSelectedElements[0].parent;
|
||||
|
||||
const newLayer = new GroupState(
|
||||
{
|
||||
type: 'group',
|
||||
elements: [],
|
||||
},
|
||||
context.instanceState.scene,
|
||||
currentSelectedElements[0].parent
|
||||
);
|
||||
|
||||
currentSelectedElements.forEach((element: ElementState) => {
|
||||
newLayer.doAction(LayerActionID.Duplicate, element);
|
||||
currentLayer.doAction(LayerActionID.Delete, element);
|
||||
});
|
||||
|
||||
currentLayer.elements.push(newLayer);
|
||||
|
||||
context.instanceState.scene.save();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button icon="plus" size="sm" variant="secondary" onClick={createNewLayer}>
|
||||
Group items
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -72,7 +72,7 @@ export function getElementEditor(opts: CanvasEditorOptions): NestedPanelOptions<
|
||||
}
|
||||
const ctx = { ...context, options: currentOptions };
|
||||
|
||||
if (layer.registerOptionsUI) {
|
||||
if (layer?.registerOptionsUI) {
|
||||
layer.registerOptionsUI(builder, ctx);
|
||||
}
|
||||
|
||||
|
||||
24
public/app/plugins/panel/canvas/editor/elementsEditor.tsx
Normal file
24
public/app/plugins/panel/canvas/editor/elementsEditor.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NestedPanelOptions } from '@grafana/data/src/utils/OptionsUIBuilders';
|
||||
import { Scene } from 'app/features/canvas/runtime/scene';
|
||||
import { MultiSelectionEditor } from './MultiSelectionEditor';
|
||||
|
||||
export interface CanvasEditorGroupOptions {
|
||||
scene: Scene;
|
||||
category?: string[];
|
||||
}
|
||||
|
||||
export const getElementsEditor = (opts: CanvasEditorGroupOptions): NestedPanelOptions<any> => {
|
||||
return {
|
||||
category: opts.category,
|
||||
path: '--',
|
||||
build: (builder, context) => {
|
||||
builder.addCustomEditor({
|
||||
id: 'content',
|
||||
path: '__', // not used
|
||||
name: 'Options',
|
||||
editor: MultiSelectionEditor,
|
||||
settings: opts,
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -4,10 +4,38 @@ import { NestedPanelOptions, NestedValueAccess } from '@grafana/data/src/utils/O
|
||||
import { setOptionImmutably } from 'app/features/dashboard/components/PanelEditor/utils';
|
||||
import { InstanceState } from '../CanvasPanel';
|
||||
import { LayerElementListEditor } from './LayerElementListEditor';
|
||||
import { GroupState } from 'app/features/canvas/runtime/group';
|
||||
import { Scene } from 'app/features/canvas/runtime/scene';
|
||||
import { ElementState } from 'app/features/canvas/runtime/element';
|
||||
|
||||
export function getLayerEditor(opts: InstanceState): NestedPanelOptions<InstanceState> {
|
||||
const { layer } = opts;
|
||||
const options = layer.options || { elements: [] };
|
||||
export interface LayerEditorProps {
|
||||
scene: Scene;
|
||||
layer: GroupState;
|
||||
selected: ElementState[];
|
||||
}
|
||||
|
||||
export function getLayerEditor(opts: InstanceState): NestedPanelOptions<LayerEditorProps> {
|
||||
const { selected, scene } = opts;
|
||||
|
||||
if (!scene.currentLayer) {
|
||||
scene.currentLayer = scene.root as GroupState;
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
for (const element of selected) {
|
||||
if (element instanceof GroupState) {
|
||||
scene.currentLayer = element;
|
||||
break;
|
||||
}
|
||||
|
||||
if (element.parent) {
|
||||
scene.currentLayer = element.parent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const options = scene.currentLayer.options || { elements: [] };
|
||||
|
||||
return {
|
||||
category: ['Layer'],
|
||||
@@ -24,7 +52,7 @@ export function getLayerEditor(opts: InstanceState): NestedPanelOptions<Instance
|
||||
return;
|
||||
}
|
||||
const c = setOptionImmutably(options, path, value);
|
||||
layer.onChange(c);
|
||||
scene.currentLayer?.onChange(c);
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -35,7 +63,7 @@ export function getLayerEditor(opts: InstanceState): NestedPanelOptions<Instance
|
||||
path: 'root',
|
||||
name: 'Elements',
|
||||
editor: LayerElementListEditor,
|
||||
settings: opts,
|
||||
settings: { scene, layer: scene.currentLayer, selected },
|
||||
});
|
||||
|
||||
// // force clean layer configuration
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CanvasPanel, InstanceState } from './CanvasPanel';
|
||||
import { PanelOptions } from './models.gen';
|
||||
import { getElementEditor } from './editor/elementEditor';
|
||||
import { getLayerEditor } from './editor/layerEditor';
|
||||
import { getElementsEditor } from './editor/elementsEditor';
|
||||
|
||||
export const plugin = new PanelPlugin<PanelOptions>(CanvasPanel)
|
||||
.setNoPadding() // extend to panel edges
|
||||
@@ -28,6 +29,13 @@ export const plugin = new PanelPlugin<PanelOptions>(CanvasPanel)
|
||||
scene: state.scene,
|
||||
})
|
||||
);
|
||||
} else if (selection?.length > 1) {
|
||||
builder.addNestedOptions(
|
||||
getElementsEditor({
|
||||
category: [`Current selection`],
|
||||
scene: state.scene,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
builder.addNestedOptions(getLayerEditor(state));
|
||||
|
||||
Reference in New Issue
Block a user