Canvas: refactor layer editor (#42562)

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
Nathan Marrs 2021-12-02 15:54:45 -08:00 committed by GitHub
parent c80e7764d8
commit e07abd76c0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 372 additions and 316 deletions

1
.github/CODEOWNERS vendored
View File

@ -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

View 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}
/>
);
};

View 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}>&nbsp; {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};
`,
}));

View File

@ -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} />),
};
}
});

View File

@ -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}

View File

@ -0,0 +1,4 @@
/** An interface that has a getName method */
export interface LayerElement {
getName: () => string;
}

View File

@ -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>>;

View File

@ -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>(() => [

View File

@ -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() {

View File

@ -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();

View File

@ -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);
});

View File

@ -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}>
&nbsp; {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};
`,
}));

View File

@ -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 };

View File

@ -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);

View 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}
/>
</>
);
};

View File

@ -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}
/>
);
};

View File

@ -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}>&nbsp; {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>
);
};

View File

@ -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} />
</>
);
};

View File

@ -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)

View File

@ -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

View File

@ -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 = {