Canvas: Connection properties based on data (#64360)

Co-authored-by: nmarrs <nathanielmarrs@gmail.com>
This commit is contained in:
Adela Almasan
2023-04-25 12:31:45 -05:00
committed by GitHub
parent 1421f388ae
commit 12e5101b91
11 changed files with 272 additions and 50 deletions

View File

@ -5193,6 +5193,9 @@ exports[`better eslint`] = {
"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/connectionEditor.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"],

View File

@ -4,7 +4,7 @@ import { RegistryItem } from '@grafana/data';
import { PanelOptionsSupplier } from '@grafana/data/src/panel/PanelPlugin';
import { config } from 'app/core/config';
import { DimensionContext } from '../dimensions/context';
import { DimensionContext, ColorDimensionConfig, ScaleDimensionConfig } from '../dimensions';
import { BackgroundConfig, Constraint, LineConfig, Placement } from './types';
@ -46,6 +46,8 @@ export interface CanvasConnection {
target: ConnectionCoordinates;
targetName?: string;
path: ConnectionPath;
color?: ColorDimensionConfig;
size?: ScaleDimensionConfig;
// See https://github.com/anseki/leader-line#options for more examples of more properties
}

View File

@ -144,6 +144,8 @@ export class Scene {
this.initMoveable(destroySelecto, enableEditing);
this.currentLayer = this.root;
this.selection.next([]);
this.connections.select(undefined);
this.connections.updateState();
}
});
return this.root;

View File

