mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Canvas: Add basic responsive design and layer editor UX (#40404)
This commit is contained in:
@@ -7,6 +7,7 @@ import { CanvasGroupOptions } from 'app/features/canvas';
|
||||
import { Scene } from 'app/features/canvas/runtime/scene';
|
||||
import { PanelContext, PanelContextRoot } from '@grafana/ui';
|
||||
import { ElementState } from 'app/features/canvas/runtime/element';
|
||||
import { GroupState } from 'app/features/canvas/runtime/group';
|
||||
|
||||
interface Props extends PanelProps<PanelOptions> {}
|
||||
|
||||
@@ -16,7 +17,8 @@ interface State {
|
||||
|
||||
export interface InstanceState {
|
||||
scene: Scene;
|
||||
selected?: ElementState;
|
||||
selected: ElementState[];
|
||||
layer: GroupState;
|
||||
}
|
||||
|
||||
export class CanvasPanel extends Component<Props, State> {
|
||||
@@ -53,14 +55,16 @@ export class CanvasPanel extends Component<Props, State> {
|
||||
if (this.panelContext.onInstanceStateChange && this.panelContext.app === CoreApp.PanelEditor) {
|
||||
this.panelContext.onInstanceStateChange({
|
||||
scene: this.scene,
|
||||
layer: this.scene.root,
|
||||
});
|
||||
|
||||
this.subs.add(
|
||||
this.scene.selected.subscribe({
|
||||
this.scene.selection.subscribe({
|
||||
next: (v) => {
|
||||
this.panelContext.onInstanceStateChange!({
|
||||
scene: this.scene,
|
||||
selected: v,
|
||||
layer: this.scene.root,
|
||||
});
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { Button, Container, Icon, IconButton, stylesFactory, ValuePicker } from '@grafana/ui';
|
||||
import { GrafanaTheme, SelectableValue, StandardEditorProps } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
|
||||
|
||||
import { PanelOptions } from '../models.gen';
|
||||
import { InstanceState } from '../CanvasPanel';
|
||||
import { LayerActionID } from '../types';
|
||||
import { canvasElementRegistry } from 'app/features/canvas';
|
||||
|
||||
type Props = StandardEditorProps<any, InstanceState, PanelOptions>;
|
||||
|
||||
export class LayerElementListEditor extends PureComponent<Props> {
|
||||
style = getStyles(config.theme);
|
||||
|
||||
onAddItem = (sel: SelectableValue<string>) => {
|
||||
// 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);
|
||||
};
|
||||
|
||||
onSelect = (item: any) => {
|
||||
const { settings } = this.props.item;
|
||||
|
||||
if (settings?.scene && settings?.scene?.selecto) {
|
||||
settings.scene.selecto.clickTarget(item, item?.div);
|
||||
}
|
||||
};
|
||||
|
||||
getRowStyle = (sel: boolean) => {
|
||||
return sel ? `${this.style.row} ${this.style.sel}` : this.style.row;
|
||||
};
|
||||
|
||||
onDragEnd = (result: DropResult) => {
|
||||
if (!result.destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { settings } = this.props.item;
|
||||
if (!settings?.layer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { layer } = settings;
|
||||
|
||||
const count = layer.elements.length - 1;
|
||||
const src = (result.source.index - count) * -1;
|
||||
const dst = (result.destination.index - count) * -1;
|
||||
|
||||
layer.reorder(src, dst);
|
||||
};
|
||||
|
||||
render() {
|
||||
const settings = this.props.item.settings;
|
||||
if (!settings) {
|
||||
return <div>No settings</div>;
|
||||
}
|
||||
const layer = settings.layer;
|
||||
if (!layer) {
|
||||
return <div>Missing layer?</div>;
|
||||
}
|
||||
|
||||
const styles = this.style;
|
||||
const selection: number[] = settings.selected ? settings.selected.map((v) => v.UID) : [];
|
||||
return (
|
||||
<>
|
||||
<DragDropContext onDragEnd={this.onDragEnd}>
|
||||
<Droppable droppableId="droppable">
|
||||
{(provided, snapshot) => (
|
||||
<div {...provided.droppableProps} ref={provided.innerRef}>
|
||||
{(() => {
|
||||
// reverse order
|
||||
const rows: any = [];
|
||||
for (let i = layer.elements.length - 1; i >= 0; i--) {
|
||||
const element = layer.elements[i];
|
||||
rows.push(
|
||||
<Draggable key={element.UID} draggableId={`${element.UID}`} index={rows.length}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
className={this.getRowStyle(selection.includes(element.UID))}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
onMouseDown={() => this.onSelect(element)}
|
||||
>
|
||||
<span className={styles.typeWrapper}>{element.item.name}</span>
|
||||
<div className={styles.textWrapper}>
|
||||
{element.UID} ({i})
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
name="copy"
|
||||
title={'duplicate'}
|
||||
className={styles.actionIcon}
|
||||
onClick={() => layer.doAction(LayerActionID.Duplicate, element)}
|
||||
surface="header"
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
name="trash-alt"
|
||||
title={'remove'}
|
||||
className={cx(styles.actionIcon, styles.dragIcon)}
|
||||
onClick={() => layer.doAction(LayerActionID.Delete, element)}
|
||||
surface="header"
|
||||
/>
|
||||
<Icon
|
||||
title="Drag and drop to reorder"
|
||||
name="draggabledots"
|
||||
size="lg"
|
||||
className={styles.dragIcon}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
})()}
|
||||
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
<br />
|
||||
|
||||
<Container>
|
||||
<ValuePicker
|
||||
icon="plus"
|
||||
label="Add item"
|
||||
variant="secondary"
|
||||
options={canvasElementRegistry.selectOptions().options}
|
||||
onChange={this.onAddItem}
|
||||
isFullWidth={false}
|
||||
/>
|
||||
{selection.length > 0 && (
|
||||
<Button size="sm" variant="secondary" onClick={() => console.log('TODO!')}>
|
||||
Clear Selection
|
||||
</Button>
|
||||
)}
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
|
||||
wrapper: css`
|
||||
margin-bottom: ${theme.spacing.md};
|
||||
`,
|
||||
row: css`
|
||||
padding: ${theme.spacing.xs} ${theme.spacing.sm};
|
||||
border-radius: ${theme.border.radius.sm};
|
||||
background: ${theme.colors.bg2};
|
||||
min-height: ${theme.spacing.formInputHeight}px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 3px;
|
||||
cursor: pointer;
|
||||
|
||||
border: 1px solid ${theme.colors.formInputBorder};
|
||||
&:hover {
|
||||
border: 1px solid ${theme.colors.formInputBorderHover};
|
||||
}
|
||||
`,
|
||||
sel: css`
|
||||
border: 1px solid ${theme.colors.formInputBorderActive};
|
||||
&:hover {
|
||||
border: 1px solid ${theme.colors.formInputBorderActive};
|
||||
}
|
||||
`,
|
||||
dragIcon: css`
|
||||
cursor: drag;
|
||||
`,
|
||||
actionIcon: css`
|
||||
color: ${theme.colors.textWeak};
|
||||
&:hover {
|
||||
color: ${theme.colors.text};
|
||||
}
|
||||
`,
|
||||
typeWrapper: css`
|
||||
color: ${theme.colors.textBlue};
|
||||
margin-right: 5px;
|
||||
`,
|
||||
textWrapper: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
margin-right: ${theme.spacing.sm};
|
||||
`,
|
||||
}));
|
||||
66
public/app/plugins/panel/canvas/editor/PlacementEditor.tsx
Normal file
66
public/app/plugins/panel/canvas/editor/PlacementEditor.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React, { FC } from 'react';
|
||||
import { Button, Field, HorizontalGroup, InlineField, InlineFieldRow } from '@grafana/ui';
|
||||
import { StandardEditorProps } from '@grafana/data';
|
||||
|
||||
import { PanelOptions } from '../models.gen';
|
||||
import { useObservable } from 'react-use';
|
||||
import { Subject } from 'rxjs';
|
||||
import { CanvasEditorOptions } from './elementEditor';
|
||||
import { Anchor, Placement } from 'app/features/canvas';
|
||||
import { NumberInput } from 'app/features/dimensions/editors/NumberInput';
|
||||
|
||||
const anchors: Array<keyof Anchor> = ['top', 'left', 'bottom', 'right'];
|
||||
const places: Array<keyof Placement> = ['top', 'left', 'bottom', 'right', 'width', 'height'];
|
||||
|
||||
export const PlacementEditor: FC<StandardEditorProps<any, CanvasEditorOptions, PanelOptions>> = ({ item }) => {
|
||||
const settings = item.settings;
|
||||
|
||||
// Will force a rerender whenever the subject changes
|
||||
useObservable(settings?.scene ? settings.scene.moved : new Subject());
|
||||
|
||||
if (!settings) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
const element = settings.element;
|
||||
if (!element) {
|
||||
return <div>???</div>;
|
||||
}
|
||||
const { placement } = element;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<HorizontalGroup>
|
||||
{anchors.map((a) => (
|
||||
<Button
|
||||
key={a}
|
||||
size="sm"
|
||||
variant={element.anchor[a] ? 'primary' : 'secondary'}
|
||||
onClick={() => settings.scene.toggleAnchor(element, a)}
|
||||
>
|
||||
{a}
|
||||
</Button>
|
||||
))}
|
||||
</HorizontalGroup>
|
||||
<br />
|
||||
|
||||
<Field label="Position">
|
||||
<>
|
||||
{places.map((p) => {
|
||||
const v = placement[p];
|
||||
if (v == null) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<InlineFieldRow key={p}>
|
||||
<InlineField label={p} labelWidth={8} grow={true}>
|
||||
<NumberInput value={v} onChange={(v) => console.log('TODO, edit!!!', p, v)} />
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { NestedPanelOptions, NestedValueAccess } from '@grafana/data/src/utils/O
|
||||
import { setOptionImmutably } from 'app/features/dashboard/components/PanelEditor/utils';
|
||||
import { ElementState } from 'app/features/canvas/runtime/element';
|
||||
import { Scene } from 'app/features/canvas/runtime/scene';
|
||||
import { PlacementEditor } from './PlacementEditor';
|
||||
|
||||
export interface CanvasEditorOptions {
|
||||
element: ElementState;
|
||||
@@ -44,6 +45,8 @@ export function getElementEditor(opts: CanvasEditorOptions): NestedPanelOptions<
|
||||
|
||||
// Dynamically fill the selected element
|
||||
build: (builder, context) => {
|
||||
console.log('MAKE element editor', opts.element.UID);
|
||||
|
||||
const { options } = opts.element;
|
||||
const layerTypes = canvasElementRegistry.selectOptions(
|
||||
options?.type // the selected value
|
||||
@@ -70,6 +73,15 @@ export function getElementEditor(opts: CanvasEditorOptions): NestedPanelOptions<
|
||||
|
||||
optionBuilder.addBackground(builder, ctx);
|
||||
optionBuilder.addBorder(builder, ctx);
|
||||
|
||||
builder.addCustomEditor({
|
||||
category: ['Layout'],
|
||||
id: 'content',
|
||||
path: '__', // not used
|
||||
name: 'Anchor',
|
||||
editor: PlacementEditor,
|
||||
settings: opts,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
56
public/app/plugins/panel/canvas/editor/layerEditor.tsx
Normal file
56
public/app/plugins/panel/canvas/editor/layerEditor.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { get as lodashGet } from 'lodash';
|
||||
import { optionBuilder } from './options';
|
||||
import { NestedPanelOptions, NestedValueAccess } from '@grafana/data/src/utils/OptionsUIBuilders';
|
||||
import { setOptionImmutably } from 'app/features/dashboard/components/PanelEditor/utils';
|
||||
import { InstanceState } from '../CanvasPanel';
|
||||
import { LayerElementListEditor } from './LayerElementListEditor';
|
||||
|
||||
export function getLayerEditor(opts: InstanceState): NestedPanelOptions<InstanceState> {
|
||||
const { layer, scene } = opts;
|
||||
const options = layer.options || { elements: [] };
|
||||
|
||||
return {
|
||||
category: ['Layer'],
|
||||
path: '--', // not used!
|
||||
|
||||
// Note that canvas editor writes things to the scene!
|
||||
values: (parent: NestedValueAccess) => ({
|
||||
getValue: (path: string) => {
|
||||
return lodashGet(options, path);
|
||||
},
|
||||
onChange: (path: string, value: any) => {
|
||||
if (path === 'type' && value) {
|
||||
console.warn('unable to change layer type');
|
||||
return;
|
||||
}
|
||||
const c = setOptionImmutably(options, path, value);
|
||||
scene.onChange(layer.UID, c);
|
||||
},
|
||||
}),
|
||||
|
||||
// Dynamically fill the selected element
|
||||
build: (builder, context) => {
|
||||
console.log('MAKE layer editor', layer.UID);
|
||||
|
||||
builder.addCustomEditor({
|
||||
id: 'content',
|
||||
path: 'root',
|
||||
name: 'Elements',
|
||||
editor: LayerElementListEditor,
|
||||
settings: opts,
|
||||
});
|
||||
|
||||
// // 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 } };
|
||||
const ctx = { ...context, options };
|
||||
|
||||
// if (layer.registerOptionsUI) {
|
||||
// layer.registerOptionsUI(builder, ctx);
|
||||
// }
|
||||
|
||||
optionBuilder.addBackground(builder as any, ctx);
|
||||
optionBuilder.addBorder(builder as any, ctx);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { PanelPlugin } from '@grafana/data';
|
||||
import { CanvasPanel, InstanceState } from './CanvasPanel';
|
||||
import { PanelOptions } from './models.gen';
|
||||
import { getElementEditor } from './editor/elementEditor';
|
||||
import { getLayerEditor } from './editor/layerEditor';
|
||||
|
||||
export const plugin = new PanelPlugin<PanelOptions>(CanvasPanel)
|
||||
.setNoPadding() // extend to panel edges
|
||||
@@ -17,13 +18,20 @@ export const plugin = new PanelPlugin<PanelOptions>(CanvasPanel)
|
||||
defaultValue: true,
|
||||
});
|
||||
|
||||
if (state?.selected) {
|
||||
builder.addNestedOptions(
|
||||
getElementEditor({
|
||||
category: ['Selected element'],
|
||||
element: state.selected,
|
||||
scene: state.scene,
|
||||
})
|
||||
);
|
||||
if (state) {
|
||||
const selection = state.selected;
|
||||
if (selection?.length === 1) {
|
||||
builder.addNestedOptions(
|
||||
getElementEditor({
|
||||
category: [`Selected element (id: ${selection[0].UID})`], // changing the ID forces are reload
|
||||
element: selection[0],
|
||||
scene: state.scene,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
console.log('NO Single seleciton', selection?.length);
|
||||
}
|
||||
|
||||
builder.addNestedOptions(getLayerEditor(state));
|
||||
}
|
||||
});
|
||||
|
||||
6
public/app/plugins/panel/canvas/types.ts
Normal file
6
public/app/plugins/panel/canvas/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export enum LayerActionID {
|
||||
Delete = 'delete',
|
||||
Duplicate = 'duplicate',
|
||||
MoveTop = 'move-top',
|
||||
MoveBottom = 'move-bottom',
|
||||
}
|
||||
Reference in New Issue
Block a user