From 5df91bdcf1dd612ef5f70c5919114b1a5f5e7be0 Mon Sep 17 00:00:00 2001 From: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Wed, 4 May 2022 14:15:07 -0500 Subject: [PATCH] Canvas: Inline edit (#48222) Canvas inline edit panel Co-authored-by: Ryan McKinley --- package.json | 1 + public/app/features/canvas/runtime/scene.tsx | 9 +- .../app/plugins/panel/canvas/CanvasPanel.tsx | 102 ++++++++++++++- .../app/plugins/panel/canvas/InlineEdit.tsx | 121 ++++++++++++++++++ .../plugins/panel/canvas/InlineEditBody.tsx | 99 ++++++++++++++ .../app/plugins/panel/canvas/globalStyles.ts | 11 ++ public/sass/components/_dashboard_grid.scss | 3 +- yarn.lock | 10 ++ 8 files changed, 349 insertions(+), 7 deletions(-) create mode 100644 public/app/plugins/panel/canvas/InlineEdit.tsx create mode 100644 public/app/plugins/panel/canvas/InlineEditBody.tsx create mode 100644 public/app/plugins/panel/canvas/globalStyles.ts diff --git a/package.json b/package.json index e8b82a35ed7..c266818d655 100644 --- a/package.json +++ b/package.json @@ -281,6 +281,7 @@ "@sentry/browser": "6.19.1", "@sentry/types": "6.19.1", "@sentry/utils": "6.19.1", + "@types/react-resizable": "^1.7.4", "@visx/event": "2.6.0", "@visx/gradient": "2.1.0", "@visx/group": "2.1.0", diff --git a/public/app/features/canvas/runtime/scene.tsx b/public/app/features/canvas/runtime/scene.tsx index 0b951cb2bc6..dd66f93a677 100644 --- a/public/app/features/canvas/runtime/scene.tsx +++ b/public/app/features/canvas/runtime/scene.tsx @@ -55,6 +55,7 @@ export class Scene { div?: HTMLDivElement; currentLayer?: FrameState; isEditingEnabled?: boolean; + skipNextSelectionBroadcast = false; constructor(cfg: CanvasFrameOptions, enableEditing: boolean, public onSave: (cfg: CanvasFrameOptions) => void) { this.root = this.load(cfg, enableEditing); @@ -199,7 +200,8 @@ export class Scene { }; }; - clearCurrentSelection() { + clearCurrentSelection(skipNextSelectionBroadcast = false) { + this.skipNextSelectionBroadcast = skipNextSelectionBroadcast; let event: MouseEvent = new MouseEvent('click'); this.selecto?.clickTarget(event, this.div); } @@ -256,6 +258,11 @@ export class Scene { private updateSelection = (selection: SelectionParams) => { this.moveable!.target = selection.targets; + if (this.skipNextSelectionBroadcast) { + this.skipNextSelectionBroadcast = false; + return; + } + if (selection.frame) { this.selection.next([selection.frame]); } else { diff --git a/public/app/plugins/panel/canvas/CanvasPanel.tsx b/public/app/plugins/panel/canvas/CanvasPanel.tsx index 6b1c52468e9..b04d25bc77a 100644 --- a/public/app/plugins/panel/canvas/CanvasPanel.tsx +++ b/public/app/plugins/panel/canvas/CanvasPanel.tsx @@ -1,19 +1,23 @@ -import { Component } from 'react'; -import { Subscription } from 'rxjs'; +import { css } from '@emotion/css'; +import React, { Component } from 'react'; +import { ReplaySubject, Subscription } from 'rxjs'; -import { PanelProps } from '@grafana/data'; -import { PanelContext, PanelContextRoot } from '@grafana/ui'; +import { GrafanaTheme, PanelProps } from '@grafana/data'; +import { config, locationService } from '@grafana/runtime/src'; +import { Button, PanelContext, PanelContextRoot, stylesFactory } from '@grafana/ui'; import { CanvasFrameOptions } from 'app/features/canvas'; import { ElementState } from 'app/features/canvas/runtime/element'; import { Scene } from 'app/features/canvas/runtime/scene'; import { PanelEditEnteredEvent, PanelEditExitedEvent } from 'app/types/events'; +import { InlineEdit } from './InlineEdit'; import { PanelOptions } from './models.gen'; interface Props extends PanelProps {} interface State { refresh: number; + openInlineEdit: boolean; } export interface InstanceState { @@ -21,6 +25,16 @@ export interface InstanceState { selected: ElementState[]; } +export interface SelectionAction { + panel: CanvasPanel; +} + +let canvasInstances: CanvasPanel[] = []; +let activeCanvasPanel: CanvasPanel | undefined = undefined; +let isInlineEditOpen = false; + +export const activePanelSubject = new ReplaySubject(1); + export class CanvasPanel extends Component { static contextType = PanelContextRoot; panelContext: PanelContext = {} as PanelContext; @@ -28,11 +42,14 @@ export class CanvasPanel extends Component { readonly scene: Scene; private subs = new Subscription(); needsReload = false; + styles = getStyles(config.theme); + isEditing = locationService.getSearchObject().editPanel !== undefined; constructor(props: Props) { super(props); this.state = { refresh: 0, + openInlineEdit: false, }; // Only the initial options are ever used. @@ -45,6 +62,7 @@ export class CanvasPanel extends Component { this.props.eventBus.subscribe(PanelEditEnteredEvent, (evt) => { // Remove current selection when entering edit mode for any panel in dashboard this.scene.clearCurrentSelection(); + this.inlineEditButtonClose(); }) ); @@ -58,6 +76,9 @@ export class CanvasPanel extends Component { } componentDidMount() { + activeCanvasPanel = this; + activePanelSubject.next({ panel: this }); + this.panelContext = this.context as PanelContext; if (this.panelContext.onInstanceStateChange) { this.panelContext.onInstanceStateChange({ @@ -73,14 +94,27 @@ export class CanvasPanel extends Component { selected: v, layer: this.scene.root, }); + + activeCanvasPanel = this; + activePanelSubject.next({ panel: this }); + + canvasInstances.forEach((canvasInstance) => { + if (canvasInstance !== activeCanvasPanel) { + canvasInstance.scene.clearCurrentSelection(true); + } + }); }, }) ); } + + canvasInstances.push(this); } componentWillUnmount() { this.subs.unsubscribe(); + isInlineEditOpen = false; + canvasInstances = canvasInstances.filter((ci) => ci.props.id !== activeCanvasPanel?.props.id); } // NOTE, all changes to the scene flow through this function @@ -91,6 +125,7 @@ export class CanvasPanel extends Component { ...options, root, }); + this.setState({ refresh: this.state.refresh + 1 }); // console.log('send changes', root); }; @@ -112,6 +147,10 @@ export class CanvasPanel extends Component { changed = true; } + if (this.state.openInlineEdit !== nextState.openInlineEdit) { + changed = true; + } + // After editing, the options are valid, but the scene was in a different panel or inline editing mode has changed const shouldUpdateSceneAndPanel = this.needsReload && this.props.options !== nextProps.options; const inlineEditingSwitched = this.props.options.inlineEditing !== nextProps.options.inlineEditing; @@ -130,7 +169,60 @@ export class CanvasPanel extends Component { return changed; } + inlineEditButtonClick = () => { + if (isInlineEditOpen) { + this.forceUpdate(); + this.setActivePanel(); + return; + } + + this.setActivePanel(); + this.setState({ openInlineEdit: true }); + isInlineEditOpen = true; + }; + + inlineEditButtonClose = () => { + this.setState({ openInlineEdit: false }); + isInlineEditOpen = false; + }; + + setActivePanel = () => { + activeCanvasPanel = this; + activePanelSubject.next({ panel: this }); + }; + + renderInlineEdit = () => { + return this.inlineEditButtonClose()} />; + }; + render() { - return this.scene.render(); + return ( + <> + {this.scene.render()} + {this.props.options.inlineEditing && !this.isEditing && ( +
+
+
+ {this.state.openInlineEdit && this.renderInlineEdit()} +
+ )} + + ); } } + +const getStyles = stylesFactory((theme: GrafanaTheme) => ({ + inlineEditButton: css` + position: absolute; + bottom: 8px; + left: 8px; + z-index: 999; + `, +})); diff --git a/public/app/plugins/panel/canvas/InlineEdit.tsx b/public/app/plugins/panel/canvas/InlineEdit.tsx new file mode 100644 index 00000000000..d3249248c4b --- /dev/null +++ b/public/app/plugins/panel/canvas/InlineEdit.tsx @@ -0,0 +1,121 @@ +import { css } from '@emotion/css'; +import React, { SyntheticEvent, useRef, useState } from 'react'; +import Draggable from 'react-draggable'; +import { Resizable, ResizeCallbackData } from 'react-resizable'; + +import { Dimensions2D, GrafanaTheme2 } from '@grafana/data'; +import { IconButton, Portal, useStyles2 } from '@grafana/ui'; +import store from 'app/core/store'; + +import { InlineEditBody } from './InlineEditBody'; + +type Props = { + onClose?: () => void; +}; + +const OFFSET_X = 70; + +export const InlineEdit = ({ onClose }: Props) => { + const btnInlineEdit = document.querySelector('[data-btninlineedit]')!.getBoundingClientRect(); + const ref = useRef(null); + const styles = useStyles2(getStyles); + const inlineEditKey = 'inlineEditPanel'; + + const defaultMeasurements = { width: 350, height: 400 }; + const defaultX = btnInlineEdit.x + OFFSET_X; + const defaultY = btnInlineEdit.y - defaultMeasurements.height; + + const savedPlacement = store.getObject(inlineEditKey, { + x: defaultX, + y: defaultY, + w: defaultMeasurements.width, + h: defaultMeasurements.height, + }); + const [measurements, setMeasurements] = useState({ width: savedPlacement.w, height: savedPlacement.h }); + const [placement, setPlacement] = useState({ x: savedPlacement.x, y: savedPlacement.y }); + + const onDragStop = (event: any, dragElement: any) => { + let x = dragElement.x < 0 ? 0 : dragElement.x; + let y = dragElement.y < 0 ? 0 : dragElement.y; + + setPlacement({ x: x, y: y }); + saveToStore(x, y, measurements.width, measurements.height); + }; + + const onResizeStop = (event: SyntheticEvent, data: ResizeCallbackData) => { + const { size } = data; + setMeasurements({ width: size.width, height: size.height }); + saveToStore(placement.x, placement.y, size.width, size.height); + }; + + const saveToStore = (x: number, y: number, width: number, height: number) => { + store.setObject(inlineEditKey, { x: x, y: y, w: width, h: height }); + }; + + return ( + +
+ + +
+ +
+
Canvas Inline Editor
+ +
+
+
+ +
+
+
+ + +
+ + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + inlineEditorContainer: css` + display: flex; + flex-direction: column; + background: ${theme.v1.colors.panelBg}; + box-shadow: 5px 5px 20px -5px #000000; + z-index: 1000; + opacity: 1; + `, + draggableWrapper: css` + width: 0; + height: 0; + `, + inlineEditorHeader: css` + display: flex; + align-items: center; + justify-content: center; + background: ${theme.colors.background.canvas}; + border: 1px solid ${theme.colors.border.weak}; + height: 40px; + cursor: move; + `, + inlineEditorContent: css` + white-space: pre-wrap; + padding: 10px; + `, + inlineEditorClose: css` + margin-left: auto; + `, + placeholder: css` + width: 24px; + height: 24px; + visibility: hidden; + margin-right: auto; + `, + inlineEditorContentWrapper: css` + overflow: scroll; + `, +}); diff --git a/public/app/plugins/panel/canvas/InlineEditBody.tsx b/public/app/plugins/panel/canvas/InlineEditBody.tsx new file mode 100644 index 00000000000..68b5754ef23 --- /dev/null +++ b/public/app/plugins/panel/canvas/InlineEditBody.tsx @@ -0,0 +1,99 @@ +import { get as lodashGet } from 'lodash'; +import React, { useMemo } from 'react'; +import { useObservable } from 'react-use'; + +import { PanelOptionsEditorBuilder, StandardEditorContext } from '@grafana/data'; +import { PanelOptionsSupplier } from '@grafana/data/src/panel/PanelPlugin'; +import { NestedValueAccess } from '@grafana/data/src/utils/OptionsUIBuilders'; +import { FrameState } from 'app/features/canvas/runtime/frame'; +import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; +import { fillOptionsPaneItems } from 'app/features/dashboard/components/PanelEditor/getVisualizationOptions'; +import { setOptionImmutably } from 'app/features/dashboard/components/PanelEditor/utils'; + +import { activePanelSubject, InstanceState } from './CanvasPanel'; +import { getElementEditor } from './editor/elementEditor'; +import { getLayerEditor } from './editor/layerEditor'; + +export const InlineEditBody = () => { + const activePanel = useObservable(activePanelSubject); + const instanceState = activePanel?.panel.context?.instanceState; + + const pane = useMemo(() => { + const state: InstanceState = instanceState; + if (!state) { + return new OptionsPaneCategoryDescriptor({ id: 'root', title: 'root' }); + } + + const supplier = (builder: PanelOptionsEditorBuilder, context: StandardEditorContext) => { + builder.addNestedOptions(getLayerEditor(instanceState)); + + const selection = state.selected; + if (selection?.length === 1) { + const element = selection[0]; + if (!(element instanceof FrameState)) { + builder.addNestedOptions( + getElementEditor({ + category: [`Selected element (${element.options.name})`], + element, + scene: state.scene, + }) + ); + } + } + }; + + return getOptionsPaneCategoryDescriptor({}, supplier); + }, [instanceState]); + + return ( +
+
{pane.items.map((v) => v.render())}
+
+ {pane.categories.map((c) => { + return ( +
+
{c.props.title}
+
{c.items.map((s) => s.render())}
+
+ ); + })} +
+
+ ); +}; + +// 🤮🤮🤮🤮 this oddly does not actually do anything, but structure is required. I'll try to clean it up... +function getOptionsPaneCategoryDescriptor( + props: any, + supplier: PanelOptionsSupplier +): OptionsPaneCategoryDescriptor { + const context: StandardEditorContext = { + data: props.input, + options: props.options, + }; + + const root = new OptionsPaneCategoryDescriptor({ id: 'root', title: 'root' }); + const getOptionsPaneCategory = (categoryNames?: string[]): OptionsPaneCategoryDescriptor => { + if (categoryNames?.length) { + const key = categoryNames[0]; + let sub = root.categories.find((v) => v.props.id === key); + if (!sub) { + sub = new OptionsPaneCategoryDescriptor({ id: key, title: key }); + root.categories.push(sub); + } + return sub; + } + return root; + }; + + const access: NestedValueAccess = { + getValue: (path: string) => lodashGet(props.options, path), + onChange: (path: string, value: any) => { + props.onChange(setOptionImmutably(props.options as any, path, value)); + }, + }; + + // Use the panel options loader + fillOptionsPaneItems(supplier, access, getOptionsPaneCategory, context); + return root; +} diff --git a/public/app/plugins/panel/canvas/globalStyles.ts b/public/app/plugins/panel/canvas/globalStyles.ts new file mode 100644 index 00000000000..a90f0db0619 --- /dev/null +++ b/public/app/plugins/panel/canvas/globalStyles.ts @@ -0,0 +1,11 @@ +import { css } from '@emotion/react'; + +import { GrafanaTheme2 } from '@grafana/data'; + +export function getGlobalStyles(theme: GrafanaTheme2) { + return css` + .moveable-control-box { + z-index: 999; + } + `; +} diff --git a/public/sass/components/_dashboard_grid.scss b/public/sass/components/_dashboard_grid.scss index a0c7dd87d4e..6da0f59b2a8 100644 --- a/public/sass/components/_dashboard_grid.scss +++ b/public/sass/components/_dashboard_grid.scss @@ -6,7 +6,8 @@ visibility: hidden; } -.react-grid-item { +.react-grid-item, +#grafana-portal-container { touch-action: initial !important; &:hover { diff --git a/yarn.lock b/yarn.lock index 9045b42791a..f75b4ac59a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10804,6 +10804,15 @@ __metadata: languageName: node linkType: hard +"@types/react-resizable@npm:^1.7.4": + version: 1.7.4 + resolution: "@types/react-resizable@npm:1.7.4" + dependencies: + "@types/react": "*" + checksum: d665bb2ddf830b9f841be21204cee119602b3d983537a94ccbad40deb7cd602e04742e3e013009bbb27c2d0fe72441b29ed48bc75f7e482cfb25eaf45f281dc9 + languageName: node + linkType: hard + "@types/react-router-dom@npm:5.3.3": version: 5.3.3 resolution: "@types/react-router-dom@npm:5.3.3" @@ -20953,6 +20962,7 @@ __metadata: "@types/react-highlight-words": 0.16.4 "@types/react-loadable": 5.5.6 "@types/react-redux": 7.1.23 + "@types/react-resizable": ^1.7.4 "@types/react-router-dom": 5.3.3 "@types/react-table": ^7 "@types/react-test-renderer": 17.0.1