Canvas: Tree View Navigation (#51855)

* tree navigation using rc-tree library
This commit is contained in:
Adela Almasan 2022-07-12 08:31:02 -05:00 committed by GitHub
parent db9c9b5354
commit c73d78eaac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 621 additions and 23 deletions

View File

@ -8943,6 +8943,9 @@ exports[`better eslint`] = {
"public/app/plugins/panel/canvas/editor/PlacementEditor.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/plugins/panel/canvas/editor/TreeNavigationEditor.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/plugins/panel/canvas/editor/elementEditor.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
@ -10402,4 +10405,4 @@ exports[`no undocumented stories`] = {
[0, 0, 0, "No undocumented stories are allowed, please add an .mdx file with some documentation", "5381"]
]
}`
};
};

View File

@ -285,6 +285,7 @@
"@sentry/browser": "6.19.7",
"@sentry/types": "6.19.7",
"@sentry/utils": "6.19.7",
"@types/rc-tree": "^3.0.0",
"@types/react-resizable": "3.0.0",
"@types/webpack-env": "^1.17.0",
"@visx/event": "2.6.0",
@ -349,6 +350,7 @@
"rc-drawer": "4.4.3",
"rc-slider": "9.7.5",
"rc-time-picker": "3.7.3",
"rc-tree": "^5.6.5",
"re-resizable": "6.9.9",
"react": "17.0.2",
"react-awesome-query-builder": "^5.1.2",

View File

@ -74,6 +74,19 @@ export class FrameState extends ElementState {
this.reinitializeMoveable();
}
// used for tree view
reorderTree(src: ElementState, dest: ElementState, firstPosition = false) {
const result = Array.from(this.elements);
const srcIndex = this.elements.indexOf(src);
const destIndex = firstPosition ? this.elements.length - 1 : this.elements.indexOf(dest);
const [removed] = result.splice(srcIndex, 1);
result.splice(destIndex, 0, removed);
this.elements = result;
this.reinitializeMoveable();
}
doMove(child: ElementState, action: LayerActionID) {
const vals = this.elements.filter((v) => v !== child);
if (action === LayerActionID.MoveBottom) {
@ -176,7 +189,7 @@ export class FrameState extends ElementState {
render() {
return (
<div key={this.UID} ref={this.initElement} style={{ overflow: 'hidden' }}>
<div key={this.UID} ref={this.initElement}>
{this.elements.map((v) => v.render())}
</div>
);

View File

@ -417,6 +417,65 @@ export class Scene {
});
};
reorderElements = (src: ElementState, dest: ElementState, dragToGap: boolean, destPosition: number) => {
switch (dragToGap) {
case true:
switch (destPosition) {
case -1:
// top of the tree
if (src.parent instanceof FrameState) {
// move outside the frame
if (dest.parent) {
this.updateElements(src, dest.parent, dest.parent.elements.length);
src.updateData(dest.parent.scene.context);
}
} else {
dest.parent?.reorderTree(src, dest, true);
}
break;
default:
if (dest.parent) {
this.updateElements(src, dest.parent, dest.parent.elements.indexOf(dest));
src.updateData(dest.parent.scene.context);
}
break;
}
break;
case false:
if (dest instanceof FrameState) {
if (src.parent === dest) {
// same frame parent
src.parent?.reorderTree(src, dest, true);
} else {
this.updateElements(src, dest);
src.updateData(dest.scene.context);
}
} else if (src.parent === dest.parent) {
src.parent?.reorderTree(src, dest);
} else {
if (dest.parent) {
this.updateElements(src, dest.parent);
src.updateData(dest.parent.scene.context);
}
}
break;
}
};
private updateElements = (src: ElementState, dest: FrameState | RootElement, idx: number | null = null) => {
src.parent?.doAction(LayerActionID.Delete, src);
src.parent = dest;
const elementContainer = src.div?.getBoundingClientRect();
src.setPlacementFromConstraint(elementContainer, dest.div?.getBoundingClientRect());
const destIndex = idx ?? dest.elements.length - 1;
dest.elements.splice(destIndex, 0, src);
dest.scene.save();
dest.reinitializeMoveable();
};
render() {
const canShowContextMenu = this.isPanelEditing || (!this.isPanelEditing && this.isEditingEnabled);

View File

@ -1,8 +1,8 @@
import React, { PureComponent } from 'react';
import { DropResult } from 'react-beautiful-dnd';
import { AppEvents, SelectableValue, StandardEditorProps } from '@grafana/data';
import { config } from '@grafana/runtime/src';
import { SelectableValue, StandardEditorProps } from '@grafana/data';
import { config } from '@grafana/runtime';
import { Button, HorizontalGroup } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import { AddLayerButton } from 'app/core/components/Layers/AddLayerButton';
@ -11,11 +11,11 @@ import { CanvasElementOptions, canvasElementRegistry } from 'app/features/canvas
import { notFoundItem } from 'app/features/canvas/elements/notFound';
import { ElementState } from 'app/features/canvas/runtime/element';
import { FrameState } from 'app/features/canvas/runtime/frame';
import { SelectionParams } from 'app/features/canvas/runtime/scene';
import { ShowConfirmModalEvent } from 'app/types/events';
import { PanelOptions } from '../models.gen';
import { LayerActionID } from '../types';
import { doSelect } from '../utils';
import { LayerEditorProps } from './layerEditor';
@ -50,24 +50,8 @@ export class LayerElementListEditor extends PureComponent<Props> {
onSelect = (item: ElementState) => {
const { settings } = this.props.item;
if (settings?.scene) {
try {
let selection: SelectionParams = { targets: [] };
if (item instanceof FrameState) {
const targetElements: HTMLDivElement[] = [];
targetElements.push(item?.div!);
selection.targets = targetElements;
selection.frame = 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']);
}
doSelect(settings.scene, item);
}
};

View File

@ -0,0 +1,99 @@
import { Global } from '@emotion/react';
import Tree from 'rc-tree';
import React, { Key, useEffect, useState } from 'react';
import SVG from 'react-inlinesvg';
import { StandardEditorProps } from '@grafana/data';
import { useTheme2 } from '@grafana/ui';
import { ElementState } from 'app/features/canvas/runtime/element';
import { getGlobalStyles } from '../globalStyles';
import { PanelOptions } from '../models.gen';
import { getTreeData, onNodeDrop } from '../tree';
import { DragNode, DropNode } from '../types';
import { doSelect } from '../utils';
import { TreeViewEditorProps } from './treeViewEditor';
export const TreeNavigationEditor = ({ item }: StandardEditorProps<any, TreeViewEditorProps, PanelOptions>) => {
const [treeData, setTreeData] = useState(getTreeData(item?.settings?.scene.root));
const [autoExpandParent, setAutoExpandParent] = useState(true);
const [expandedKeys, setExpandedKeys] = useState<Key[]>([]);
const theme = useTheme2();
const globalCSS = getGlobalStyles(theme);
const selectedBgColor = theme.colors.background.secondary;
const { settings } = item;
useEffect(() => {
const selection: string[] = settings?.selected ? settings.selected.map((v) => v.getName()) : [];
setTreeData(getTreeData(item?.settings?.scene.root, selection, selectedBgColor));
}, [item?.settings?.scene.root, selectedBgColor, settings?.selected]);
if (!settings) {
return <div>No settings</div>;
}
const onSelect = (selectedKeys: Key[], info: { node: { dataRef: ElementState } }) => {
if (item.settings?.scene) {
doSelect(item.settings.scene, info.node.dataRef);
}
};
const allowDrop = () => {
return true;
};
const onDrop = (info: { node: DropNode; dragNode: DragNode; dropPosition: number; dropToGap: boolean }) => {
const destPos = info.node.pos.split('-');
const destPosition = info.dropPosition - Number(destPos[destPos.length - 1]);
const srcEl = info.dragNode.dataRef;
const destEl = info.node.dataRef;
const data = onNodeDrop(info, treeData);
setTreeData(data);
destEl.parent?.scene.reorderElements(srcEl, destEl, info.dropToGap, destPosition);
};
const onExpand = (expandedKeys: Key[]) => {
setExpandedKeys(expandedKeys);
setAutoExpandParent(false);
};
const getSvgIcon = (path = '', style = {}) => <SVG src={path} title={'Node Icon'} style={{ ...style }} />;
const switcherIcon = (obj: { isLeaf: boolean; expanded: boolean }) => {
if (obj.isLeaf) {
// TODO: Implement element specific icons
return getSvgIcon('');
}
return getSvgIcon('public/img/icons/unicons/angle-right.svg', {
transform: `rotate(${obj.expanded ? 90 : 0}deg)`,
fill: theme.colors.text.primary,
});
};
return (
<>
<Global styles={globalCSS} />
<Tree
selectable={true}
onSelect={onSelect}
draggable={true}
defaultExpandAll={true}
autoExpandParent={autoExpandParent}
showIcon={false}
allowDrop={allowDrop}
onDrop={onDrop}
expandedKeys={expandedKeys}
onExpand={onExpand}
treeData={treeData}
switcherIcon={switcherIcon}
/>
</>
);
};

View File

@ -0,0 +1,47 @@
import { NestedPanelOptions } from '@grafana/data/src/utils/OptionsUIBuilders';
import { ElementState } from 'app/features/canvas/runtime/element';
import { FrameState } from 'app/features/canvas/runtime/frame';
import { Scene } from 'app/features/canvas/runtime/scene';
import { InstanceState } from '../CanvasPanel';
import { TreeNavigationEditor } from './TreeNavigationEditor';
export interface TreeViewEditorProps {
scene: Scene;
layer: FrameState;
selected: ElementState[];
}
export function getTreeViewEditor(opts: InstanceState): NestedPanelOptions<TreeViewEditorProps> {
const { selected, scene } = opts;
if (selected) {
for (const element of selected) {
if (element instanceof FrameState) {
scene.currentLayer = element;
break;
}
if (element.parent) {
scene.currentLayer = element.parent;
break;
}
}
}
return {
category: ['Tree View'],
path: '--',
build: (builder, context) => {
builder.addCustomEditor({
category: [],
id: 'treeView',
path: '__', // not used
name: '',
editor: TreeNavigationEditor,
settings: { scene, layer: scene.currentLayer, selected },
});
},
};
}

View File

@ -7,5 +7,237 @@ export function getGlobalStyles(theme: GrafanaTheme2) {
.moveable-control-box {
z-index: 999;
}
,
.rc-tree {
margin: 0;
border: 1px solid transparent;
&-focused:not(&-active-focused) {
border-color: cyan;
}
.rc-tree-treenode {
margin: 0;
padding: 5px;
line-height: 24px;
white-space: nowrap;
list-style: none;
outline: 0;
.draggable {
color: #333;
-moz-user-select: none;
-khtml-user-select: none;
-webkit-user-select: none;
user-select: none;
/* Required to make elements draggable in old WebKit */
// -khtml-user-drag: element;
// -webkit-user-drag: element;
}
&:hover {
background-color: ${theme.colors.background.secondary};
}
&.dragging {
background: rgba(100, 100, 255, 0.1);
}
&.drop-container {
> .draggable::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
box-shadow: inset 0 0 0 2px red;
content: '';
}
& ~ .rc-tree-treenode {
border-left: 2px solid chocolate;
}
}
&.drop-target {
border: 1px solid ${theme.colors.border.strong};
& ~ .rc-tree-treenode {
border-left: none;
}
}
&.filter-node {
> .rc-tree-node-content-wrapper {
color: #a60000 !important;
font-weight: bold !important;
}
}
ul {
margin: 0;
padding: 0 0 0 18px;
}
.rc-tree-node-content-wrapper {
position: relative;
display: inline-block;
height: 24px;
margin: 0;
padding: 0;
text-decoration: none;
vertical-align: top;
cursor: pointer;
}
span {
&.rc-tree-switcher,
&.rc-tree-checkbox,
&.rc-tree-iconEle {
display: inline-block;
width: 16px;
height: 16px;
margin-right: 2px;
line-height: 16px;
vertical-align: -0.125em;
background-color: transparent;
background-repeat: no-repeat;
background-attachment: scroll;
border: 0 none;
outline: none;
cursor: pointer;
&.rc-tree-icon__customize {
background-image: none;
}
}
&.rc-tree-icon_loading {
margin-right: 2px;
vertical-align: top;
background: url('data:image/gif;base64,R0lGODlhEAAQAKIGAMLY8YSx5HOm4Mjc88/g9Ofw+v///wAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQFCgAGACwAAAAAEAAQAAADMGi6RbUwGjKIXCAA016PgRBElAVlG/RdLOO0X9nK61W39qvqiwz5Ls/rRqrggsdkAgAh+QQFCgAGACwCAAAABwAFAAADD2hqELAmiFBIYY4MAutdCQAh+QQFCgAGACwGAAAABwAFAAADD1hU1kaDOKMYCGAGEeYFCQAh+QQFCgAGACwKAAIABQAHAAADEFhUZjSkKdZqBQG0IELDQAIAIfkEBQoABgAsCgAGAAUABwAAAxBoVlRKgyjmlAIBqCDCzUoCACH5BAUKAAYALAYACgAHAAUAAAMPaGpFtYYMAgJgLogA610JACH5BAUKAAYALAIACgAHAAUAAAMPCAHWFiI4o1ghZZJB5i0JACH5BAUKAAYALAAABgAFAAcAAAMQCAFmIaEp1motpDQySMNFAgA7')
no-repeat scroll 0 0 transparent;
}
&.rc-tree-switcher {
&.rc-tree-switcher-noop {
cursor: auto;
}
&.rc-tree-switcher_open {
background-position: -93px -56px;
}
&.rc-tree-switcher_close {
background-position: -75px -56px;
}
}
&.rc-tree-checkbox {
width: 13px;
height: 13px;
margin: 0 3px;
background-position: 0 0;
&-checked {
background-position: -14px 0;
}
&-indeterminate {
background-position: -14px -28px;
}
&-disabled {
background-position: 0 -56px;
}
&.rc-tree-checkbox-checked.rc-tree-checkbox-disabled {
background-position: -14px -56px;
}
&.rc-tree-checkbox-indeterminate.rc-tree-checkbox-disabled {
position: relative;
background: #ccc;
border-radius: 3px;
&::after {
position: absolute;
top: 5px;
left: 3px;
width: 5px;
height: 0;
border: 2px solid #fff;
border-top: 0;
border-left: 0;
-webkit-transform: scale(1);
transform: scale(1);
content: ' ';
}
}
}
}
}
&:not(.rc-tree-show-line) {
.rc-tree-treenode {
.rc-tree-switcher-noop {
background: none;
}
}
}
&.rc-tree-show-line {
.rc-tree-treenode:not(:last-child) {
> ul {
background: url('data:image/gif;base64,R0lGODlhCQACAIAAAMzMzP///yH5BAEAAAEALAAAAAAJAAIAAAIEjI9pUAA7') 0 0
repeat-y;
}
> .rc-tree-switcher-noop {
background-position: -56px -18px;
}
}
.rc-tree-treenode:last-child {
> .rc-tree-switcher-noop {
background-position: -56px -36px;
}
}
}
&-child-tree {
display: none;
&-open {
display: block;
}
}
&-treenode-disabled {
> span:not(.rc-tree-switcher),
> a,
> a span {
color: #767676;
cursor: not-allowed;
}
}
&-treenode-active {
background: rgba(0, 0, 0, 0.1);
}
&-node-selected {
opacity: 0.8;
}
&-icon__open {
margin-right: 2px;
vertical-align: top;
background-position: -110px -16px;
}
&-icon__close {
margin-right: 2px;
vertical-align: top;
background-position: -110px 0;
}
&-icon__docu {
margin-right: 2px;
vertical-align: top;
background-position: -110px -32px;
}
&-icon__customize {
margin-right: 2px;
vertical-align: top;
}
&-title {
display: inline-block;
}
&-indent {
display: inline-block;
height: 0;
vertical-align: bottom;
}
&-indent-unit {
display: inline-block;
width: 16px;
}
&-draggable-icon {
display: inline-flex;
justify-content: center;
width: 16px;
}
}
`;
}