@ -12,7 +12,7 @@ import { PanelEditEnteredEvent, PanelEditExitedEvent } from 'app/types/events';
import { InlineEdit } from './InlineEdit';
import { SetBackground } from './SetBackground';
import { PanelOptions } from './models.gen';
import { AnchorPoint, CanvasTooltipPayload } from './types';
import { AnchorPoint, CanvasTooltipPayload, ConnectionState } from './types';
interface Props extends PanelProps<PanelOptions> {}
@ -27,6 +27,7 @@ interface State {
export interface InstanceState {
scene: Scene;
selected: ElementState[];
selectedConnection?: ConnectionState;
}
export interface SelectionAction {
@ -113,20 +114,56 @@ export class CanvasPanel extends Component<Props, State> {
this.subs.add(
this.scene.selection.subscribe({
next: (v) => {
if (v.length) {
activeCanvasPanel = this;
activePanelSubject.next({ panel: this });
}
canvasInstances.forEach((canvasInstance) => {
if (canvasInstance !== activeCanvasPanel) {
canvasInstance.scene.clearCurrentSelection(true);
canvasInstance.scene.connections.select(undefined);
}
});
this.panelContext.onInstanceStateChange!({
scene: this.scene,
selected: v,
layer: this.scene.root,
});
},
})
);
activeCanvasPanel = this;
activePanelSubject.next({ panel: this });
this.subs.add(
this.scene.connections.selection.subscribe({
next: (v) => {
if (!this.context.instanceState) {
return;
}
this.panelContext.onInstanceStateChange!({
scene: this.scene,
selected: this.context.instanceState.selected,
selectedConnection: v,
layer: this.scene.root,
});
if (v) {
activeCanvasPanel = this;
activePanelSubject.next({ panel: this });
}
canvasInstances.forEach((canvasInstance) => {
if (canvasInstance !== activeCanvasPanel) {
canvasInstance.scene.clearCurrentSelection(true);
canvasInstance.scene.connections.select(undefined);
}
});
setTimeout(() => {
this.forceUpdate();
});
},
})
);

View File

@ -1,14 +1,12 @@
import { css } from '@emotion/css';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { config } from 'app/core/config';
import { CanvasConnection } from 'app/features/canvas/element';
import { ElementState } from 'app/features/canvas/runtime/element';
import { Scene } from 'app/features/canvas/runtime/scene';
import { getConnections } from './utils';
import { ConnectionState } from './types';
type Props = {
setSVGRef: (anchorElement: SVGSVGElement) => void;
@ -17,16 +15,18 @@ type Props = {
};
let idCounter = 0;
const htmlElementTypes = ['input', 'textarea'];
export const ConnectionSVG = ({ setSVGRef, setLineRef, scene }: Props) => {
const styles = useStyles2(getStyles);
const headId = Date.now() + '_' + idCounter++;
const CONNECTION_LINE_ID = 'connectionLineId';
const CONNECTION_HEAD_ID = useMemo(() => `head-${headId}`, [headId]);
const CONNECTION_LINE_ID = useMemo(() => `connectionLineId-${headId}`, [headId]);
const EDITOR_HEAD_ID = useMemo(() => `editorHead-${headId}`, [headId]);
const defaultArrowColor = config.theme2.colors.text.primary;
const defaultArrowSize = 2;
const [selectedConnection, setSelectedConnection] = useState<CanvasConnection | undefined>(undefined);
const [selectedConnection, setSelectedConnection] = useState<ConnectionState | undefined>(undefined);
// Need to use ref to ensure state is not stale in event handler
const selectedConnectionRef = useRef(selectedConnection);
@ -34,24 +34,36 @@ export const ConnectionSVG = ({ setSVGRef, setLineRef, scene }: Props) => {
selectedConnectionRef.current = selectedConnection;
});
const [selectedConnectionSource, setSelectedConnectionSource] = useState<ElementState | undefined>(undefined);
const selectedConnectionSourceRef = useRef(selectedConnectionSource);
useEffect(() => {
selectedConnectionSourceRef.current = selectedConnectionSource;
});
if (scene.panel.context.instanceState?.selectedConnection) {
setSelectedConnection(scene.panel.context.instanceState?.selectedConnection);
}
}, [scene.panel.context.instanceState?.selectedConnection]);
const onKeyUp = (e: KeyboardEvent) => {
const target = e.target;
if (!(target instanceof HTMLElement)) {
return;
}
if (htmlElementTypes.indexOf(target.nodeName.toLowerCase()) > -1) {
return;
}
// Backspace (8) or delete (46)
if (e.keyCode === 8 || e.keyCode === 46) {
if (selectedConnectionRef.current && selectedConnectionSourceRef.current) {
selectedConnectionSourceRef.current.options.connections =
selectedConnectionSourceRef.current.options.connections?.filter(
(connection) => connection !== selectedConnectionRef.current
if (selectedConnectionRef.current && selectedConnectionRef.current.source) {
selectedConnectionRef.current.source.options.connections =
selectedConnectionRef.current.source.options.connections?.filter(
(connection) => connection !== selectedConnectionRef.current?.info
);
selectedConnectionSourceRef.current.onChange(selectedConnectionSourceRef.current.options);
selectedConnectionRef.current.source.onChange(selectedConnectionRef.current.source.options);
setSelectedConnection(undefined);
setSelectedConnectionSource(undefined);
scene.connections.select(undefined);
scene.connections.updateState();
scene.save();
}
} else {
// Prevent removing event listener if key is not delete
@ -71,28 +83,36 @@ export const ConnectionSVG = ({ setSVGRef, setLineRef, scene }: Props) => {
if (shouldResetSelectedConnection) {
setSelectedConnection(undefined);
setSelectedConnectionSource(undefined);
scene.connections.select(undefined);
}
};
const selectConnection = (connection: CanvasConnection, source: ElementState) => {
const selectConnection = (connection: ConnectionState) => {
if (scene.isEditingEnabled) {
setSelectedConnection(connection);
setSelectedConnectionSource(source);
scene.connections.select(connection);
document.addEventListener('keyup', onKeyUp);
scene.selecto!.rootContainer!.addEventListener('click', clearSelectedConnection);
}
};
// Flat list of all connections
const findConnections = useCallback(() => {
return getConnections(scene.byName);
}, [scene.byName]);
// @TODO revisit, currently returning last row index for field
const getRowIndex = (fieldName: string | undefined) => {
if (fieldName) {
const series = scene.context.getPanelData()?.series[0];
const field = series?.fields.find((f) => (f.name = fieldName));
const data = field?.values;
return data ? data.length - 1 : 0;
}
return 0;
};
// Figure out target and then target's relative coordinates drawing (if no target do parent)
const renderConnections = () => {
return findConnections().map((v, idx) => {
return scene.connections.state.map((v, idx) => {
const { source, target, info } = v;
const sourceRect = source.div?.getBoundingClientRect();
const parent = source.div?.parentElement;
@ -129,13 +149,21 @@ export const ConnectionSVG = ({ setSVGRef, setLineRef, scene }: Props) => {
y2 = parentVerticalCenter - (info.target.y * parentRect.height) / 2;
}
const isSelected = selectedConnection === info;
const selectedStyles = { stroke: '#44aaff', strokeWidth: 3 };
const isSelected = selectedConnection === v && scene.panel.context.instanceState.selectedConnection;
const strokeColor = info.color ? scene.context.getColor(info.color).value() : defaultArrowColor;
const lastRowIndex = getRowIndex(info.size?.field);
const strokeWidth = info.size ? scene.context.getScale(info.size).get(lastRowIndex) : defaultArrowSize;
const connectionCursorStyle = scene.isEditingEnabled ? 'grab' : '';
const selectedStyles = { stroke: '#44aaff', strokeOpacity: 0.6, strokeWidth: strokeWidth + 5 };
const CONNECTION_HEAD_ID = `connectionHead-${headId + Math.random()}`;
return (
<svg className={styles.connection} key={idx}>
<g onClick={() => selectConnection(info, source)}>
<g onClick={() => selectConnection(v)}>
<defs>
<marker
id={CONNECTION_HEAD_ID}
@ -144,17 +172,18 @@ export const ConnectionSVG = ({ setSVGRef, setLineRef, scene }: Props) => {
refX="10"
refY="3.5"
orient="auto"
stroke={defaultArrowColor}
stroke={strokeColor}
>
<polygon points="0 0, 10 3.5, 0 7" fill={defaultArrowColor} />
<polygon points="0 0, 10 3.5, 0 7" fill={strokeColor} />
</marker>
</defs>
<line
id={`${CONNECTION_LINE_ID}_transparent`}
cursor={connectionCursorStyle}
stroke="transparent"
pointerEvents="auto"
stroke="transparent"
strokeWidth={15}
style={isSelected ? selectedStyles : {}}
x1={x1}
y1={y1}
x2={x2}
@ -162,15 +191,14 @@ export const ConnectionSVG = ({ setSVGRef, setLineRef, scene }: Props) => {
/>
<line
id={CONNECTION_LINE_ID}
stroke={defaultArrowColor}
stroke={strokeColor}
pointerEvents="auto"
strokeWidth={2}
strokeWidth={strokeWidth}
markerEnd={`url(#${CONNECTION_HEAD_ID})`}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
style={isSelected ? selectedStyles : {}}
cursor={connectionCursorStyle}
/>
</g>

View File

@ -1,12 +1,15 @@
import React from 'react';
import { BehaviorSubject } from 'rxjs';
import { ConnectionPath } from 'app/features/canvas';
import { config } from '@grafana/runtime';
import { CanvasConnection, ConnectionPath } from 'app/features/canvas';
import { ElementState } from 'app/features/canvas/runtime/element';
import { Scene } from 'app/features/canvas/runtime/scene';
import { CONNECTION_ANCHOR_ALT, ConnectionAnchors, CONNECTION_ANCHOR_HIGHLIGHT_OFFSET } from './ConnectionAnchors';
import { ConnectionSVG } from './ConnectionSVG';
import { isConnectionSource, isConnectionTarget } from './utils';
import { ConnectionState } from './types';
import { getConnections, isConnectionSource, isConnectionTarget } from './utils';
export class Connections {
scene: Scene;
@ -17,11 +20,35 @@ export class Connections {
connectionTarget?: ElementState;
isDrawingConnection?: boolean;
didConnectionLeaveHighlight?: boolean;
state: ConnectionState[] = [];
readonly selection = new BehaviorSubject<ConnectionState | undefined>(undefined);
constructor(scene: Scene) {
this.scene = scene;
this.updateState();
}
select = (connection: ConnectionState | undefined) => {
if (connection === this.selection.value) {
return;
}
this.selection.next(connection);
};
updateState = () => {
const s = this.selection.value;
this.state = getConnections(this.scene.byName);
if (s) {
for (let c of this.state) {
if (c.source === s.source && c.index === s.index) {
this.selection.next(c);
break;
}
}
}
};
setConnectionAnchorRef = (anchorElement: HTMLDivElement) => {
this.connectionAnchorDiv = anchorElement;
};
@ -174,8 +201,14 @@ export class Connections {
y: targetY,
},
targetName: targetName,
color: 'white',
size: 10,
color: {
fixed: config.theme2.colors.text.primary,
},
size: {
fixed: 2,
min: 1,
max: 10,
},
path: ConnectionPath.Straight,
};
@ -199,6 +232,8 @@ export class Connections {
}
this.isDrawingConnection = false;
this.updateState();
this.scene.save();
}
};
@ -224,6 +259,13 @@ export class Connections {
this.scene.selecto?.rootContainer?.addEventListener('mousemove', this.connectionListener);
};
onChange = (current: ConnectionState, update: CanvasConnection) => {
const connections = current.source.options.connections?.splice(0) ?? [];
connections[current.index] = update;
current.source.onChange({ ...current.source.options, connections });
this.updateState();
};
// used for moveable actions
connectionsNeedUpdate = (element: ElementState): boolean => {
return isConnectionSource(element) || isConnectionTarget(element, this.scene.byName);

View File

@ -0,0 +1,42 @@
import { get as lodashGet } from 'lodash';
import { NestedPanelOptions, NestedValueAccess } from '@grafana/data/src/utils/OptionsUIBuilders';
import { CanvasConnection } from 'app/features/canvas';
import { Scene } from 'app/features/canvas/runtime/scene';
import { setOptionImmutably } from 'app/features/dashboard/components/PanelEditor/utils';
import { ConnectionState } from '../types';
import { optionBuilder } from './options';
export interface CanvasConnectionEditorOptions {
connection: ConnectionState;
scene: Scene;
category?: string[];
}
export function getConnectionEditor(opts: CanvasConnectionEditorOptions): NestedPanelOptions<CanvasConnection> {
return {
category: opts.category,
path: '--', // not used!
values: (parent: NestedValueAccess) => ({
getValue: (path: string) => {
return lodashGet(opts.connection.info, path);
},
// TODO: Fix this any (maybe a dimension supplier?)
onChange: (path: string, value: any) => {
console.log(value, typeof value);
let options = opts.connection.info;
options = setOptionImmutably(options, path, value);
opts.scene.connections.onChange(opts.connection, options);
},
}),
build: (builder, context) => {
const ctx = { ...context, options: opts.connection.info };
optionBuilder.addColor(builder, ctx);
optionBuilder.addSize(builder, ctx);
},
};
}

View File

@ -1,11 +1,13 @@
import { PanelOptionsSupplier } from '@grafana/data/src/panel/PanelPlugin';
import { CanvasElementOptions } from 'app/features/canvas';
import { ColorDimensionEditor, ResourceDimensionEditor } from 'app/features/dimensions/editors';
import { CanvasConnection, CanvasElementOptions } from 'app/features/canvas';
import { ColorDimensionEditor, ResourceDimensionEditor, ScaleDimensionEditor } from 'app/features/dimensions/editors';
import { BackgroundSizeEditor } from 'app/features/dimensions/editors/BackgroundSizeEditor';
interface OptionSuppliers {
addBackground: PanelOptionsSupplier<CanvasElementOptions>;
addBorder: PanelOptionsSupplier<CanvasElementOptions>;
addColor: PanelOptionsSupplier<CanvasConnection>;
addSize: PanelOptionsSupplier<CanvasConnection>;
}
const getCategoryName = (str: string, type: string | undefined) => {
@ -81,4 +83,41 @@ export const optionBuilder: OptionSuppliers = {
});
}
},
addColor: (builder, context) => {
const category = ['Color'];
builder.addCustomEditor({
category,
id: 'color',
path: 'color',
name: 'Color',
editor: ColorDimensionEditor,
settings: {},
defaultValue: {
// Configured values
fixed: '',
},
});
},
addSize: (builder, context) => {
const category = ['Size'];
builder.addCustomEditor({
category,
id: 'size',
path: 'size',
name: 'Size',
editor: ScaleDimensionEditor,
settings: {
min: 1,
max: 10,
},
defaultValue: {
// Configured values
fixed: 2,
min: 1,
max: 10,
},
});
},
};

View File

@ -2,6 +2,7 @@ import { FieldConfigProperty, PanelOptionsEditorBuilder, PanelPlugin } from '@gr
import { FrameState } from 'app/features/canvas/runtime/frame';
import { CanvasPanel, InstanceState } from './CanvasPanel';
import { getConnectionEditor } from './editor/connectionEditor';
import { getElementEditor } from './editor/elementEditor';
import { getLayerEditor } from './editor/layerEditor';
import { canvasMigrationHandler } from './migrations';
@ -44,6 +45,8 @@ export const plugin = new PanelPlugin<PanelOptions>(CanvasPanel)
builder.addNestedOptions(getLayerEditor(state));
const selection = state.selected;
const connectionSelection = state.selectedConnection;
if (selection?.length === 1) {
const element = selection[0];
if (!(element instanceof FrameState)) {
@ -56,5 +59,15 @@ export const plugin = new PanelPlugin<PanelOptions>(CanvasPanel)
);
}
}
if (connectionSelection) {
builder.addNestedOptions(
getConnectionEditor({
category: ['Selected connection'],
connection: connectionSelection,
scene: state.scene,
})
);
}
}
});

View File

@ -20,6 +20,7 @@ export interface DropNode extends DragNode {
export enum InlineEditTabs {
ElementManagement = 'element-management',
SelectedElement = 'selected-element',
SelectedConnection = 'selected-connection',
}
export type AnchorPoint = {
@ -33,7 +34,8 @@ export interface CanvasTooltipPayload {
isOpen?: boolean;
}
export interface ConnectionInfo {
export interface ConnectionState {
index: number; // array index from the source
source: ElementState;
target: ElementState;
info: CanvasConnection;

View File

@ -1,3 +1,5 @@
import { isNumber, isString } from 'lodash';
import { AppEvents, Field, LinkModel, PluginState, SelectableValue } from '@grafana/data';
import { hasAlphaPanels } from 'app/core/config';
@ -16,7 +18,7 @@ import { FrameState } from '../../../features/canvas/runtime/frame';
import { Scene, SelectionParams } from '../../../features/canvas/runtime/scene';
import { DimensionContext } from '../../../features/dimensions';
import { AnchorPoint, ConnectionInfo } from './types';
import { AnchorPoint, ConnectionState } from './types';
export function doSelect(scene: Scene, element: ElementState | FrameState) {
try {
@ -139,19 +141,29 @@ export function isConnectionTarget(element: ElementState, sceneByName: Map<strin
}
export function getConnections(sceneByName: Map<string, ElementState>) {
const connections: ConnectionInfo[] = [];
const connections: ConnectionState[] = [];
for (let v of sceneByName.values()) {
if (v.options.connections) {
for (let c of v.options.connections) {
v.options.connections.forEach((c, index) => {
// @TODO Remove after v10.x
if (isString(c.color)) {
c.color = { fixed: c.color };
}
if (isNumber(c.size)) {
c.size = { fixed: 2, min: 1, max: 10 };
}
const target = c.targetName ? sceneByName.get(c.targetName) : v.parent;
if (target) {
connections.push({
index,
source: v,
target,
info: c,
});
}
}
});
}
}
@ -159,7 +171,7 @@ export function getConnections(sceneByName: Map<string, ElementState>) {
}
export function getConnectionsByTarget(element: ElementState, scene: Scene) {
return getConnections(scene.byName).filter((connection) => connection.target === element);
return scene.connections.state.filter((connection) => connection.target === element);
}
export function updateConnectionsForSource(element: ElementState, scene: Scene) {