Canvas: add layer elements editor functionality (#40968)

This commit is contained in:
Nathan Marrs
2021-10-28 09:58:31 -07:00
committed by GitHub
parent 97e0e12f40
commit 84d13c3f35
13 changed files with 148 additions and 88 deletions
+2 -2
View File
@@ -45,13 +45,13 @@ export interface CanvasElementItem<TConfig = any, TData = any> extends RegistryI
/** The default width/height to use when adding */ /** The default width/height to use when adding */
defaultSize?: Placement; defaultSize?: Placement;
defaultConfig: TConfig;
prepareData?: (ctx: DimensionContext, cfg: TConfig) => TData; prepareData?: (ctx: DimensionContext, cfg: TConfig) => TData;
/** Component used to draw */ /** Component used to draw */
display: ComponentType<CanvasElementProps<TConfig, TData>>; display: ComponentType<CanvasElementProps<TConfig, TData>>;
getNewOptions: (options?: CanvasElementOptions) => Omit<CanvasElementOptions<TConfig>, 'type'>;
/** Build the configuraiton UI */ /** Build the configuraiton UI */
registerOptionsUI?: PanelOptionsSupplier<CanvasElementOptions<TConfig>>; registerOptionsUI?: PanelOptionsSupplier<CanvasElementOptions<TConfig>>;
} }
+13 -11
View File
@@ -64,18 +64,20 @@ export const iconItem: CanvasElementItem<IconConfig, IconData> = {
display: IconDisplay, display: IconDisplay,
defaultConfig: { getNewOptions: (options) => ({
path: { placement: {
mode: ResourceDimensionMode.Fixed, width: 50,
fixed: 'img/icons/unicons/question-circle.svg', height: 50,
}, },
fill: { fixed: '#FFF899' }, ...options,
}, config: {
path: {
defaultSize: { mode: ResourceDimensionMode.Fixed,
width: 50, fixed: 'img/icons/unicons/question-circle.svg',
height: 50, },
}, fill: { fixed: '#FFF899' },
},
}),
// Called when data changes // Called when data changes
prepareData: (ctx: DimensionContext, cfg: IconConfig) => { prepareData: (ctx: DimensionContext, cfg: IconConfig) => {
@@ -23,12 +23,14 @@ export const notFoundItem: CanvasElementItem<NotFoundConfig> = {
name: 'Not found', name: 'Not found',
description: 'Display when element type is not found in the registry', description: 'Display when element type is not found in the registry',
defaultConfig: {},
display: NotFoundDisplay, display: NotFoundDisplay,
defaultSize: { defaultSize: {
width: 100, width: 100,
height: 100, height: 100,
}, },
getNewOptions: () => ({
config: {},
}),
}; };
@@ -71,16 +71,24 @@ export const textBoxItem: CanvasElementItem<TextBoxConfig, TextBoxData> = {
display: TextBoxDisplay, display: TextBoxDisplay,
defaultConfig: {
align: Align.Left,
valign: VAlign.Middle,
},
defaultSize: { defaultSize: {
width: 240, width: 240,
height: 160, height: 160,
}, },
getNewOptions: (options) => ({
background: {
color: {
fixed: 'grey',
},
},
...options,
config: {
align: Align.Left,
valign: VAlign.Middle,
},
}),
// Called when data changes // Called when data changes
prepareData: (ctx: DimensionContext, cfg: TextBoxConfig) => { prepareData: (ctx: DimensionContext, cfg: TextBoxConfig) => {
const data: TextBoxData = { const data: TextBoxData = {
@@ -89,9 +97,11 @@ export const textBoxItem: CanvasElementItem<TextBoxConfig, TextBoxData> = {
valign: cfg.valign ?? VAlign.Middle, valign: cfg.valign ?? VAlign.Middle,
size: cfg.size, size: cfg.size,
}; };
if (cfg.color) { if (cfg.color) {
data.color = ctx.getColor(cfg.color).value(); data.color = ctx.getColor(cfg.color).value();
} }
return data; return data;
}, },
+1 -2
View File
@@ -5,8 +5,7 @@ import { textBoxItem } from './elements/textBox';
export const DEFAULT_CANVAS_ELEMENT_CONFIG: CanvasElementOptions = { export const DEFAULT_CANVAS_ELEMENT_CONFIG: CanvasElementOptions = {
type: iconItem.id, type: iconItem.id,
config: { ...iconItem.defaultConfig }, ...iconItem.getNewOptions(),
placement: { ...iconItem.defaultSize },
}; };
export const canvasElementRegistry = new Registry<CanvasElementItem>(() => [ export const canvasElementRegistry = new Registry<CanvasElementItem>(() => [
+12 -2
View File
@@ -186,6 +186,10 @@ export class ElementState {
this.options = { ...options }; this.options = { ...options };
let trav = this.parent; let trav = this.parent;
while (trav) { while (trav) {
if (trav.isRoot()) {
trav.scene.save();
break;
}
trav.revId++; trav.revId++;
trav = trav.parent; trav = trav.parent;
} }
@@ -291,8 +295,14 @@ export class ElementState {
render() { render() {
const { item } = this; const { item } = this;
return ( return (
<div key={`${this.UID}/${this.revId}`} style={{ ...this.sizeStyle, ...this.dataStyle }} ref={this.initElement}> <div key={`${this.UID}`} style={{ ...this.sizeStyle, ...this.dataStyle }} ref={this.initElement}>
<item.display config={this.options.config} width={this.width} height={this.height} data={this.data} /> <item.display
key={`${this.UID}/${this.revId}`}
config={this.options.config}
width={this.width}
height={this.height}
data={this.data}
/>
</div> </div>
); );
} }
+25 -9
View File
@@ -6,13 +6,17 @@ import { ElementState } from './element';
import { CanvasElementItem } from '../element'; import { CanvasElementItem } from '../element';
import { LayerActionID } from 'app/plugins/panel/canvas/types'; import { LayerActionID } from 'app/plugins/panel/canvas/types';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { Scene } from './scene';
import { RootElement } from './root';
export const groupItemDummy: CanvasElementItem = { export const groupItemDummy: CanvasElementItem = {
id: 'group', id: 'group',
name: 'Group', name: 'Group',
description: 'Group', description: 'Group',
defaultConfig: {}, getNewOptions: () => ({
config: {},
}),
// eslint-disable-next-line react/display-name // eslint-disable-next-line react/display-name
display: () => { display: () => {
@@ -22,10 +26,13 @@ export const groupItemDummy: CanvasElementItem = {
export class GroupState extends ElementState { export class GroupState extends ElementState {
elements: 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); super(groupItemDummy, options, parent);
this.scene = scene;
// mutate options object // mutate options object
let { elements } = this.options; let { elements } = this.options;
if (!elements) { if (!elements) {
@@ -34,7 +41,7 @@ export class GroupState extends ElementState {
for (const c of elements) { for (const c of elements) {
if (c.type === 'group') { if (c.type === 'group') {
this.elements.push(new GroupState(c as CanvasGroupOptions, this)); this.elements.push(new GroupState(c as CanvasGroupOptions, scene, this));
} else { } else {
const item = canvasElementRegistry.getIfExists(c.type) ?? notFoundItem; const item = canvasElementRegistry.getIfExists(c.type) ?? notFoundItem;
this.elements.push(new ElementState(item, c, this)); this.elements.push(new ElementState(item, c, this));
@@ -42,7 +49,7 @@ export class GroupState extends ElementState {
} }
} }
isRoot() { isRoot(): this is RootElement {
return false; return false;
} }
@@ -80,7 +87,14 @@ export class GroupState extends ElementState {
const [removed] = result.splice(startIndex, 1); const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed); result.splice(endIndex, 0, removed);
this.elements = result; 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? // ??? or should this be on the element directly?
@@ -89,6 +103,8 @@ export class GroupState extends ElementState {
switch (action) { switch (action) {
case LayerActionID.Delete: case LayerActionID.Delete:
this.elements = this.elements.filter((e) => e !== element); this.elements = this.elements.filter((e) => e !== element);
this.scene.save();
this.reinitializeMoveable();
break; break;
case LayerActionID.Duplicate: case LayerActionID.Duplicate:
if (element.item.id === 'group') { if (element.item.id === 'group') {
@@ -108,18 +124,18 @@ export class GroupState extends ElementState {
if (element.anchor.right) { if (element.anchor.right) {
opts.placement!.right! += 10; opts.placement!.right! += 10;
} }
console.log('DUPLICATE', opts);
const copy = new ElementState(element.item, opts, this); const copy = new ElementState(element.item, opts, this);
copy.updateSize(element.width, element.height); 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.elements.push(copy);
this.scene.save();
this.reinitializeMoveable();
break; break;
default: default:
console.log('DO action', action, element); console.log('DO action', action, element);
return; return;
} }
this.onChange(this.getSaveModel());
}; };
render() { render() {
+4 -3
View File
@@ -1,12 +1,13 @@
import { CanvasGroupOptions, CanvasElementOptions } from 'app/features/canvas'; import { CanvasGroupOptions, CanvasElementOptions } from 'app/features/canvas';
import { GroupState } from './group'; import { GroupState } from './group';
import { Scene } from './scene';
export class RootElement extends GroupState { export class RootElement extends GroupState {
constructor(public options: CanvasGroupOptions, private changeCallback: () => void) { constructor(public options: CanvasGroupOptions, public scene: Scene, private changeCallback: () => void) {
super(options); super(options, scene);
} }
isRoot() { isRoot(): this is RootElement {
return true; return true;
} }
+19 -26
View File
@@ -7,13 +7,7 @@ import Selecto from 'selecto';
import { config } from 'app/core/config'; import { config } from 'app/core/config';
import { GrafanaTheme2, PanelData } from '@grafana/data'; import { GrafanaTheme2, PanelData } from '@grafana/data';
import { stylesFactory } from '@grafana/ui'; import { stylesFactory } from '@grafana/ui';
import { import { Anchor, CanvasGroupOptions, DEFAULT_CANVAS_ELEMENT_CONFIG, Placement } from 'app/features/canvas';
Anchor,
CanvasElementOptions,
CanvasGroupOptions,
DEFAULT_CANVAS_ELEMENT_CONFIG,
Placement,
} from 'app/features/canvas';
import { import {
ColorDimensionConfig, ColorDimensionConfig,
ResourceDimensionConfig, ResourceDimensionConfig,
@@ -31,7 +25,6 @@ import { ElementState } from './element';
import { RootElement } from './root'; import { RootElement } from './root';
export class Scene { export class Scene {
private lookup = new Map<number, ElementState>();
styles = getStyles(config.theme2); styles = getStyles(config.theme2);
readonly selection = new ReplaySubject<ElementState[]>(1); readonly selection = new ReplaySubject<ElementState[]>(1);
readonly moved = new Subject<number>(); // called after resize/drag for editor updates readonly moved = new Subject<number>(); // called after resize/drag for editor updates
@@ -56,15 +49,10 @@ export class Scene {
type: 'group', type: 'group',
elements: [DEFAULT_CANVAS_ELEMENT_CONFIG], elements: [DEFAULT_CANVAS_ELEMENT_CONFIG],
}, },
this,
this.save // callback when changes are made 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(() => { setTimeout(() => {
if (this.div && enableEditing) { if (this.div && enableEditing) {
this.initMoveable(); this.initMoveable();
@@ -92,20 +80,13 @@ export class Scene {
this.root.updateSize(width, height); this.root.updateSize(width, height);
if (this.selecto?.getSelectedTargets().length) { if (this.selecto?.getSelectedTargets().length) {
let event: MouseEvent = new MouseEvent('click'); this.clearCurrentSelection();
this.selecto.clickTarget(event, this.div);
} }
} }
onChange(uid: number, cfg: CanvasElementOptions) { clearCurrentSelection() {
const elem = this.lookup.get(uid); let event: MouseEvent = new MouseEvent('click');
if (!elem) { this.selecto?.clickTarget(event, this.div);
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();
} }
toggleAnchor(element: ElementState, k: keyof Anchor) { toggleAnchor(element: ElementState, k: keyof Anchor) {
@@ -157,12 +138,16 @@ export class Scene {
this.div = sceneContainer; this.div = sceneContainer;
}; };
initMoveable = () => { initMoveable = (destroySelecto = false) => {
const targetElements: HTMLDivElement[] = []; const targetElements: HTMLDivElement[] = [];
this.root.elements.forEach((element: ElementState) => { this.root.elements.forEach((element: ElementState) => {
targetElements.push(element.div!); targetElements.push(element.div!);
}); });
if (destroySelecto) {
this.selecto?.destroy();
}
this.selecto = new Selecto({ this.selecto = new Selecto({
container: this.div, container: this.div,
selectableTargets: targetElements, selectableTargets: targetElements,
@@ -188,6 +173,14 @@ export class Scene {
}); });
this.moved.next(Date.now()); // TODO only on end 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) => { .on('resize', (event) => {
const targetedElement = this.findElementByTarget(event.target); const targetedElement = this.findElementByTarget(event.target);
targetedElement!.applyResize(event); targetedElement!.applyResize(event);
@@ -96,7 +96,7 @@ export class CanvasPanel extends Component<Props, State> {
// console.log('send changes', root); // console.log('send changes', root);
}; };
shouldComponentUpdate(nextProps: Props) { shouldComponentUpdate(nextProps: Props, nextState: State) {
const { width, height, data } = this.props; const { width, height, data } = this.props;
let changed = false; let changed = false;
@@ -109,6 +109,10 @@ export class CanvasPanel extends Component<Props, State> {
changed = true; 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 // After editing, the options are valid, but the scene was in a different panel or inline editing mode has changed
const shouldUpdateSceneAndPanel = const shouldUpdateSceneAndPanel =
(this.needsReload && this.props.options !== nextProps.options) || (this.needsReload && this.props.options !== nextProps.options) ||
@@ -8,8 +8,10 @@ import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautif
import { PanelOptions } from '../models.gen'; import { PanelOptions } from '../models.gen';
import { InstanceState } from '../CanvasPanel'; import { InstanceState } from '../CanvasPanel';
import { LayerActionID } from '../types'; 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 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<any, InstanceState, PanelOptions>; type Props = StandardEditorProps<any, InstanceState, PanelOptions>;
@@ -17,20 +19,22 @@ export class LayerElementListEditor extends PureComponent<Props> {
style = getLayerDragStyles(config.theme); style = getLayerDragStyles(config.theme);
onAddItem = (sel: SelectableValue<string>) => { onAddItem = (sel: SelectableValue<string>) => {
// const reg = drawItemsRegistry.getIfExists(sel.value); const { settings } = this.props.item;
// if (!reg) { if (!settings?.layer) {
// console.error('NOT FOUND', sel); return;
// return; }
// } const { layer } = settings;
// const layer = this.props.value;
// const item = newItem(reg, layer.items.length); const item = canvasElementRegistry.getIfExists(sel.value) ?? notFoundItem;
// const isList = this.props.context.options?.mode === LayoutMode.List; const newElementOptions = item.getNewOptions() as CanvasElementOptions;
// const items = isList ? [item, ...layer.items] : [...layer.items, item]; newElementOptions.type = item.id;
// this.props.onChange({ const newElement = new ElementState(item, newElementOptions, layer);
// ...layer, newElement.updateSize(newElement.width, newElement.height);
// items, newElement.updateData(layer.scene.context);
// }); layer.elements.push(newElement);
// this.onSelect(item); layer.scene.save();
layer.reinitializeMoveable();
}; };
onSelect = (item: any) => { onSelect = (item: any) => {
@@ -45,6 +49,18 @@ export class LayerElementListEditor extends PureComponent<Props> {
} }
}; };
onClearSelection = () => {
const { settings } = this.props.item;
if (!settings?.layer) {
return;
}
const { layer } = settings;
layer.scene.clearCurrentSelection();
};
getRowStyle = (sel: boolean) => { getRowStyle = (sel: boolean) => {
return sel ? `${this.style.row} ${this.style.sel}` : this.style.row; return sel ? `${this.style.row} ${this.style.sel}` : this.style.row;
}; };
@@ -108,7 +124,7 @@ export class LayerElementListEditor extends PureComponent<Props> {
<IconButton <IconButton
name="copy" name="copy"
title={'duplicate'} title={'Duplicate'}
className={styles.actionIcon} className={styles.actionIcon}
onClick={() => layer.doAction(LayerActionID.Duplicate, element)} onClick={() => layer.doAction(LayerActionID.Duplicate, element)}
surface="header" surface="header"
@@ -116,7 +132,7 @@ export class LayerElementListEditor extends PureComponent<Props> {
<IconButton <IconButton
name="trash-alt" name="trash-alt"
title={'remove'} title={'Remove'}
className={cx(styles.actionIcon, styles.dragIcon)} className={cx(styles.actionIcon, styles.dragIcon)}
onClick={() => layer.doAction(LayerActionID.Delete, element)} onClick={() => layer.doAction(LayerActionID.Delete, element)}
surface="header" surface="header"
@@ -152,7 +168,7 @@ export class LayerElementListEditor extends PureComponent<Props> {
isFullWidth={false} isFullWidth={false}
/> />
{selection.length > 0 && ( {selection.length > 0 && (
<Button size="sm" variant="secondary" onClick={() => console.log('TODO!')}> <Button size="sm" variant="secondary" onClick={this.onClearSelection}>
Clear Selection Clear Selection
</Button> </Button>
)} )}
@@ -1,4 +1,4 @@
import { cloneDeep, get as lodashGet } from 'lodash'; import { get as lodashGet } from 'lodash';
import { optionBuilder } from './options'; import { optionBuilder } from './options';
import { CanvasElementOptions, canvasElementRegistry, DEFAULT_CANVAS_ELEMENT_CONFIG } from 'app/features/canvas'; import { CanvasElementOptions, canvasElementRegistry, DEFAULT_CANVAS_ELEMENT_CONFIG } from 'app/features/canvas';
import { NestedPanelOptions, NestedValueAccess } from '@grafana/data/src/utils/OptionsUIBuilders'; import { NestedPanelOptions, NestedValueAccess } from '@grafana/data/src/utils/OptionsUIBuilders';
@@ -32,14 +32,15 @@ export function getElementEditor(opts: CanvasEditorOptions): NestedPanelOptions<
return; return;
} }
options = { options = {
...options, // keep current options ...options,
...layer.getNewOptions(options),
type: layer.id, type: layer.id,
config: cloneDeep(layer.defaultConfig ?? {}),
}; };
} else { } else {
options = setOptionImmutably(options, path, value); 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 // force clean layer configuration
const layer = canvasElementRegistry.getIfExists(options?.type ?? DEFAULT_CANVAS_ELEMENT_CONFIG.type)!; 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 }; const ctx = { ...context, options: currentOptions };
if (layer.registerOptionsUI) { if (layer.registerOptionsUI) {
@@ -6,7 +6,7 @@ import { InstanceState } from '../CanvasPanel';
import { LayerElementListEditor } from './LayerElementListEditor'; import { LayerElementListEditor } from './LayerElementListEditor';
export function getLayerEditor(opts: InstanceState): NestedPanelOptions<InstanceState> { export function getLayerEditor(opts: InstanceState): NestedPanelOptions<InstanceState> {
const { layer, scene } = opts; const { layer } = opts;
const options = layer.options || { elements: [] }; const options = layer.options || { elements: [] };
return { return {
@@ -24,7 +24,7 @@ export function getLayerEditor(opts: InstanceState): NestedPanelOptions<Instance
return; return;
} }
const c = setOptionImmutably(options, path, value); const c = setOptionImmutably(options, path, value);
scene.onChange(layer.UID, c); layer.onChange(c);
}, },
}), }),