diff --git a/.betterer.results b/.betterer.results index 34133989d8f..ae38d94c5e2 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"], diff --git a/public/app/features/canvas/element.ts b/public/app/features/canvas/element.ts index e2634ea9229..13a5358006b 100644 --- a/public/app/features/canvas/element.ts +++ b/public/app/features/canvas/element.ts @@ -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 } diff --git a/public/app/features/canvas/runtime/scene.tsx b/public/app/features/canvas/runtime/scene.tsx index 39034cdb554..d6a534cc67a 100644 --- a/public/app/features/canvas/runtime/scene.tsx +++ b/public/app/features/canvas/runtime/scene.tsx @@ -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; diff --git a/public/app/plugins/panel/canvas/CanvasPanel.tsx b/public/app/plugins/panel/canvas/CanvasPanel.tsx index 16d86b6c6de..0b80d268ae7 100644 --- a/public/app/plugins/panel/canvas/CanvasPanel.tsx +++ b/public/app/plugins/panel/canvas/CanvasPanel.tsx @@ -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 {} @@ -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 { 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(); + }); }, }) ); diff --git a/public/app/plugins/panel/canvas/ConnectionSVG.tsx b/public/app/plugins/panel/canvas/ConnectionSVG.tsx index 63e98a7bc88..e2146895cfd 100644 --- a/public/app/plugins/panel/canvas/ConnectionSVG.tsx +++ b/public/app/plugins/panel/canvas/ConnectionSVG.tsx @@ -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(undefined); + const [selectedConnection, setSelectedConnection] = useState(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(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 ( - selectConnection(info, source)}> + selectConnection(v)}> { refX="10" refY="3.5" orient="auto" - stroke={defaultArrowColor} + stroke={strokeColor} > - + { /> diff --git a/public/app/plugins/panel/canvas/Connections.tsx b/public/app/plugins/panel/canvas/Connections.tsx index fa106ad31e1..6edda793f76 100644 --- a/public/app/plugins/panel/canvas/Connections.tsx +++ b/public/app/plugins/panel/canvas/Connections.tsx @@ -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(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); diff --git a/public/app/plugins/panel/canvas/editor/connectionEditor.tsx b/public/app/plugins/panel/canvas/editor/connectionEditor.tsx new file mode 100644 index 00000000000..49689915cac --- /dev/null +++ b/public/app/plugins/panel/canvas/editor/connectionEditor.tsx @@ -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 { + 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); + }, + }; +} diff --git a/public/app/plugins/panel/canvas/editor/options.ts b/public/app/plugins/panel/canvas/editor/options.ts index 2e81e17b821..12858d18c7b 100644 --- a/public/app/plugins/panel/canvas/editor/options.ts +++ b/public/app/plugins/panel/canvas/editor/options.ts @@ -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; addBorder: PanelOptionsSupplier; + addColor: PanelOptionsSupplier; + addSize: PanelOptionsSupplier; } 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, + }, + }); + }, }; diff --git a/public/app/plugins/panel/canvas/module.tsx b/public/app/plugins/panel/canvas/module.tsx index 092afb38e0a..5e3fe07af0e 100644 --- a/public/app/plugins/panel/canvas/module.tsx +++ b/public/app/plugins/panel/canvas/module.tsx @@ -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(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(CanvasPanel) ); } } + + if (connectionSelection) { + builder.addNestedOptions( + getConnectionEditor({ + category: ['Selected connection'], + connection: connectionSelection, + scene: state.scene, + }) + ); + } } }); diff --git a/public/app/plugins/panel/canvas/types.ts b/public/app/plugins/panel/canvas/types.ts index 1e7b5958baf..aea23f571af 100644 --- a/public/app/plugins/panel/canvas/types.ts +++ b/public/app/plugins/panel/canvas/types.ts @@ -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; diff --git a/public/app/plugins/panel/canvas/utils.ts b/public/app/plugins/panel/canvas/utils.ts index 4422882e97f..26fac0ec0a6 100644 --- a/public/app/plugins/panel/canvas/utils.ts +++ b/public/app/plugins/panel/canvas/utils.ts @@ -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) { - 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) { } 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) {