From ff7225745a66e4b9e6e99c349d2750e126a42377 Mon Sep 17 00:00:00 2001 From: Nathan Marrs Date: Thu, 22 Sep 2022 10:21:45 -0700 Subject: [PATCH] Canvas: Add metric value element type (#55205) --- .../components/MatchersUI/FieldNamePicker.tsx | 2 +- public/app/features/canvas/element.ts | 6 + .../features/canvas/elements/metricValue.tsx | 220 ++++++++++++++++++ .../app/features/canvas/elements/textBox.tsx | 4 +- public/app/features/canvas/registry.ts | 9 +- .../app/features/canvas/runtime/element.tsx | 13 +- public/app/features/canvas/runtime/scene.tsx | 38 ++- .../app/plugins/panel/canvas/CanvasPanel.tsx | 1 + .../canvas/editor/TreeNavigationEditor.tsx | 3 + 9 files changed, 284 insertions(+), 12 deletions(-) create mode 100644 public/app/features/canvas/elements/metricValue.tsx diff --git a/packages/grafana-ui/src/components/MatchersUI/FieldNamePicker.tsx b/packages/grafana-ui/src/components/MatchersUI/FieldNamePicker.tsx index 2ee4b776551..7b5ff5e2f46 100644 --- a/packages/grafana-ui/src/components/MatchersUI/FieldNamePicker.tsx +++ b/packages/grafana-ui/src/components/MatchersUI/FieldNamePicker.tsx @@ -6,7 +6,7 @@ import { Select } from '../Select/Select'; import { useFieldDisplayNames, useSelectOptions, frameHasName } from './utils'; -// Pick a field name out of the fulds +// Pick a field name out of the fields export const FieldNamePicker: React.FC> = ({ value, onChange, diff --git a/public/app/features/canvas/element.ts b/public/app/features/canvas/element.ts index 9f2afee3f40..f4a60f88097 100644 --- a/public/app/features/canvas/element.ts +++ b/public/app/features/canvas/element.ts @@ -34,6 +34,9 @@ export interface CanvasElementProps { // Raw data data?: TData; + + // If the element is currently selected + isSelected?: boolean; } /** @@ -54,6 +57,9 @@ export interface CanvasElementItem extends RegistryI /** Build the configuration UI */ registerOptionsUI?: PanelOptionsSupplier>; + + /** If item has an edit mode */ + hasEditMode?: boolean; } export const defaultBgColor = '#D9D9D9'; diff --git a/public/app/features/canvas/elements/metricValue.tsx b/public/app/features/canvas/elements/metricValue.tsx new file mode 100644 index 00000000000..fe424f705df --- /dev/null +++ b/public/app/features/canvas/elements/metricValue.tsx @@ -0,0 +1,220 @@ +import { css } from '@emotion/css'; +import React, { useCallback } from 'react'; +import { useObservable } from 'react-use'; +import { of } from 'rxjs'; + +import { DataFrame, FieldNamePickerConfigSettings, GrafanaTheme2, StandardEditorsRegistryItem } from '@grafana/data'; +import { usePanelContext, useStyles2 } from '@grafana/ui'; +import { FieldNamePicker } from '@grafana/ui/src/components/MatchersUI/FieldNamePicker'; +import { TextDimensionMode } from 'app/features/dimensions'; +import { DimensionContext } from 'app/features/dimensions/context'; +import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor'; +import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor'; + +import { CanvasElementItem, CanvasElementProps, defaultBgColor, defaultTextColor } from '../element'; +import { ElementState } from '../runtime/element'; + +import { Align, TextBoxConfig, TextBoxData, VAlign } from './textBox'; + +// eslint-disable-next-line +const dummyFieldSettings: StandardEditorsRegistryItem = { + settings: {}, +} as StandardEditorsRegistryItem; + +const MetricValueDisplay = (props: CanvasElementProps) => { + const { data, isSelected } = props; + const styles = useStyles2(getStyles(data)); + + const context = usePanelContext(); + const scene = context.instanceState?.scene; + + const isEditMode = useObservable(scene?.editModeEnabled ?? of(false)); + + if (isEditMode && isSelected) { + return ; + } + return ( +
+ {data?.text ? data.text : 'Double click to set field'} +
+ ); +}; + +const MetricValueEdit = (props: CanvasElementProps) => { + let { data, config } = props; + const context = usePanelContext(); + let panelData: DataFrame[]; + panelData = context.instanceState?.scene?.data.series; + + const onFieldChange = useCallback( + (field) => { + let selectedElement: ElementState; + selectedElement = context.instanceState?.selected[0]; + if (selectedElement) { + const options = selectedElement.options; + selectedElement.onChange({ + ...options, + config: { + ...options.config, + text: { fixed: '', field: field, mode: TextDimensionMode.Field }, + }, + background: { + color: { field: field, fixed: options.background?.color?.fixed ?? '' }, + }, + }); + + // Force a re-render (update scene data after config update) + const scene = context.instanceState?.scene; + if (scene) { + scene.editModeEnabled.next(false); + scene.updateData(scene.data); + } + } + }, + [context.instanceState?.scene, context.instanceState?.selected] + ); + + const styles = useStyles2(getStyles(data)); + return ( +
+ {panelData && ( + + )} +
+ ); +}; + +const getStyles = (data: TextBoxData | undefined) => (theme: GrafanaTheme2) => ({ + container: css` + position: absolute; + height: 100%; + width: 100%; + display: table; + `, + inlineEditorContainer: css` + height: 100%; + width: 100%; + display: flex; + align-items: center; + padding: 10px; + `, + span: css` + display: table-cell; + vertical-align: ${data?.valign}; + text-align: ${data?.align}; + font-size: ${data?.size}px; + color: ${data?.color}; + `, +}); + +export const metricValueItem: CanvasElementItem = { + id: 'metric-value', + name: 'Metric Value', + description: 'Display a field value', + + display: MetricValueDisplay, + + hasEditMode: true, + + defaultSize: { + width: 260, + height: 50, + }, + + getNewOptions: (options) => ({ + ...options, + config: { + align: Align.Center, + valign: VAlign.Middle, + color: { + fixed: defaultTextColor, + }, + text: { mode: TextDimensionMode.Field, fixed: '', field: '' }, + size: 20, + }, + background: { + color: { + fixed: defaultBgColor, + }, + }, + placement: { + top: 100, + left: 100, + }, + }), + + prepareData: (ctx: DimensionContext, cfg: TextBoxConfig) => { + const data: TextBoxData = { + text: cfg.text ? ctx.getText(cfg.text).value() : '', + align: cfg.align ?? Align.Center, + valign: cfg.valign ?? VAlign.Middle, + size: cfg.size, + }; + + if (cfg.color) { + data.color = ctx.getColor(cfg.color).value(); + } + + return data; + }, + + registerOptionsUI: (builder) => { + const category = ['Metric value']; + builder + .addCustomEditor({ + category, + id: 'textSelector', + path: 'config.text', + name: 'Text', + editor: TextDimensionEditor, + }) + .addCustomEditor({ + category, + id: 'config.color', + path: 'config.color', + name: 'Text color', + editor: ColorDimensionEditor, + settings: {}, + defaultValue: {}, + }) + .addRadio({ + category, + path: 'config.align', + name: 'Align text', + settings: { + options: [ + { value: Align.Left, label: 'Left' }, + { value: Align.Center, label: 'Center' }, + { value: Align.Right, label: 'Right' }, + ], + }, + defaultValue: Align.Left, + }) + .addRadio({ + category, + path: 'config.valign', + name: 'Vertical align', + settings: { + options: [ + { value: VAlign.Top, label: 'Top' }, + { value: VAlign.Middle, label: 'Middle' }, + { value: VAlign.Bottom, label: 'Bottom' }, + ], + }, + defaultValue: VAlign.Middle, + }) + .addNumberInput({ + category, + path: 'config.size', + name: 'Text size', + settings: { + placeholder: 'Auto', + }, + }); + }, +}; diff --git a/public/app/features/canvas/elements/textBox.tsx b/public/app/features/canvas/elements/textBox.tsx index b1197e15024..852e94f2201 100644 --- a/public/app/features/canvas/elements/textBox.tsx +++ b/public/app/features/canvas/elements/textBox.tsx @@ -23,7 +23,7 @@ export enum VAlign { Bottom = 'bottom', } -interface TextBoxData { +export interface TextBoxData { text?: string; color?: string; size?: number; // 0 or missing will "auto size" @@ -31,7 +31,7 @@ interface TextBoxData { valign: VAlign; } -interface TextBoxConfig { +export interface TextBoxConfig { text?: TextDimensionConfig; color?: ColorDimensionConfig; size?: number; // 0 or missing will "auto size" diff --git a/public/app/features/canvas/registry.ts b/public/app/features/canvas/registry.ts index 719dfc54d80..8ed20d197b6 100644 --- a/public/app/features/canvas/registry.ts +++ b/public/app/features/canvas/registry.ts @@ -6,18 +6,21 @@ import { droneFrontItem } from './elements/droneFront'; import { droneSideItem } from './elements/droneSide'; import { droneTopItem } from './elements/droneTop'; import { iconItem } from './elements/icon'; +import { metricValueItem } from './elements/metricValue'; import { textBoxItem } from './elements/textBox'; import { windTurbineItem } from './elements/windTurbine'; export const DEFAULT_CANVAS_ELEMENT_CONFIG: CanvasElementOptions = { - ...iconItem.getNewOptions(), - type: iconItem.id, + ...metricValueItem.getNewOptions(), + placement: { ...metricValueItem.getNewOptions().placement, ...metricValueItem.defaultSize }, + type: metricValueItem.id, name: `Element 1`, }; export const defaultElementItems = [ - iconItem, // default for now + metricValueItem, // default for now textBoxItem, + iconItem, ]; const advancedElementItems = [buttonItem, windTurbineItem, droneTopItem, droneFrontItem, droneSideItem]; diff --git a/public/app/features/canvas/runtime/element.tsx b/public/app/features/canvas/runtime/element.tsx index 2dd63cf2125..9b1d6d5176b 100644 --- a/public/app/features/canvas/runtime/element.tsx +++ b/public/app/features/canvas/runtime/element.tsx @@ -445,10 +445,19 @@ export class ElementState implements LayerElement { }; render() { - const { item } = this; + const { item, div } = this; + const scene = this.getScene(); + // TODO: Rethink selected state handling + const isSelected = div && scene && scene.selecto && scene.selecto.getSelectedTargets().includes(div); + return (
- +
); } diff --git a/public/app/features/canvas/runtime/scene.tsx b/public/app/features/canvas/runtime/scene.tsx index 561a33c3b54..5a58a43aa3a 100644 --- a/public/app/features/canvas/runtime/scene.tsx +++ b/public/app/features/canvas/runtime/scene.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/css'; import Moveable from 'moveable'; import React, { CSSProperties } from 'react'; -import { ReplaySubject, Subject } from 'rxjs'; +import { BehaviorSubject, ReplaySubject, Subject, Subscription } from 'rxjs'; import { first } from 'rxjs/operators'; import Selecto from 'selecto'; @@ -67,6 +67,9 @@ export class Scene { inlineEditingCallback?: () => void; + readonly editModeEnabled = new BehaviorSubject(false); + subscription: Subscription; + constructor( cfg: CanvasFrameOptions, enableEditing: boolean, @@ -74,6 +77,13 @@ export class Scene { public onSave: (cfg: CanvasFrameOptions) => void ) { this.root = this.load(cfg, enableEditing, showAdvancedTypes); + + this.subscription = this.editModeEnabled.subscribe((open) => { + if (!this.moveable || !this.isEditingEnabled) { + return; + } + this.moveable.draggable = !open; + }); } getNextElementName = (isFrame = false) => { @@ -284,6 +294,7 @@ export class Scene { if (this.selecto) { this.selecto.setSelectedTargets(selection.targets); this.updateSelection(selection); + this.editModeEnabled.next(false); } }; @@ -339,7 +350,7 @@ export class Scene { }); this.moveable = new Moveable(this.div!, { - draggable: allowChanges, + draggable: allowChanges && !this.editModeEnabled.getValue(), resizable: allowChanges, ables: [dimensionViewable, constraintViewable(this), settingsViewable(this)], props: { @@ -350,6 +361,17 @@ export class Scene { origin: false, className: this.styles.selected, }) + .on('click', (event) => { + const targetedElement = this.findElementByTarget(event.target); + let elementSupportsEditing = false; + if (targetedElement) { + elementSupportsEditing = targetedElement.item.hasEditMode ?? false; + } + + if (event.isDouble && allowChanges && !this.editModeEnabled.getValue() && elementSupportsEditing) { + this.editModeEnabled.next(true); + } + }) .on('clickGroup', (event) => { this.selecto!.clickTarget(event.inputEvent, event.inputTarget); }) @@ -441,7 +463,12 @@ export class Scene { .includes(selectedTarget.parentElement.parentElement); // Apply grabbing cursor while dragging, applyLayoutStylesToDiv() resets it to grab when done - if (this.isEditingEnabled && isTargetMoveableElement && this.selecto?.getSelectedTargets().length) { + if ( + this.isEditingEnabled && + !this.editModeEnabled.getValue() && + isTargetMoveableElement && + this.selecto?.getSelectedTargets().length + ) { this.selecto.getSelectedTargets()[0].style.cursor = 'grabbing'; } @@ -450,12 +477,15 @@ export class Scene { event.stop(); } }) + .on('select', () => { + this.editModeEnabled.next(false); + }) .on('selectEnd', (event) => { targets = event.selected; this.updateSelection({ targets }); if (event.isDragStart) { - if (this.isEditingEnabled && this.selecto?.getSelectedTargets().length) { + if (this.isEditingEnabled && !this.editModeEnabled.getValue() && this.selecto?.getSelectedTargets().length) { this.selecto.getSelectedTargets()[0].style.cursor = 'grabbing'; } event.inputEvent.preventDefault(); diff --git a/public/app/plugins/panel/canvas/CanvasPanel.tsx b/public/app/plugins/panel/canvas/CanvasPanel.tsx index eb4b715bf1b..298a668c126 100644 --- a/public/app/plugins/panel/canvas/CanvasPanel.tsx +++ b/public/app/plugins/panel/canvas/CanvasPanel.tsx @@ -121,6 +121,7 @@ export class CanvasPanel extends Component { } componentWillUnmount() { + this.scene.subscription.unsubscribe(); this.subs.unsubscribe(); isInlineEditOpen = false; canvasInstances = canvasInstances.filter((ci) => ci.props.id !== activeCanvasPanel?.props.id); diff --git a/public/app/plugins/panel/canvas/editor/TreeNavigationEditor.tsx b/public/app/plugins/panel/canvas/editor/TreeNavigationEditor.tsx index 393c7a2a4f3..8701c64118a 100644 --- a/public/app/plugins/panel/canvas/editor/TreeNavigationEditor.tsx +++ b/public/app/plugins/panel/canvas/editor/TreeNavigationEditor.tsx @@ -111,6 +111,9 @@ export const TreeNavigationEditor = ({ item }: StandardEditorProps