mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Canvas: Add metric value element type (#55205)
This commit is contained in:
parent
3ab4d543d4
commit
ff7225745a
@ -6,7 +6,7 @@ import { Select } from '../Select/Select';
|
|||||||
|
|
||||||
import { useFieldDisplayNames, useSelectOptions, frameHasName } from './utils';
|
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<StandardEditorProps<string, FieldNamePickerConfigSettings>> = ({
|
export const FieldNamePicker: React.FC<StandardEditorProps<string, FieldNamePickerConfigSettings>> = ({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
@ -34,6 +34,9 @@ export interface CanvasElementProps<TConfig = any, TData = any> {
|
|||||||
|
|
||||||
// Raw data
|
// Raw data
|
||||||
data?: TData;
|
data?: TData;
|
||||||
|
|
||||||
|
// If the element is currently selected
|
||||||
|
isSelected?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -54,6 +57,9 @@ export interface CanvasElementItem<TConfig = any, TData = any> extends RegistryI
|
|||||||
|
|
||||||
/** Build the configuration UI */
|
/** Build the configuration UI */
|
||||||
registerOptionsUI?: PanelOptionsSupplier<CanvasElementOptions<TConfig>>;
|
registerOptionsUI?: PanelOptionsSupplier<CanvasElementOptions<TConfig>>;
|
||||||
|
|
||||||
|
/** If item has an edit mode */
|
||||||
|
hasEditMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultBgColor = '#D9D9D9';
|
export const defaultBgColor = '#D9D9D9';
|
||||||
|
220
public/app/features/canvas/elements/metricValue.tsx
Normal file
220
public/app/features/canvas/elements/metricValue.tsx
Normal file
@ -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<string, FieldNamePickerConfigSettings> = {
|
||||||
|
settings: {},
|
||||||
|
} as StandardEditorsRegistryItem<string, FieldNamePickerConfigSettings>;
|
||||||
|
|
||||||
|
const MetricValueDisplay = (props: CanvasElementProps<TextBoxConfig, TextBoxData>) => {
|
||||||
|
const { data, isSelected } = props;
|
||||||
|
const styles = useStyles2(getStyles(data));
|
||||||
|
|
||||||
|
const context = usePanelContext();
|
||||||
|
const scene = context.instanceState?.scene;
|
||||||
|
|
||||||
|
const isEditMode = useObservable<boolean>(scene?.editModeEnabled ?? of(false));
|
||||||
|
|
||||||
|
if (isEditMode && isSelected) {
|
||||||
|
return <MetricValueEdit {...props} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<span className={styles.span}>{data?.text ? data.text : 'Double click to set field'}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MetricValueEdit = (props: CanvasElementProps<TextBoxConfig, TextBoxData>) => {
|
||||||
|
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 (
|
||||||
|
<div className={styles.inlineEditorContainer}>
|
||||||
|
{panelData && (
|
||||||
|
<FieldNamePicker
|
||||||
|
context={{ data: panelData }}
|
||||||
|
value={config.text?.field ?? ''}
|
||||||
|
onChange={onFieldChange}
|
||||||
|
item={dummyFieldSettings}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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<TextBoxConfig, TextBoxData> = {
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
@ -23,7 +23,7 @@ export enum VAlign {
|
|||||||
Bottom = 'bottom',
|
Bottom = 'bottom',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TextBoxData {
|
export interface TextBoxData {
|
||||||
text?: string;
|
text?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
size?: number; // 0 or missing will "auto size"
|
size?: number; // 0 or missing will "auto size"
|
||||||
@ -31,7 +31,7 @@ interface TextBoxData {
|
|||||||
valign: VAlign;
|
valign: VAlign;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TextBoxConfig {
|
export interface TextBoxConfig {
|
||||||
text?: TextDimensionConfig;
|
text?: TextDimensionConfig;
|
||||||
color?: ColorDimensionConfig;
|
color?: ColorDimensionConfig;
|
||||||
size?: number; // 0 or missing will "auto size"
|
size?: number; // 0 or missing will "auto size"
|
||||||
|
@ -6,18 +6,21 @@ import { droneFrontItem } from './elements/droneFront';
|
|||||||
import { droneSideItem } from './elements/droneSide';
|
import { droneSideItem } from './elements/droneSide';
|
||||||
import { droneTopItem } from './elements/droneTop';
|
import { droneTopItem } from './elements/droneTop';
|
||||||
import { iconItem } from './elements/icon';
|
import { iconItem } from './elements/icon';
|
||||||
|
import { metricValueItem } from './elements/metricValue';
|
||||||
import { textBoxItem } from './elements/textBox';
|
import { textBoxItem } from './elements/textBox';
|
||||||
import { windTurbineItem } from './elements/windTurbine';
|
import { windTurbineItem } from './elements/windTurbine';
|
||||||
|
|
||||||
export const DEFAULT_CANVAS_ELEMENT_CONFIG: CanvasElementOptions = {
|
export const DEFAULT_CANVAS_ELEMENT_CONFIG: CanvasElementOptions = {
|
||||||
...iconItem.getNewOptions(),
|
...metricValueItem.getNewOptions(),
|
||||||
type: iconItem.id,
|
placement: { ...metricValueItem.getNewOptions().placement, ...metricValueItem.defaultSize },
|
||||||
|
type: metricValueItem.id,
|
||||||
name: `Element 1`,
|
name: `Element 1`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultElementItems = [
|
export const defaultElementItems = [
|
||||||
iconItem, // default for now
|
metricValueItem, // default for now
|
||||||
textBoxItem,
|
textBoxItem,
|
||||||
|
iconItem,
|
||||||
];
|
];
|
||||||
|
|
||||||
const advancedElementItems = [buttonItem, windTurbineItem, droneTopItem, droneFrontItem, droneSideItem];
|
const advancedElementItems = [buttonItem, windTurbineItem, droneTopItem, droneFrontItem, droneSideItem];
|
||||||
|
@ -445,10 +445,19 @@ export class ElementState implements LayerElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
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 (
|
return (
|
||||||
<div key={this.UID} ref={this.initElement}>
|
<div key={this.UID} ref={this.initElement}>
|
||||||
<item.display key={`${this.UID}/${this.revId}`} config={this.options.config} data={this.data} />
|
<item.display
|
||||||
|
key={`${this.UID}/${this.revId}`}
|
||||||
|
config={this.options.config}
|
||||||
|
data={this.data}
|
||||||
|
isSelected={isSelected}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import Moveable from 'moveable';
|
import Moveable from 'moveable';
|
||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { ReplaySubject, Subject } from 'rxjs';
|
import { BehaviorSubject, ReplaySubject, Subject, Subscription } from 'rxjs';
|
||||||
import { first } from 'rxjs/operators';
|
import { first } from 'rxjs/operators';
|
||||||
import Selecto from 'selecto';
|
import Selecto from 'selecto';
|
||||||
|
|
||||||
@ -67,6 +67,9 @@ export class Scene {
|
|||||||
|
|
||||||
inlineEditingCallback?: () => void;
|
inlineEditingCallback?: () => void;
|
||||||
|
|
||||||
|
readonly editModeEnabled = new BehaviorSubject<boolean>(false);
|
||||||
|
subscription: Subscription;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
cfg: CanvasFrameOptions,
|
cfg: CanvasFrameOptions,
|
||||||
enableEditing: boolean,
|
enableEditing: boolean,
|
||||||
@ -74,6 +77,13 @@ export class Scene {
|
|||||||
public onSave: (cfg: CanvasFrameOptions) => void
|
public onSave: (cfg: CanvasFrameOptions) => void
|
||||||
) {
|
) {
|
||||||
this.root = this.load(cfg, enableEditing, showAdvancedTypes);
|
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) => {
|
getNextElementName = (isFrame = false) => {
|
||||||
@ -284,6 +294,7 @@ export class Scene {
|
|||||||
if (this.selecto) {
|
if (this.selecto) {
|
||||||
this.selecto.setSelectedTargets(selection.targets);
|
this.selecto.setSelectedTargets(selection.targets);
|
||||||
this.updateSelection(selection);
|
this.updateSelection(selection);
|
||||||
|
this.editModeEnabled.next(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -339,7 +350,7 @@ export class Scene {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.moveable = new Moveable(this.div!, {
|
this.moveable = new Moveable(this.div!, {
|
||||||
draggable: allowChanges,
|
draggable: allowChanges && !this.editModeEnabled.getValue(),
|
||||||
resizable: allowChanges,
|
resizable: allowChanges,
|
||||||
ables: [dimensionViewable, constraintViewable(this), settingsViewable(this)],
|
ables: [dimensionViewable, constraintViewable(this), settingsViewable(this)],
|
||||||
props: {
|
props: {
|
||||||
@ -349,6 +360,17 @@ export class Scene {
|
|||||||
},
|
},
|
||||||
origin: false,
|
origin: false,
|
||||||
className: this.styles.selected,
|
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) => {
|
.on('clickGroup', (event) => {
|
||||||
this.selecto!.clickTarget(event.inputEvent, event.inputTarget);
|
this.selecto!.clickTarget(event.inputEvent, event.inputTarget);
|
||||||
@ -441,7 +463,12 @@ export class Scene {
|
|||||||
.includes(selectedTarget.parentElement.parentElement);
|
.includes(selectedTarget.parentElement.parentElement);
|
||||||
|
|
||||||
// Apply grabbing cursor while dragging, applyLayoutStylesToDiv() resets it to grab when done
|
// 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';
|
this.selecto.getSelectedTargets()[0].style.cursor = 'grabbing';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -449,13 +476,16 @@ export class Scene {
|
|||||||
// Prevent drawing selection box when selected target is a moveable element or already selected
|
// Prevent drawing selection box when selected target is a moveable element or already selected
|
||||||
event.stop();
|
event.stop();
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.on('select', () => {
|
||||||
|
this.editModeEnabled.next(false);
|
||||||
})
|
})
|
||||||
.on('selectEnd', (event) => {
|
.on('selectEnd', (event) => {
|
||||||
targets = event.selected;
|
targets = event.selected;
|
||||||
this.updateSelection({ targets });
|
this.updateSelection({ targets });
|
||||||
|
|
||||||
if (event.isDragStart) {
|
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';
|
this.selecto.getSelectedTargets()[0].style.cursor = 'grabbing';
|
||||||
}
|
}
|
||||||
event.inputEvent.preventDefault();
|
event.inputEvent.preventDefault();
|
||||||
|
@ -121,6 +121,7 @@ export class CanvasPanel extends Component<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
|
this.scene.subscription.unsubscribe();
|
||||||
this.subs.unsubscribe();
|
this.subs.unsubscribe();
|
||||||
isInlineEditOpen = false;
|
isInlineEditOpen = false;
|
||||||
canvasInstances = canvasInstances.filter((ci) => ci.props.id !== activeCanvasPanel?.props.id);
|
canvasInstances = canvasInstances.filter((ci) => ci.props.id !== activeCanvasPanel?.props.id);
|
||||||
|
@ -111,6 +111,9 @@ export const TreeNavigationEditor = ({ item }: StandardEditorProps<any, TreeView
|
|||||||
const newItem = canvasElementRegistry.getIfExists(sel.value) ?? notFoundItem;
|
const newItem = canvasElementRegistry.getIfExists(sel.value) ?? notFoundItem;
|
||||||
const newElementOptions = newItem.getNewOptions() as CanvasElementOptions;
|
const newElementOptions = newItem.getNewOptions() as CanvasElementOptions;
|
||||||
newElementOptions.type = newItem.id;
|
newElementOptions.type = newItem.id;
|
||||||
|
if (newItem.defaultSize) {
|
||||||
|
newElementOptions.placement = { ...newElementOptions.placement, ...newItem.defaultSize };
|
||||||
|
}
|
||||||
const newElement = new ElementState(newItem, newElementOptions, layer);
|
const newElement = new ElementState(newItem, newElementOptions, layer);
|
||||||
newElement.updateData(layer.scene.context);
|
newElement.updateData(layer.scene.context);
|
||||||
layer.elements.push(newElement);
|
layer.elements.push(newElement);
|
||||||
|
Loading…
Reference in New Issue
Block a user