mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Canvas: refactor layer editor (#42562)
Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
parent
c80e7764d8
commit
e07abd76c0
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@ -85,6 +85,7 @@ go.sum @grafana/backend-platform
|
||||
/plugins-bundled @grafana/plugins-platform-frontend
|
||||
/public @grafana/user-essentials
|
||||
/public/app/core/components/TimePicker @grafana/grafana-bi-squad
|
||||
/public/app/core/components/Layers @grafana/grafana-edge-squad
|
||||
/public/app/features/canvas/ @grafana/grafana-edge-squad
|
||||
/public/app/features/dimensions/ @grafana/grafana-edge-squad
|
||||
/public/app/features/live/ @grafana/grafana-edge-squad
|
||||
|
23
public/app/core/components/Layers/AddLayerButton.tsx
Normal file
23
public/app/core/components/Layers/AddLayerButton.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
|
||||
import { ValuePicker } from '@grafana/ui';
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
|
||||
type AddLayerButtonProps = {
|
||||
onChange: (sel: SelectableValue<string>) => void;
|
||||
options: Array<SelectableValue<string>>;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const AddLayerButton = ({ onChange, options, label }: AddLayerButtonProps) => {
|
||||
return (
|
||||
<ValuePicker
|
||||
icon="plus"
|
||||
label={label}
|
||||
variant="secondary"
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
isFullWidth={true}
|
||||
/>
|
||||
);
|
||||
};
|
171
public/app/core/components/Layers/LayerDragDropList.tsx
Normal file
171
public/app/core/components/Layers/LayerDragDropList.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
import React from 'react';
|
||||
import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { Icon, IconButton, stylesFactory } from '@grafana/ui';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { LayerName } from './LayerName';
|
||||
import { LayerElement } from './types';
|
||||
|
||||
type LayerDragDropListProps<T extends LayerElement> = {
|
||||
layers: T[];
|
||||
getLayerInfo: (element: T) => string;
|
||||
onDragEnd: (result: DropResult) => void;
|
||||
onSelect: (element: T) => any;
|
||||
onDelete: (element: T) => any;
|
||||
onDuplicate?: (element: T) => any;
|
||||
isGroup?: (element: T) => boolean;
|
||||
selection?: string[]; // list of unique ids (names)
|
||||
excludeBaseLayer?: boolean;
|
||||
onNameChange: (element: T, newName: string) => any;
|
||||
verifyLayerNameUniqueness?: (nameToCheck: string) => boolean;
|
||||
};
|
||||
|
||||
export const LayerDragDropList = <T extends LayerElement>({
|
||||
layers,
|
||||
getLayerInfo,
|
||||
onDragEnd,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
isGroup,
|
||||
selection,
|
||||
excludeBaseLayer,
|
||||
onNameChange,
|
||||
verifyLayerNameUniqueness,
|
||||
}: LayerDragDropListProps<T>) => {
|
||||
const style = styles(config.theme);
|
||||
|
||||
const getRowStyle = (isSelected: boolean) => {
|
||||
return isSelected ? `${style.row} ${style.sel}` : style.row;
|
||||
};
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="droppable">
|
||||
{(provided, snapshot) => (
|
||||
<div {...provided.droppableProps} ref={provided.innerRef}>
|
||||
{(() => {
|
||||
// reverse order
|
||||
const rows: any = [];
|
||||
const lastLayerIndex = excludeBaseLayer ? 1 : 0;
|
||||
for (let i = layers.length - 1; i >= lastLayerIndex; i--) {
|
||||
const element = layers[i];
|
||||
const uid = element.getName();
|
||||
|
||||
const isSelected = Boolean(selection?.includes(uid));
|
||||
rows.push(
|
||||
<Draggable key={uid} draggableId={uid} index={rows.length}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
className={getRowStyle(isSelected)}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
onMouseDown={() => onSelect(element)}
|
||||
>
|
||||
<LayerName
|
||||
name={uid}
|
||||
onChange={(v) => onNameChange(element, v)}
|
||||
verifyLayerNameUniqueness={verifyLayerNameUniqueness ?? undefined}
|
||||
/>
|
||||
<div className={style.textWrapper}> {getLayerInfo(element)}</div>
|
||||
|
||||
{!isGroup!(element) && (
|
||||
<>
|
||||
{onDuplicate ? (
|
||||
<IconButton
|
||||
name="copy"
|
||||
title={'Duplicate'}
|
||||
className={style.actionIcon}
|
||||
onClick={() => onDuplicate(element)}
|
||||
surface="header"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<IconButton
|
||||
name="trash-alt"
|
||||
title={'remove'}
|
||||
className={cx(style.actionIcon, style.dragIcon)}
|
||||
onClick={() => onDelete(element)}
|
||||
surface="header"
|
||||
/>
|
||||
{layers.length > 2 && (
|
||||
<Icon
|
||||
title="Drag and drop to reorder"
|
||||
name="draggabledots"
|
||||
size="lg"
|
||||
className={style.dragIcon}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
|
||||
return rows;
|
||||
})()}
|
||||
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
);
|
||||
};
|
||||
|
||||
LayerDragDropList.defaultProps = {
|
||||
isGroup: () => false,
|
||||
};
|
||||
|
||||
const styles = 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};
|
||||
`,
|
||||
}));
|
@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { LayerHeaderProps, LayerHeader } from './LayerHeader';
|
||||
import { LayerNameProps, LayerName } from './LayerName';
|
||||
|
||||
describe('LayerHeader', () => {
|
||||
describe('LayerName', () => {
|
||||
it('Can edit title', () => {
|
||||
const scenario = renderScenario({});
|
||||
screen.getByTestId('layer-name-div').click();
|
||||
@ -11,7 +11,7 @@ describe('LayerHeader', () => {
|
||||
fireEvent.change(input, { target: { value: 'new name' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect((scenario.props.onChange as any).mock.calls[0][0].name).toBe('new name');
|
||||
expect((scenario.props.onChange as any).mock.calls[0][0]).toBe('new name');
|
||||
});
|
||||
|
||||
it('Show error when empty name is specified', async () => {
|
||||
@ -36,21 +36,21 @@ describe('LayerHeader', () => {
|
||||
expect(alert.textContent).toBe('Layer name already exists');
|
||||
});
|
||||
|
||||
function renderScenario(overrides: Partial<LayerHeaderProps>) {
|
||||
const props: LayerHeaderProps = {
|
||||
layer: { name: 'Layer 1', type: '?' },
|
||||
canRename: (v: string) => {
|
||||
const names = new Set(['Layer 1', 'Layer 2']);
|
||||
return !names.has(v);
|
||||
},
|
||||
function renderScenario(overrides: Partial<LayerNameProps>) {
|
||||
const props: LayerNameProps = {
|
||||
name: 'Layer 1',
|
||||
onChange: jest.fn(),
|
||||
verifyLayerNameUniqueness: (nameToCheck: string) => {
|
||||
const names = new Set(['Layer 1', 'Layer 2']);
|
||||
return !names.has(nameToCheck);
|
||||
},
|
||||
};
|
||||
|
||||
Object.assign(props, overrides);
|
||||
|
||||
return {
|
||||
props,
|
||||
renderResult: render(<LayerHeader {...props} />),
|
||||
renderResult: render(<LayerName {...props} />),
|
||||
};
|
||||
}
|
||||
});
|
@ -1,15 +1,15 @@
|
||||
import React, { useState } from 'react';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { Icon, Input, FieldValidationMessage, useStyles } from '@grafana/ui';
|
||||
import { GrafanaTheme, MapLayerOptions } from '@grafana/data';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
|
||||
export interface LayerHeaderProps {
|
||||
layer: MapLayerOptions<any>;
|
||||
canRename: (v: string) => boolean;
|
||||
onChange: (layer: MapLayerOptions<any>) => void;
|
||||
export interface LayerNameProps {
|
||||
name: string;
|
||||
onChange: (v: string) => void;
|
||||
verifyLayerNameUniqueness?: (nameToCheck: string) => boolean;
|
||||
}
|
||||
|
||||
export const LayerHeader = ({ layer, canRename, onChange }: LayerHeaderProps) => {
|
||||
export const LayerName = ({ name, onChange, verifyLayerNameUniqueness }: LayerNameProps) => {
|
||||
const styles = useStyles(getStyles);
|
||||
|
||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||
@ -27,11 +27,8 @@ export const LayerHeader = ({ layer, canRename, onChange }: LayerHeaderProps) =>
|
||||
return;
|
||||
}
|
||||
|
||||
if (layer.name !== newName) {
|
||||
onChange({
|
||||
...layer,
|
||||
name: newName,
|
||||
});
|
||||
if (name !== newName) {
|
||||
onChange(newName);
|
||||
}
|
||||
};
|
||||
|
||||
@ -43,7 +40,7 @@ export const LayerHeader = ({ layer, canRename, onChange }: LayerHeaderProps) =>
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canRename(newName)) {
|
||||
if (verifyLayerNameUniqueness && !verifyLayerNameUniqueness(newName)) {
|
||||
setValidationError('Layer name already exists');
|
||||
return;
|
||||
}
|
||||
@ -77,7 +74,7 @@ export const LayerHeader = ({ layer, canRename, onChange }: LayerHeaderProps) =>
|
||||
onClick={onEditLayer}
|
||||
data-testid="layer-name-div"
|
||||
>
|
||||
<span className={styles.layerName}>{layer.name}</span>
|
||||
<span className={styles.layerName}>{name}</span>
|
||||
<Icon name="pen" className={styles.layerEditIcon} size="sm" />
|
||||
</button>
|
||||
)}
|
||||
@ -86,7 +83,7 @@ export const LayerHeader = ({ layer, canRename, onChange }: LayerHeaderProps) =>
|
||||
<>
|
||||
<Input
|
||||
type="text"
|
||||
defaultValue={layer.name}
|
||||
defaultValue={name}
|
||||
onBlur={onEditLayerBlur}
|
||||
autoFocus
|
||||
onKeyDown={onKeyDown}
|
4
public/app/core/components/Layers/types.ts
Normal file
4
public/app/core/components/Layers/types.ts
Normal file
@ -0,0 +1,4 @@
|
||||
/** An interface that has a getName method */
|
||||
export interface LayerElement {
|
||||
getName: () => string;
|
||||
}
|
@ -12,12 +12,13 @@ import { PanelOptionsSupplier } from '@grafana/data/src/panel/PanelPlugin';
|
||||
* @alpha
|
||||
*/
|
||||
export interface CanvasElementOptions<TConfig = any> {
|
||||
name: string; // configured unique display name
|
||||
type: string;
|
||||
|
||||
// Custom options depending on the type
|
||||
config?: TConfig;
|
||||
|
||||
// Standard options avaliable for all elements
|
||||
// Standard options available for all elements
|
||||
anchor?: Anchor; // defaults top, left, width and height
|
||||
placement?: Placement;
|
||||
background?: BackgroundConfig;
|
||||
@ -50,7 +51,7 @@ export interface CanvasElementItem<TConfig = any, TData = any> extends RegistryI
|
||||
/** Component used to draw */
|
||||
display: ComponentType<CanvasElementProps<TConfig, TData>>;
|
||||
|
||||
getNewOptions: (options?: CanvasElementOptions) => Omit<CanvasElementOptions<TConfig>, 'type'>;
|
||||
getNewOptions: (options?: CanvasElementOptions) => Omit<CanvasElementOptions<TConfig>, 'type' | 'name'>;
|
||||
|
||||
/** Build the configuraiton UI */
|
||||
registerOptionsUI?: PanelOptionsSupplier<CanvasElementOptions<TConfig>>;
|
||||
|
@ -4,8 +4,9 @@ import { iconItem } from './elements/icon';
|
||||
import { textBoxItem } from './elements/textBox';
|
||||
|
||||
export const DEFAULT_CANVAS_ELEMENT_CONFIG: CanvasElementOptions = {
|
||||
type: iconItem.id,
|
||||
...iconItem.getNewOptions(),
|
||||
type: iconItem.id,
|
||||
name: `Group ${Date.now()}.${Math.floor(Math.random() * 100)}`,
|
||||
};
|
||||
|
||||
export const canvasElementRegistry = new Registry<CanvasElementItem>(() => [
|
||||
|
@ -12,10 +12,11 @@ import {
|
||||
import { DimensionContext } from 'app/features/dimensions';
|
||||
import { notFoundItem } from 'app/features/canvas/elements/notFound';
|
||||
import { GroupState } from './group';
|
||||
import { LayerElement } from 'app/core/components/Layers/types';
|
||||
|
||||
let counter = 0;
|
||||
|
||||
export class ElementState {
|
||||
export class ElementState implements LayerElement {
|
||||
readonly UID = counter++;
|
||||
|
||||
revId = 0;
|
||||
@ -36,12 +37,20 @@ export class ElementState {
|
||||
|
||||
constructor(public item: CanvasElementItem, public options: CanvasElementOptions, public parent?: GroupState) {
|
||||
if (!options) {
|
||||
this.options = { type: item.id };
|
||||
this.options = { type: item.id, name: `Element ${this.UID}` };
|
||||
}
|
||||
this.anchor = options.anchor ?? {};
|
||||
this.placement = options.placement ?? {};
|
||||
options.anchor = this.anchor;
|
||||
options.placement = this.placement;
|
||||
|
||||
if (!options.name) {
|
||||
options.name = `Element ${this.UID}`;
|
||||
}
|
||||
}
|
||||
|
||||
getName() {
|
||||
return this.options.name;
|
||||
}
|
||||
|
||||
validatePlacement() {
|
||||
|
@ -99,7 +99,7 @@ export class GroupState extends ElementState {
|
||||
|
||||
// ??? or should this be on the element directly?
|
||||
// are actions scoped to layers?
|
||||
doAction = (action: LayerActionID, element: ElementState) => {
|
||||
doAction = (action: LayerActionID, element: ElementState, updateName = true) => {
|
||||
switch (action) {
|
||||
case LayerActionID.Delete:
|
||||
this.elements = this.elements.filter((e) => e !== element);
|
||||
@ -128,6 +128,9 @@ export class GroupState extends ElementState {
|
||||
const copy = new ElementState(element.item, opts, this);
|
||||
copy.updateSize(element.width, element.height);
|
||||
copy.updateData(this.scene.context);
|
||||
if (updateName) {
|
||||
copy.options.name = `Element ${copy.UID} (duplicate)`;
|
||||
}
|
||||
this.elements.push(copy);
|
||||
this.scene.save();
|
||||
this.reinitializeMoveable();
|
||||
|
@ -103,6 +103,7 @@ export class Scene {
|
||||
const newLayer = new GroupState(
|
||||
{
|
||||
type: 'group',
|
||||
name: `Group ${Date.now()}.${Math.floor(Math.random() * 100)}`,
|
||||
elements: [],
|
||||
},
|
||||
this,
|
||||
@ -110,7 +111,7 @@ export class Scene {
|
||||
);
|
||||
|
||||
currentSelectedElements.forEach((element: ElementState) => {
|
||||
newLayer.doAction(LayerActionID.Duplicate, element);
|
||||
newLayer.doAction(LayerActionID.Duplicate, element, false);
|
||||
currentLayer.doAction(LayerActionID.Delete, element);
|
||||
});
|
||||
|
||||
|
@ -1,9 +1,7 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { Button, HorizontalGroup, Icon, IconButton, stylesFactory, ValuePicker } from '@grafana/ui';
|
||||
import { AppEvents, GrafanaTheme, SelectableValue, StandardEditorProps } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
|
||||
import { Button, HorizontalGroup } from '@grafana/ui';
|
||||
import { AppEvents, SelectableValue, StandardEditorProps } from '@grafana/data';
|
||||
import { DropResult } from 'react-beautiful-dnd';
|
||||
|
||||
import { PanelOptions } from '../models.gen';
|
||||
import { LayerActionID } from '../types';
|
||||
@ -15,12 +13,12 @@ import { GroupState } from 'app/features/canvas/runtime/group';
|
||||
import { LayerEditorProps } from './layerEditor';
|
||||
import { SelectionParams } from 'app/features/canvas/runtime/scene';
|
||||
import { ShowConfirmModalEvent } from 'app/types/events';
|
||||
import { LayerDragDropList } from 'app/core/components/Layers/LayerDragDropList';
|
||||
import { AddLayerButton } from 'app/core/components/Layers/AddLayerButton';
|
||||
|
||||
type Props = StandardEditorProps<any, LayerEditorProps, PanelOptions>;
|
||||
|
||||
export class LayerElementListEditor extends PureComponent<Props> {
|
||||
style = getLayerDragStyles(config.theme);
|
||||
|
||||
getScene = () => {
|
||||
const { settings } = this.props.item;
|
||||
if (!settings?.layer) {
|
||||
@ -86,10 +84,6 @@ export class LayerElementListEditor extends PureComponent<Props> {
|
||||
layer.scene.clearCurrentSelection();
|
||||
};
|
||||
|
||||
getRowStyle = (sel: boolean) => {
|
||||
return sel ? `${this.style.row} ${this.style.sel}` : this.style.row;
|
||||
};
|
||||
|
||||
onDragEnd = (result: DropResult) => {
|
||||
if (!result.destination) {
|
||||
return;
|
||||
@ -133,7 +127,7 @@ export class LayerElementListEditor extends PureComponent<Props> {
|
||||
const { layer } = settings;
|
||||
|
||||
layer.elements.forEach((element: ElementState) => {
|
||||
layer.parent?.doAction(LayerActionID.Duplicate, element);
|
||||
layer.parent?.doAction(LayerActionID.Duplicate, element, false);
|
||||
});
|
||||
this.deleteGroup();
|
||||
};
|
||||
@ -201,8 +195,27 @@ export class LayerElementListEditor extends PureComponent<Props> {
|
||||
return <div>Missing layer?</div>;
|
||||
}
|
||||
|
||||
const styles = this.style;
|
||||
const selection: number[] = settings.selected ? settings.selected.map((v) => v.UID) : [];
|
||||
const onDelete = (element: ElementState) => {
|
||||
layer.doAction(LayerActionID.Delete, element);
|
||||
};
|
||||
|
||||
const onDuplicate = (element: ElementState) => {
|
||||
layer.doAction(LayerActionID.Duplicate, element);
|
||||
};
|
||||
|
||||
const getLayerInfo = (element: ElementState) => {
|
||||
return element.options.type;
|
||||
};
|
||||
|
||||
const onNameChange = (element: ElementState, name: string) => {
|
||||
element.onChange({ ...element.options, name });
|
||||
};
|
||||
|
||||
const isGroup = (element: ElementState) => {
|
||||
return element instanceof GroupState;
|
||||
};
|
||||
|
||||
const selection: string[] = settings.selected ? settings.selected.map((v) => v.getName()) : [];
|
||||
return (
|
||||
<>
|
||||
{!layer.isRoot() && (
|
||||
@ -221,78 +234,24 @@ export class LayerElementListEditor extends PureComponent<Props> {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
|
||||
{element.item.id !== 'group' && (
|
||||
<>
|
||||
<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>
|
||||
<LayerDragDropList
|
||||
onDragEnd={this.onDragEnd}
|
||||
onSelect={this.onSelect}
|
||||
onDelete={onDelete}
|
||||
onDuplicate={onDuplicate}
|
||||
getLayerInfo={getLayerInfo}
|
||||
onNameChange={onNameChange}
|
||||
isGroup={isGroup}
|
||||
layers={layer.elements}
|
||||
selection={selection}
|
||||
/>
|
||||
<br />
|
||||
|
||||
<HorizontalGroup>
|
||||
<ValuePicker
|
||||
icon="plus"
|
||||
label="Add item"
|
||||
variant="secondary"
|
||||
options={canvasElementRegistry.selectOptions().options}
|
||||
<AddLayerButton
|
||||
onChange={this.onAddItem}
|
||||
isFullWidth={false}
|
||||
options={canvasElementRegistry.selectOptions().options}
|
||||
label={'Add item'}
|
||||
/>
|
||||
{selection.length > 0 && (
|
||||
<Button size="sm" variant="secondary" onClick={this.onClearSelection}>
|
||||
@ -309,51 +268,3 @@ export class LayerElementListEditor extends PureComponent<Props> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const getLayerDragStyles = 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};
|
||||
`,
|
||||
}));
|
||||
|
@ -68,6 +68,7 @@ export function getElementEditor(opts: CanvasEditorOptions): NestedPanelOptions<
|
||||
currentOptions = {
|
||||
...layer.getNewOptions(options),
|
||||
type: layer.id,
|
||||
name: `Element ${Date.now()}.${Math.floor(Math.random() * 100)}`,
|
||||
};
|
||||
}
|
||||
const ctx = { ...context, options: currentOptions };
|
||||
|
@ -453,6 +453,8 @@ export class GeomapPanel extends Component<Props, State> {
|
||||
layer,
|
||||
handler,
|
||||
|
||||
getName: () => UID,
|
||||
|
||||
// Used by the editors
|
||||
onChange: (cfg: MapLayerOptions) => {
|
||||
this.updateLayer(UID, cfg);
|
||||
|
81
public/app/plugins/panel/geomap/editor/LayersEditor.tsx
Normal file
81
public/app/plugins/panel/geomap/editor/LayersEditor.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import { Container } from '@grafana/ui';
|
||||
import { StandardEditorProps } from '@grafana/data';
|
||||
import { DropResult } from 'react-beautiful-dnd';
|
||||
|
||||
import { GeomapPanelOptions, MapLayerState } from '../types';
|
||||
import { GeomapInstanceState } from '../GeomapPanel';
|
||||
import { geomapLayerRegistry } from '../layers/registry';
|
||||
import { dataLayerFilter } from './layerEditor';
|
||||
import { AddLayerButton } from 'app/core/components/Layers/AddLayerButton';
|
||||
import { LayerDragDropList } from 'app/core/components/Layers/LayerDragDropList';
|
||||
|
||||
type LayersEditorProps = StandardEditorProps<any, any, GeomapPanelOptions, GeomapInstanceState>;
|
||||
|
||||
export const LayersEditor = (props: LayersEditorProps) => {
|
||||
const { layers, selected, actions } = props.context.instanceState ?? {};
|
||||
if (!layers || !actions) {
|
||||
return <div>No layers?</div>;
|
||||
}
|
||||
|
||||
const onDragEnd = (result: DropResult) => {
|
||||
if (!result.destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { layers, actions } = props.context.instanceState ?? {};
|
||||
if (!layers || !actions) {
|
||||
return;
|
||||
}
|
||||
|
||||
// account for the reverse order and offset (0 is baselayer)
|
||||
const count = layers.length - 1;
|
||||
const src = (result.source.index - count) * -1;
|
||||
const dst = (result.destination.index - count) * -1;
|
||||
|
||||
actions.reorder(src, dst);
|
||||
};
|
||||
|
||||
const onSelect = (element: MapLayerState<any>) => {
|
||||
actions.selectLayer(element.options.name);
|
||||
};
|
||||
|
||||
const onDelete = (element: MapLayerState<any>) => {
|
||||
actions.deleteLayer(element.options.name);
|
||||
};
|
||||
|
||||
const getLayerInfo = (element: MapLayerState<any>) => {
|
||||
return element.options.type;
|
||||
};
|
||||
|
||||
const onNameChange = (element: MapLayerState<any>, name: string) => {
|
||||
element.onChange({ ...element.options, name });
|
||||
};
|
||||
|
||||
const selection = selected ? [layers[selected]?.getName()] : [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container>
|
||||
<AddLayerButton
|
||||
onChange={(v) => actions.addlayer(v.value!)}
|
||||
options={geomapLayerRegistry.selectOptions(undefined, dataLayerFilter).options}
|
||||
label={'Add layer'}
|
||||
/>
|
||||
</Container>
|
||||
<br />
|
||||
|
||||
<LayerDragDropList
|
||||
layers={layers}
|
||||
getLayerInfo={getLayerInfo}
|
||||
onDragEnd={onDragEnd}
|
||||
onSelect={onSelect}
|
||||
onDelete={onDelete}
|
||||
selection={selection}
|
||||
excludeBaseLayer
|
||||
onNameChange={onNameChange}
|
||||
verifyLayerNameUniqueness={actions.canRename}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,21 +0,0 @@
|
||||
import React from 'react';
|
||||
import { ValuePicker } from '@grafana/ui';
|
||||
|
||||
import { geomapLayerRegistry } from '../../layers/registry';
|
||||
import { dataLayerFilter } from '../layerEditor';
|
||||
import { GeomapLayerActions } from '../../GeomapPanel';
|
||||
|
||||
type AddLayerButtonProps = { actions: GeomapLayerActions };
|
||||
|
||||
export const AddLayerButton = ({ actions }: AddLayerButtonProps) => {
|
||||
return (
|
||||
<ValuePicker
|
||||
icon="plus"
|
||||
label="Add layer"
|
||||
variant="secondary"
|
||||
options={geomapLayerRegistry.selectOptions(undefined, dataLayerFilter).options}
|
||||
onChange={(v) => actions.addlayer(v.value!)}
|
||||
isFullWidth={true}
|
||||
/>
|
||||
);
|
||||
};
|
@ -1,83 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Icon, IconButton } from '@grafana/ui';
|
||||
import { cx } from '@emotion/css';
|
||||
import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
import { MapLayerState } from '../../types';
|
||||
import { getLayerDragStyles } from 'app/plugins/panel/canvas/editor/LayerElementListEditor';
|
||||
import { GeomapLayerActions } from '../../GeomapPanel';
|
||||
import { LayerHeader } from './LayerHeader';
|
||||
|
||||
type LayerListProps = {
|
||||
layers: Array<MapLayerState<any>>;
|
||||
onDragEnd: (result: DropResult) => void;
|
||||
selected?: number;
|
||||
actions: GeomapLayerActions;
|
||||
};
|
||||
|
||||
export const LayerList = ({ layers, onDragEnd, selected, actions }: LayerListProps) => {
|
||||
const style = getLayerDragStyles(config.theme);
|
||||
|
||||
const getRowStyle = (sel: boolean) => {
|
||||
return sel ? `${style.row} ${style.sel}` : style.row;
|
||||
};
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="droppable">
|
||||
{(provided, snapshot) => (
|
||||
<div {...provided.droppableProps} ref={provided.innerRef}>
|
||||
{(() => {
|
||||
// reverse order
|
||||
const rows: any = [];
|
||||
for (let i = layers.length - 1; i > 0; i--) {
|
||||
const element = layers[i];
|
||||
const uid = element.options.name;
|
||||
rows.push(
|
||||
<Draggable key={uid} draggableId={uid} index={rows.length}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
className={getRowStyle(i === selected)}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
onMouseDown={() => actions!.selectLayer(uid)}
|
||||
>
|
||||
<LayerHeader
|
||||
layer={element.options}
|
||||
canRename={actions.canRename}
|
||||
onChange={element.onChange}
|
||||
/>
|
||||
<div className={style.textWrapper}> {element.options.type}</div>
|
||||
|
||||
<IconButton
|
||||
name="trash-alt"
|
||||
title={'remove'}
|
||||
className={cx(style.actionIcon, style.dragIcon)}
|
||||
onClick={() => actions.deleteLayer(uid)}
|
||||
surface="header"
|
||||
/>
|
||||
{layers.length > 2 && (
|
||||
<Icon
|
||||
title="Drag and drop to reorder"
|
||||
name="draggabledots"
|
||||
size="lg"
|
||||
className={style.dragIcon}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
})()}
|
||||
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
);
|
||||
};
|
@ -1,47 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Container } from '@grafana/ui';
|
||||
import { StandardEditorProps } from '@grafana/data';
|
||||
import { DropResult } from 'react-beautiful-dnd';
|
||||
|
||||
import { GeomapPanelOptions } from '../../types';
|
||||
import { GeomapInstanceState } from '../../GeomapPanel';
|
||||
import { AddLayerButton } from './AddLayerButton';
|
||||
import { LayerList } from './LayerList';
|
||||
|
||||
type LayersEditorProps = StandardEditorProps<any, any, GeomapPanelOptions, GeomapInstanceState>;
|
||||
|
||||
export const LayersEditor = (props: LayersEditorProps) => {
|
||||
const { layers, selected, actions } = props.context.instanceState ?? {};
|
||||
if (!layers || !actions) {
|
||||
return <div>No layers?</div>;
|
||||
}
|
||||
|
||||
const onDragEnd = (result: DropResult) => {
|
||||
if (!result.destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { layers, actions } = props.context.instanceState ?? {};
|
||||
if (!layers || !actions) {
|
||||
return;
|
||||
}
|
||||
|
||||
// account for the reverse order and offset (0 is baselayer)
|
||||
const count = layers.length - 1;
|
||||
const src = (result.source.index - count) * -1;
|
||||
const dst = (result.destination.index - count) * -1;
|
||||
|
||||
actions.reorder(src, dst);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container>
|
||||
<AddLayerButton actions={actions} />
|
||||
</Container>
|
||||
<br />
|
||||
|
||||
<LayerList layers={layers} onDragEnd={onDragEnd} selected={selected} actions={actions} />
|
||||
</>
|
||||
);
|
||||
};
|
@ -5,7 +5,7 @@ import { MapViewEditor } from './editor/MapViewEditor';
|
||||
import { defaultView, GeomapPanelOptions } from './types';
|
||||
import { mapPanelChangedHandler, mapMigrationHandler } from './migrations';
|
||||
import { getLayerEditor } from './editor/layerEditor';
|
||||
import { LayersEditor } from './editor/LayersEditor/LayersEditor';
|
||||
import { LayersEditor } from './editor/LayersEditor';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
export const plugin = new PanelPlugin<GeomapPanelOptions>(GeomapPanel)
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { MapLayerHandler, MapLayerOptions, SelectableValue } from '@grafana/data';
|
||||
import { LayerElement } from 'app/core/components/Layers/types';
|
||||
import BaseLayer from 'ol/layer/Base';
|
||||
import { Units } from 'ol/proj/Units';
|
||||
import { StyleConfig } from './style/types';
|
||||
@ -70,7 +71,7 @@ export interface GazetteerPathEditorConfigSettings {
|
||||
//-------------------
|
||||
// Runtime model
|
||||
//-------------------
|
||||
export interface MapLayerState<TConfig = any> {
|
||||
export interface MapLayerState<TConfig = any> extends LayerElement {
|
||||
options: MapLayerOptions<TConfig>;
|
||||
handler: MapLayerHandler;
|
||||
layer: BaseLayer; // the openlayers instance
|
||||
|
@ -8,7 +8,7 @@ import { IconConfig } from 'app/features/canvas/elements/icon';
|
||||
import { ResourceDimensionMode } from 'app/features/dimensions';
|
||||
|
||||
export interface PanelOptions {
|
||||
root: Omit<CanvasElementOptions<IconConfig>, 'type'>; // type is forced
|
||||
root: Omit<CanvasElementOptions<IconConfig>, 'type' | 'name'>; // type is forced
|
||||
}
|
||||
|
||||
export const defaultPanelOptions: PanelOptions = {
|
||||
|
Loading…
Reference in New Issue
Block a user