mirror of
https://github.com/grafana/grafana.git
synced 2024-12-28 01:41:24 -06:00
Canvas: Inline edit (#48222)
Canvas inline edit panel Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
parent
6e6f6e3cce
commit
5df91bdcf1
@ -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",
|
||||
|
@ -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 {
|
||||
|
@ -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<PanelOptions> {}
|
||||
|
||||
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<SelectionAction>(1);
|
||||
|
||||
export class CanvasPanel extends Component<Props, State> {
|
||||
static contextType = PanelContextRoot;
|
||||
panelContext: PanelContext = {} as PanelContext;
|
||||
@ -28,11 +42,14 @@ export class CanvasPanel extends Component<Props, State> {
|
||||
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<Props, State> {
|
||||
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<Props, State> {
|
||||
}
|
||||
|
||||
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<Props, State> {
|
||||
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<Props, State> {
|
||||
...options,
|
||||
root,
|
||||
});
|
||||
|
||||
this.setState({ refresh: this.state.refresh + 1 });
|
||||
// console.log('send changes', root);
|
||||
};
|
||||
@ -112,6 +147,10 @@ export class CanvasPanel extends Component<Props, State> {
|
||||
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<Props, State> {
|
||||
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 <InlineEdit onClose={() => this.inlineEditButtonClose()} />;
|
||||
};
|
||||
|
||||
render() {
|
||||
return this.scene.render();
|
||||
return (
|
||||
<>
|
||||
{this.scene.render()}
|
||||
{this.props.options.inlineEditing && !this.isEditing && (
|
||||
<div>
|
||||
<div className={this.styles.inlineEditButton}>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
icon="edit"
|
||||
data-btninlineedit={this.props.id}
|
||||
onClick={this.inlineEditButtonClick}
|
||||
/>
|
||||
</div>
|
||||
{this.state.openInlineEdit && this.renderInlineEdit()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
|
||||
inlineEditButton: css`
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
z-index: 999;
|
||||
`,
|
||||
}));
|
||||
|
121
public/app/plugins/panel/canvas/InlineEdit.tsx
Normal file
121
public/app/plugins/panel/canvas/InlineEdit.tsx
Normal file
@ -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<HTMLDivElement>(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<Dimensions2D>({ 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<Element, Event>, 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 (
|
||||
<Portal>
|
||||
<div className={styles.draggableWrapper}>
|
||||
<Draggable handle="strong" onStop={onDragStop} position={{ x: placement.x, y: placement.y }}>
|
||||
<Resizable height={measurements.height} width={measurements.width} onResize={onResizeStop}>
|
||||
<div
|
||||
className={styles.inlineEditorContainer}
|
||||
style={{ height: `${measurements.height}px`, width: `${measurements.width}px` }}
|
||||
ref={ref}
|
||||
>
|
||||
<strong className={styles.inlineEditorHeader}>
|
||||
<div className={styles.placeholder} />
|
||||
<div>Canvas Inline Editor</div>
|
||||
<IconButton name="times" size="xl" className={styles.inlineEditorClose} onClick={onClose} />
|
||||
</strong>
|
||||
<div className={styles.inlineEditorContentWrapper}>
|
||||
<div className={styles.inlineEditorContent}>
|
||||
<InlineEditBody />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Resizable>
|
||||
</Draggable>
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
`,
|
||||
});
|
99
public/app/plugins/panel/canvas/InlineEditBody.tsx
Normal file
99
public/app/plugins/panel/canvas/InlineEditBody.tsx
Normal file
@ -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<any>, context: StandardEditorContext<any>) => {
|
||||
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 (
|
||||
<div>
|
||||
<div>{pane.items.map((v) => v.render())}</div>
|
||||
<div>
|
||||
{pane.categories.map((c) => {
|
||||
return (
|
||||
<div key={c.props.id}>
|
||||
<h5>{c.props.title}</h5>
|
||||
<div>{c.items.map((s) => s.render())}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 🤮🤮🤮🤮 this oddly does not actually do anything, but structure is required. I'll try to clean it up...
|
||||
function getOptionsPaneCategoryDescriptor<T = any>(
|
||||
props: any,
|
||||
supplier: PanelOptionsSupplier<T>
|
||||
): OptionsPaneCategoryDescriptor {
|
||||
const context: StandardEditorContext<unknown, unknown> = {
|
||||
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;
|
||||
}
|
11
public/app/plugins/panel/canvas/globalStyles.ts
Normal file
11
public/app/plugins/panel/canvas/globalStyles.ts
Normal file
@ -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;
|
||||
}
|
||||
`;
|
||||
}
|
@ -6,7 +6,8 @@
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.react-grid-item {
|
||||
.react-grid-item,
|
||||
#grafana-portal-container {
|
||||
touch-action: initial !important;
|
||||
|
||||
&:hover {
|
||||
|
10
yarn.lock
10
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
|
||||
|
Loading…
Reference in New Issue
Block a user