From 84d13c3f35d9f7f19e04d26c804e74def6953331 Mon Sep 17 00:00:00 2001 From: Nathan Marrs Date: Thu, 28 Oct 2021 09:58:31 -0700 Subject: [PATCH] Canvas: add layer elements editor functionality (#40968) --- public/app/features/canvas/element.ts | 4 +- public/app/features/canvas/elements/icon.tsx | 24 +++++---- .../app/features/canvas/elements/notFound.tsx | 6 ++- .../app/features/canvas/elements/textBox.tsx | 20 +++++-- public/app/features/canvas/registry.ts | 3 +- .../app/features/canvas/runtime/element.tsx | 14 ++++- public/app/features/canvas/runtime/group.tsx | 34 ++++++++---- public/app/features/canvas/runtime/root.tsx | 7 +-- public/app/features/canvas/runtime/scene.tsx | 45 +++++++--------- .../app/plugins/panel/canvas/CanvasPanel.tsx | 6 ++- .../canvas/editor/LayerElementListEditor.tsx | 52 ++++++++++++------- .../panel/canvas/editor/elementEditor.tsx | 17 ++++-- .../panel/canvas/editor/layerEditor.tsx | 4 +- 13 files changed, 148 insertions(+), 88 deletions(-) diff --git a/public/app/features/canvas/element.ts b/public/app/features/canvas/element.ts index fa2bc8c65de..0faf45eac61 100644 --- a/public/app/features/canvas/element.ts +++ b/public/app/features/canvas/element.ts @@ -45,13 +45,13 @@ export interface CanvasElementItem extends RegistryI /** The default width/height to use when adding */ defaultSize?: Placement; - defaultConfig: TConfig; - prepareData?: (ctx: DimensionContext, cfg: TConfig) => TData; /** Component used to draw */ display: ComponentType>; + getNewOptions: (options?: CanvasElementOptions) => Omit, 'type'>; + /** Build the configuraiton UI */ registerOptionsUI?: PanelOptionsSupplier>; } diff --git a/public/app/features/canvas/elements/icon.tsx b/public/app/features/canvas/elements/icon.tsx index 8717e60675f..1c28ad37afc 100644 --- a/public/app/features/canvas/elements/icon.tsx +++ b/public/app/features/canvas/elements/icon.tsx @@ -64,18 +64,20 @@ export const iconItem: CanvasElementItem = { display: IconDisplay, - defaultConfig: { - path: { - mode: ResourceDimensionMode.Fixed, - fixed: 'img/icons/unicons/question-circle.svg', + getNewOptions: (options) => ({ + placement: { + width: 50, + height: 50, }, - fill: { fixed: '#FFF899' }, - }, - - defaultSize: { - width: 50, - height: 50, - }, + ...options, + config: { + path: { + mode: ResourceDimensionMode.Fixed, + fixed: 'img/icons/unicons/question-circle.svg', + }, + fill: { fixed: '#FFF899' }, + }, + }), // Called when data changes prepareData: (ctx: DimensionContext, cfg: IconConfig) => { diff --git a/public/app/features/canvas/elements/notFound.tsx b/public/app/features/canvas/elements/notFound.tsx index 83e78bf388e..41dab2ebb24 100644 --- a/public/app/features/canvas/elements/notFound.tsx +++ b/public/app/features/canvas/elements/notFound.tsx @@ -23,12 +23,14 @@ export const notFoundItem: CanvasElementItem = { name: 'Not found', description: 'Display when element type is not found in the registry', - defaultConfig: {}, - display: NotFoundDisplay, defaultSize: { width: 100, height: 100, }, + + getNewOptions: () => ({ + config: {}, + }), }; diff --git a/public/app/features/canvas/elements/textBox.tsx b/public/app/features/canvas/elements/textBox.tsx index f3cfa28da50..477cebca0e7 100644 --- a/public/app/features/canvas/elements/textBox.tsx +++ b/public/app/features/canvas/elements/textBox.tsx @@ -71,16 +71,24 @@ export const textBoxItem: CanvasElementItem = { display: TextBoxDisplay, - defaultConfig: { - align: Align.Left, - valign: VAlign.Middle, - }, - defaultSize: { width: 240, height: 160, }, + getNewOptions: (options) => ({ + background: { + color: { + fixed: 'grey', + }, + }, + ...options, + config: { + align: Align.Left, + valign: VAlign.Middle, + }, + }), + // Called when data changes prepareData: (ctx: DimensionContext, cfg: TextBoxConfig) => { const data: TextBoxData = { @@ -89,9 +97,11 @@ export const textBoxItem: CanvasElementItem = { valign: cfg.valign ?? VAlign.Middle, size: cfg.size, }; + if (cfg.color) { data.color = ctx.getColor(cfg.color).value(); } + return data; }, diff --git a/public/app/features/canvas/registry.ts b/public/app/features/canvas/registry.ts index 8046aac9046..6791f7a8682 100644 --- a/public/app/features/canvas/registry.ts +++ b/public/app/features/canvas/registry.ts @@ -5,8 +5,7 @@ import { textBoxItem } from './elements/textBox'; export const DEFAULT_CANVAS_ELEMENT_CONFIG: CanvasElementOptions = { type: iconItem.id, - config: { ...iconItem.defaultConfig }, - placement: { ...iconItem.defaultSize }, + ...iconItem.getNewOptions(), }; export const canvasElementRegistry = new Registry(() => [ diff --git a/public/app/features/canvas/runtime/element.tsx b/public/app/features/canvas/runtime/element.tsx index 2b367461356..ff33b093a10 100644 --- a/public/app/features/canvas/runtime/element.tsx +++ b/public/app/features/canvas/runtime/element.tsx @@ -186,6 +186,10 @@ export class ElementState { this.options = { ...options }; let trav = this.parent; while (trav) { + if (trav.isRoot()) { + trav.scene.save(); + break; + } trav.revId++; trav = trav.parent; } @@ -291,8 +295,14 @@ export class ElementState { render() { const { item } = this; return ( -
- +
+
); } diff --git a/public/app/features/canvas/runtime/group.tsx b/public/app/features/canvas/runtime/group.tsx index e7ff5c7d738..b3eafae7a6c 100644 --- a/public/app/features/canvas/runtime/group.tsx +++ b/public/app/features/canvas/runtime/group.tsx @@ -6,13 +6,17 @@ import { ElementState } from './element'; import { CanvasElementItem } from '../element'; import { LayerActionID } from 'app/plugins/panel/canvas/types'; import { cloneDeep } from 'lodash'; +import { Scene } from './scene'; +import { RootElement } from './root'; export const groupItemDummy: CanvasElementItem = { id: 'group', name: 'Group', description: 'Group', - defaultConfig: {}, + getNewOptions: () => ({ + config: {}, + }), // eslint-disable-next-line react/display-name display: () => { @@ -22,10 +26,13 @@ export const groupItemDummy: CanvasElementItem = { export class GroupState extends ElementState { elements: ElementState[] = []; + scene: Scene; - constructor(public options: CanvasGroupOptions, public parent?: GroupState) { + constructor(public options: CanvasGroupOptions, scene: Scene, public parent?: GroupState) { super(groupItemDummy, options, parent); + this.scene = scene; + // mutate options object let { elements } = this.options; if (!elements) { @@ -34,7 +41,7 @@ export class GroupState extends ElementState { for (const c of elements) { if (c.type === 'group') { - this.elements.push(new GroupState(c as CanvasGroupOptions, this)); + this.elements.push(new GroupState(c as CanvasGroupOptions, scene, this)); } else { const item = canvasElementRegistry.getIfExists(c.type) ?? notFoundItem; this.elements.push(new ElementState(item, c, this)); @@ -42,7 +49,7 @@ export class GroupState extends ElementState { } } - isRoot() { + isRoot(): this is RootElement { return false; } @@ -80,7 +87,14 @@ export class GroupState extends ElementState { const [removed] = result.splice(startIndex, 1); result.splice(endIndex, 0, removed); this.elements = result; - this.onChange(this.getSaveModel()); + + this.reinitializeMoveable(); + } + + reinitializeMoveable() { + // Need to first clear current selection and then re-init moveable with slight delay + this.scene.clearCurrentSelection(); + setTimeout(() => this.scene.initMoveable(true), 100); } // ??? or should this be on the element directly? @@ -89,6 +103,8 @@ export class GroupState extends ElementState { switch (action) { case LayerActionID.Delete: this.elements = this.elements.filter((e) => e !== element); + this.scene.save(); + this.reinitializeMoveable(); break; case LayerActionID.Duplicate: if (element.item.id === 'group') { @@ -108,18 +124,18 @@ export class GroupState extends ElementState { if (element.anchor.right) { opts.placement!.right! += 10; } - console.log('DUPLICATE', opts); + const copy = new ElementState(element.item, opts, this); copy.updateSize(element.width, element.height); - copy.updateData(element.data); // :bomb: <-- need some way to tell the scene to re-init size and data + copy.updateData(this.scene.context); this.elements.push(copy); + this.scene.save(); + this.reinitializeMoveable(); break; default: console.log('DO action', action, element); return; } - - this.onChange(this.getSaveModel()); }; render() { diff --git a/public/app/features/canvas/runtime/root.tsx b/public/app/features/canvas/runtime/root.tsx index bac61ccd77d..25b45a8bba5 100644 --- a/public/app/features/canvas/runtime/root.tsx +++ b/public/app/features/canvas/runtime/root.tsx @@ -1,12 +1,13 @@ import { CanvasGroupOptions, CanvasElementOptions } from 'app/features/canvas'; import { GroupState } from './group'; +import { Scene } from './scene'; export class RootElement extends GroupState { - constructor(public options: CanvasGroupOptions, private changeCallback: () => void) { - super(options); + constructor(public options: CanvasGroupOptions, public scene: Scene, private changeCallback: () => void) { + super(options, scene); } - isRoot() { + isRoot(): this is RootElement { return true; } diff --git a/public/app/features/canvas/runtime/scene.tsx b/public/app/features/canvas/runtime/scene.tsx index aa151f85a5f..df7b33cb8b3 100644 --- a/public/app/features/canvas/runtime/scene.tsx +++ b/public/app/features/canvas/runtime/scene.tsx @@ -7,13 +7,7 @@ import Selecto from 'selecto'; import { config } from 'app/core/config'; import { GrafanaTheme2, PanelData } from '@grafana/data'; import { stylesFactory } from '@grafana/ui'; -import { - Anchor, - CanvasElementOptions, - CanvasGroupOptions, - DEFAULT_CANVAS_ELEMENT_CONFIG, - Placement, -} from 'app/features/canvas'; +import { Anchor, CanvasGroupOptions, DEFAULT_CANVAS_ELEMENT_CONFIG, Placement } from 'app/features/canvas'; import { ColorDimensionConfig, ResourceDimensionConfig, @@ -31,7 +25,6 @@ import { ElementState } from './element'; import { RootElement } from './root'; export class Scene { - private lookup = new Map(); styles = getStyles(config.theme2); readonly selection = new ReplaySubject(1); readonly moved = new Subject(); // called after resize/drag for editor updates @@ -56,15 +49,10 @@ export class Scene { type: 'group', elements: [DEFAULT_CANVAS_ELEMENT_CONFIG], }, + this, this.save // callback when changes are made ); - // Build the scene registry - this.lookup.clear(); - this.root.visit((v) => { - this.lookup.set(v.UID, v); - }); - setTimeout(() => { if (this.div && enableEditing) { this.initMoveable(); @@ -92,20 +80,13 @@ export class Scene { this.root.updateSize(width, height); if (this.selecto?.getSelectedTargets().length) { - let event: MouseEvent = new MouseEvent('click'); - this.selecto.clickTarget(event, this.div); + this.clearCurrentSelection(); } } - onChange(uid: number, cfg: CanvasElementOptions) { - const elem = this.lookup.get(uid); - if (!elem) { - throw new Error('element not found: ' + uid + ' // ' + [...this.lookup.keys()]); - } - this.revId++; - elem.onChange(cfg); - elem.updateData(this.context); // Refresh any data that may have changed - this.save(); + clearCurrentSelection() { + let event: MouseEvent = new MouseEvent('click'); + this.selecto?.clickTarget(event, this.div); } toggleAnchor(element: ElementState, k: keyof Anchor) { @@ -157,12 +138,16 @@ export class Scene { this.div = sceneContainer; }; - initMoveable = () => { + initMoveable = (destroySelecto = false) => { const targetElements: HTMLDivElement[] = []; this.root.elements.forEach((element: ElementState) => { targetElements.push(element.div!); }); + if (destroySelecto) { + this.selecto?.destroy(); + } + this.selecto = new Selecto({ container: this.div, selectableTargets: targetElements, @@ -188,6 +173,14 @@ export class Scene { }); this.moved.next(Date.now()); // TODO only on end }) + .on('dragEnd', (event) => { + const targetedElement = this.findElementByTarget(event.target); + + if (targetedElement && targetedElement.parent) { + const parent = targetedElement.parent; + targetedElement.updateSize(parent.width, parent.height); + } + }) .on('resize', (event) => { const targetedElement = this.findElementByTarget(event.target); targetedElement!.applyResize(event); diff --git a/public/app/plugins/panel/canvas/CanvasPanel.tsx b/public/app/plugins/panel/canvas/CanvasPanel.tsx index 330019f0d8f..05072c55a3c 100644 --- a/public/app/plugins/panel/canvas/CanvasPanel.tsx +++ b/public/app/plugins/panel/canvas/CanvasPanel.tsx @@ -96,7 +96,7 @@ export class CanvasPanel extends Component { // console.log('send changes', root); }; - shouldComponentUpdate(nextProps: Props) { + shouldComponentUpdate(nextProps: Props, nextState: State) { const { width, height, data } = this.props; let changed = false; @@ -109,6 +109,10 @@ export class CanvasPanel extends Component { changed = true; } + if (this.state.refresh !== nextState.refresh) { + 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) || diff --git a/public/app/plugins/panel/canvas/editor/LayerElementListEditor.tsx b/public/app/plugins/panel/canvas/editor/LayerElementListEditor.tsx index c71025e0397..1e4402c2109 100644 --- a/public/app/plugins/panel/canvas/editor/LayerElementListEditor.tsx +++ b/public/app/plugins/panel/canvas/editor/LayerElementListEditor.tsx @@ -8,8 +8,10 @@ import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautif import { PanelOptions } from '../models.gen'; import { InstanceState } from '../CanvasPanel'; import { LayerActionID } from '../types'; -import { canvasElementRegistry } from 'app/features/canvas'; +import { CanvasElementOptions, canvasElementRegistry } from 'app/features/canvas'; import appEvents from 'app/core/app_events'; +import { ElementState } from 'app/features/canvas/runtime/element'; +import { notFoundItem } from 'app/features/canvas/elements/notFound'; type Props = StandardEditorProps; @@ -17,20 +19,22 @@ export class LayerElementListEditor extends PureComponent { style = getLayerDragStyles(config.theme); onAddItem = (sel: SelectableValue) => { - // const reg = drawItemsRegistry.getIfExists(sel.value); - // if (!reg) { - // console.error('NOT FOUND', sel); - // return; - // } - // const layer = this.props.value; - // const item = newItem(reg, layer.items.length); - // const isList = this.props.context.options?.mode === LayoutMode.List; - // const items = isList ? [item, ...layer.items] : [...layer.items, item]; - // this.props.onChange({ - // ...layer, - // items, - // }); - // this.onSelect(item); + const { settings } = this.props.item; + if (!settings?.layer) { + return; + } + const { layer } = settings; + + const item = canvasElementRegistry.getIfExists(sel.value) ?? notFoundItem; + const newElementOptions = item.getNewOptions() as CanvasElementOptions; + newElementOptions.type = item.id; + const newElement = new ElementState(item, newElementOptions, layer); + newElement.updateSize(newElement.width, newElement.height); + newElement.updateData(layer.scene.context); + layer.elements.push(newElement); + layer.scene.save(); + + layer.reinitializeMoveable(); }; onSelect = (item: any) => { @@ -45,6 +49,18 @@ export class LayerElementListEditor extends PureComponent { } }; + onClearSelection = () => { + const { settings } = this.props.item; + + if (!settings?.layer) { + return; + } + + const { layer } = settings; + + layer.scene.clearCurrentSelection(); + }; + getRowStyle = (sel: boolean) => { return sel ? `${this.style.row} ${this.style.sel}` : this.style.row; }; @@ -108,7 +124,7 @@ export class LayerElementListEditor extends PureComponent { layer.doAction(LayerActionID.Duplicate, element)} surface="header" @@ -116,7 +132,7 @@ export class LayerElementListEditor extends PureComponent { layer.doAction(LayerActionID.Delete, element)} surface="header" @@ -152,7 +168,7 @@ export class LayerElementListEditor extends PureComponent { isFullWidth={false} /> {selection.length > 0 && ( - )} diff --git a/public/app/plugins/panel/canvas/editor/elementEditor.tsx b/public/app/plugins/panel/canvas/editor/elementEditor.tsx index 9ba38ec3167..b9795cb74e4 100644 --- a/public/app/plugins/panel/canvas/editor/elementEditor.tsx +++ b/public/app/plugins/panel/canvas/editor/elementEditor.tsx @@ -1,4 +1,4 @@ -import { cloneDeep, get as lodashGet } from 'lodash'; +import { get as lodashGet } from 'lodash'; import { optionBuilder } from './options'; import { CanvasElementOptions, canvasElementRegistry, DEFAULT_CANVAS_ELEMENT_CONFIG } from 'app/features/canvas'; import { NestedPanelOptions, NestedValueAccess } from '@grafana/data/src/utils/OptionsUIBuilders'; @@ -32,14 +32,15 @@ export function getElementEditor(opts: CanvasEditorOptions): NestedPanelOptions< return; } options = { - ...options, // keep current options + ...options, + ...layer.getNewOptions(options), type: layer.id, - config: cloneDeep(layer.defaultConfig ?? {}), }; } else { options = setOptionImmutably(options, path, value); } - opts.scene.onChange(opts.element.UID, options); + opts.element.onChange(options); + opts.element.updateData(opts.scene.context); }, }), @@ -64,7 +65,13 @@ export function getElementEditor(opts: CanvasEditorOptions): NestedPanelOptions< // force clean layer configuration const layer = canvasElementRegistry.getIfExists(options?.type ?? DEFAULT_CANVAS_ELEMENT_CONFIG.type)!; - const currentOptions = { ...options, type: layer.id, config: { ...layer.defaultConfig, ...options?.config } }; + let currentOptions = options; + if (!currentOptions) { + currentOptions = { + ...layer.getNewOptions(options), + type: layer.id, + }; + } const ctx = { ...context, options: currentOptions }; if (layer.registerOptionsUI) { diff --git a/public/app/plugins/panel/canvas/editor/layerEditor.tsx b/public/app/plugins/panel/canvas/editor/layerEditor.tsx index 0b311369288..2a2255e4703 100644 --- a/public/app/plugins/panel/canvas/editor/layerEditor.tsx +++ b/public/app/plugins/panel/canvas/editor/layerEditor.tsx @@ -6,7 +6,7 @@ import { InstanceState } from '../CanvasPanel'; import { LayerElementListEditor } from './LayerElementListEditor'; export function getLayerEditor(opts: InstanceState): NestedPanelOptions { - const { layer, scene } = opts; + const { layer } = opts; const options = layer.options || { elements: [] }; return { @@ -24,7 +24,7 @@ export function getLayerEditor(opts: InstanceState): NestedPanelOptions