View File

@ -4,6 +4,7 @@ import { FrameState } from 'app/features/canvas/runtime/frame';
import { CanvasPanel, InstanceState } from './CanvasPanel';
import { getElementEditor } from './editor/elementEditor';
import { getLayerEditor } from './editor/layerEditor';
import { getTreeViewEditor } from './editor/treeViewEditor';
import { PanelOptions } from './models.gen';
export const plugin = new PanelPlugin<PanelOptions>(CanvasPanel)
@ -20,6 +21,7 @@ export const plugin = new PanelPlugin<PanelOptions>(CanvasPanel)
});
if (state) {
builder.addNestedOptions(getTreeViewEditor(state));
builder.addNestedOptions(getLayerEditor(state));
const selection = state.selected;

View File

@ -0,0 +1,110 @@
import { CSSProperties } from 'react';
import { ElementState } from 'app/features/canvas/runtime/element';
import { FrameState } from 'app/features/canvas/runtime/frame';
import { RootElement } from 'app/features/canvas/runtime/root';
import { DragNode, DropNode } from './types';
export interface TreeElement {
key: number;
title: string;
selectable?: boolean;
children?: TreeElement[];
dataRef: ElementState | FrameState;
style?: CSSProperties;
}
export function getTreeData(root?: RootElement | FrameState, selection?: string[], selectedColor?: string) {
let elements: TreeElement[] = [];
if (root) {
for (let i = root.elements.length; i--; i >= 0) {
const item = root.elements[i];
const element: TreeElement = {
key: item.UID,
title: item.getName(),
selectable: true,
dataRef: item,
};
const isSelected = isItemSelected(item, selection);
if (isSelected) {
element.style = { backgroundColor: selectedColor };
}
if (item instanceof FrameState) {
element.children = getTreeData(item, selection, selectedColor);
if (isSelected) {
element.children.map((child) => {
child.style = { backgroundColor: selectedColor };
});
}
}
elements.push(element);
}
}
return elements;
}
function isItemSelected(item: ElementState, selection: string[] | undefined) {
return Boolean(selection?.includes(item.getName()));
}
export function onNodeDrop(
info: { node: DropNode; dragNode: DragNode; dropPosition: number; dropToGap: boolean },
treeData: TreeElement[]
) {
const destKey = info.node.key;
const srcKey = info.dragNode.key;
const destPos = info.node.pos.split('-');
const destPosition = info.dropPosition - Number(destPos[destPos.length - 1]);
const loop = (
data: TreeElement[],
key: number,
callback: { (item: TreeElement, index: number, arr: TreeElement[]): void }
) => {
data.forEach((item, index, arr) => {
if (item.key === key) {
callback(item, index, arr);
return;
}
if (item.children) {
loop(item.children, key, callback);
}
});
};
const data = [...treeData];
// Find dragObject
let srcElement: TreeElement | undefined = undefined;
loop(data, srcKey, (item: TreeElement, index: number, arr: TreeElement[]) => {
arr.splice(index, 1);
srcElement = item;
});
if (destPosition === 0) {
// Drop on the content
loop(data, destKey, (item: TreeElement) => {
item.children = item.children || [];
item.children.unshift(srcElement!);
});
} else {
// Drop on the gap (insert before or insert after)
let ar: TreeElement[] = [];
let i = 0;
loop(data, destKey, (item: TreeElement, index: number, arr: TreeElement[]) => {
ar = arr;
i = index;
});
if (destPosition === -1) {
ar.splice(i, 0, srcElement!);
} else {
ar.splice(i + 1, 0, srcElement!);
}
}
return data;
}

View File

@ -1,6 +1,17 @@
import { ElementState } from '../../../features/canvas/runtime/element';
export enum LayerActionID {
Delete = 'delete',
Duplicate = 'duplicate',
MoveTop = 'move-top',
MoveBottom = 'move-bottom',
}
export interface DragNode {
key: number;
dataRef: ElementState;
}
export interface DropNode extends DragNode {
pos: string;
}

View File

@ -0,0 +1,25 @@
import { AppEvents } from '@grafana/data/src';
import appEvents from '../../../core/app_events';
import { ElementState } from '../../../features/canvas/runtime/element';
import { FrameState } from '../../../features/canvas/runtime/frame';
import { Scene, SelectionParams } from '../../../features/canvas/runtime/scene';
export function doSelect(scene: Scene, element: ElementState | FrameState) {
try {
let selection: SelectionParams = { targets: [] };
if (element instanceof FrameState) {
const targetElements: HTMLDivElement[] = [];
targetElements.push(element?.div!);
selection.targets = targetElements;
selection.frame = element;
scene.select(selection);
} else {
scene.currentLayer = element.parent;
selection.targets = [element?.div!];
scene.select(selection);
}
} catch (error) {
appEvents.emit(AppEvents.alertError, ['Unable to select element, try selecting element in panel instead']);
}
}

View File

@ -11386,6 +11386,15 @@ __metadata:
languageName: node
linkType: hard
"@types/rc-tree@npm:^3.0.0":
version: 3.0.0
resolution: "@types/rc-tree@npm:3.0.0"
dependencies:
rc-tree: "*"
checksum: bf8e599c5a62c8f8d91fbecdc210119c4c2ad07bdfb8adecb4d1cce9cb967fd6856ad2940431b8fdc9993cd261f127c33588f2988bfc7bc6438993de1c6eeff3
languageName: node
linkType: hard
"@types/react-beautiful-dnd@npm:13.1.2":
version: 13.1.2
resolution: "@types/react-beautiful-dnd@npm:13.1.2"
@ -21051,6 +21060,7 @@ __metadata:
"@types/pluralize": ^0.0.29
"@types/prismjs": 1.26.0
"@types/rc-time-picker": 3.4.1
"@types/rc-tree": ^3.0.0
"@types/react": 17.0.42
"@types/react-beautiful-dnd": 13.1.2
"@types/react-dom": 17.0.14
@ -21192,6 +21202,7 @@ __metadata:
rc-drawer: 4.4.3
rc-slider: 9.7.5
rc-time-picker: 3.7.3
rc-tree: ^5.6.5
re-resizable: 6.9.9
react: 17.0.2
react-awesome-query-builder: ^5.1.2
@ -30488,7 +30499,7 @@ __metadata:
languageName: node
linkType: hard
"rc-tree@npm:~5.6.3":
"rc-tree@npm:*, rc-tree@npm:^5.6.5, rc-tree@npm:~5.6.3":
version: 5.6.5
resolution: "rc-tree@npm:5.6.5"
dependencies: