Geomap: custom layer naming (#41491)

This commit is contained in:
Nathan Marrs 2021-11-09 10:19:46 -08:00 committed by GitHub
parent a3b9c764d3
commit a992447a45
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 417 additions and 132 deletions

View File

@ -49,7 +49,7 @@ export interface FrameGeometrySource {
*/
export interface MapLayerOptions<TConfig = any> {
type: string;
name?: string; // configured display name
name?: string; // configured unique display name
// Custom options depending on the type
config?: TConfig;

View File

@ -46,6 +46,7 @@ interface State extends OverlayProps {
export interface GeomapLayerActions {
selectLayer: (uid: string) => void;
deleteLayer: (uid: string) => void;
updateLayer: (uid: string, updatedLayer: MapLayerState<any>) => void;
addlayer: (type: string) => void;
reorder: (src: number, dst: number) => void;
}
@ -92,7 +93,7 @@ export class GeomapPanel extends Component<Props, State> {
shouldComponentUpdate(nextProps: Props) {
if (!this.map) {
return true; // not yet initalized
return true; // not yet initialized
}
// Check for resize
@ -152,6 +153,21 @@ export class GeomapPanel extends Component<Props, State> {
this.layers = layers;
this.doOptionsUpdate(0);
},
updateLayer: (uid: string, updatedLayer: MapLayerState<any>) => {
const selected = this.layers.findIndex((v) => v.UID === uid);
const layers: MapLayerState[] = [];
for (const lyr of this.layers) {
if (lyr.UID === uid) {
this.map?.removeLayer(lyr.layer);
this.map?.addLayer(updatedLayer.layer);
layers.push(updatedLayer);
} else {
layers.push(lyr);
}
}
this.layers = layers;
this.doOptionsUpdate(selected);
},
addlayer: (type: string) => {
const item = geomapLayerRegistry.getIfExists(type);
if (!item) {
@ -361,7 +377,7 @@ export class GeomapPanel extends Component<Props, State> {
}
layers[selected] = info;
// initalize with new data
// initialize with new data
if (info.handler.update) {
info.handler.update(this.props.data);
}
@ -378,6 +394,18 @@ export class GeomapPanel extends Component<Props, State> {
return true;
};
private generateLayerName = (): string => {
let newLayerName = `Layer ${this.counter}`;
for (const otherLayer of this.layers) {
if (newLayerName === otherLayer.options.name) {
newLayerName += '-1';
}
}
return newLayerName;
};
async initLayer(map: Map, options: MapLayerOptions, isBasemap?: boolean): Promise<MapLayerState> {
if (isBasemap && (!options?.type || config.geomapDisableCustomBaseLayer)) {
options = DEFAULT_BASEMAP_CONFIG;
@ -408,7 +436,11 @@ export class GeomapPanel extends Component<Props, State> {
handler.update(this.props.data);
}
if (!options.name) {
options.name = this.generateLayerName();
}
const UID = `lyr-${this.counter++}`;
return {
UID,
isBasemap,
@ -427,7 +459,7 @@ export class GeomapPanel extends Component<Props, State> {
let view = new View({
center: [0, 0],
zoom: 1,
showFullExtent: true, // alows zooming so the full range is visiable
showFullExtent: true, // allows zooming so the full range is visible
});
// With shared views, all panels use the same view instance

View File

@ -1,126 +0,0 @@
import React, { PureComponent } from 'react';
import { cx } from '@emotion/css';
import { Container, Icon, IconButton, ValuePicker } from '@grafana/ui';
import { StandardEditorProps } from '@grafana/data';
import { config } from '@grafana/runtime';
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
import { GeomapPanelOptions } from '../types';
import { GeomapInstanceState } from '../GeomapPanel';
import { geomapLayerRegistry } from '../layers/registry';
import { getLayerDragStyles } from '../../canvas/editor/LayerElementListEditor';
import { dataLayerFilter } from './layerEditor';
type Props = StandardEditorProps<any, any, GeomapPanelOptions, GeomapInstanceState>;
export class LayersEditor extends PureComponent<Props> {
style = getLayerDragStyles(config.theme);
getRowStyle = (sel: boolean) => {
return sel ? `${this.style.row} ${this.style.sel}` : this.style.row;
};
onDragEnd = (result: DropResult) => {
if (!result.destination) {
return;
}
const { layers, actions } = this.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);
};
render() {
const { layers, selected, actions } = this.props.context.instanceState ?? {};
if (!layers || !actions) {
return <div>No layers?</div>;
}
const baselayer = layers[0];
const styles = this.style;
return (
<>
<Container>
<ValuePicker
icon="plus"
label="Add layer"
variant="secondary"
options={geomapLayerRegistry.selectOptions(undefined, dataLayerFilter).options}
onChange={(v) => actions.addlayer(v.value!)}
isFullWidth={true}
/>
</Container>
<br />
<DragDropContext onDragEnd={this.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];
rows.push(
<Draggable key={element.UID} draggableId={element.UID} index={rows.length}>
{(provided, snapshot) => (
<div
className={this.getRowStyle(i === selected)}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
onMouseDown={() => actions!.selectLayer(element.UID)}
>
<span className={styles.typeWrapper}>{element.options.type}</span>
<div className={styles.textWrapper}>&nbsp; ({element.layer.getSourceState() ?? '?'})</div>
<IconButton
name="trash-alt"
title={'remove'}
className={cx(styles.actionIcon, styles.dragIcon)}
onClick={() => actions.deleteLayer(element.UID)}
surface="header"
/>
{layers.length > 2 && (
<Icon
title="Drag and drop to reorder"
name="draggabledots"
size="lg"
className={styles.dragIcon}
/>
)}
</div>
)}
</Draggable>
);
}
return rows;
})()}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
{false && baselayer && (
<>
<label>Base layer</label>
<div className={this.getRowStyle(false)}>
<span className={styles.typeWrapper}>{baselayer.options.type}</span>
<div className={styles.textWrapper}>&nbsp; {baselayer.UID}</div>
</div>
</>
)}
</>
);
}
}

View File

@ -0,0 +1,21 @@
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

@ -0,0 +1,65 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { LayerHeaderProps, LayerHeader } from './LayerHeader';
describe('LayerHeader', () => {
it('Can edit title', () => {
const scenario = renderScenario({});
screen.getByTestId('layer-name-div').click();
const input = screen.getByTestId('layer-name-input');
fireEvent.change(input, { target: { value: 'new name' } });
fireEvent.blur(input);
expect((scenario.props.onChange as any).mock.calls[0][0].options.name).toBe('new name');
});
it('Show error when empty name is specified', async () => {
renderScenario({});
screen.getByTestId('layer-name-div').click();
const input = screen.getByTestId('layer-name-input');
fireEvent.change(input, { target: { value: '' } });
const alert = await screen.findByRole('alert');
expect(alert.textContent).toBe('An empty layer name is not allowed');
});
it('Show error when other layer with same name exists', async () => {
renderScenario({});
screen.getByTestId('layer-name-div').click();
const input = screen.getByTestId('layer-name-input');
fireEvent.change(input, { target: { value: 'Layer 2' } });
const alert = await screen.findByRole('alert');
expect(alert.textContent).toBe('Layer name already exists');
});
function renderScenario(overrides: Partial<LayerHeaderProps>) {
const props: any = {
layer: {
UID: '1',
options: { name: 'Layer 1' },
},
layers: [
{
UID: '1',
options: { name: 'Layer 1' },
},
{
UID: '2',
options: { name: 'Layer 2' },
},
],
onChange: jest.fn(),
};
Object.assign(props, overrides);
return {
props,
renderResult: render(<LayerHeader {...props} />),
};
}
});

View File

@ -0,0 +1,164 @@
import React, { useState } from 'react';
import { css, cx } from '@emotion/css';
import { Icon, Input, FieldValidationMessage, useStyles } from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data';
import { MapLayerState } from '../../types';
export interface LayerHeaderProps {
layer: MapLayerState<any>;
layers: Array<MapLayerState<any>>;
onChange: (layer: MapLayerState<any>) => void;
}
export const LayerHeader = ({ layer, layers, onChange }: LayerHeaderProps) => {
const styles = useStyles(getStyles);
const [isEditing, setIsEditing] = useState<boolean>(false);
const [validationError, setValidationError] = useState<string | null>(null);
const onEditLayer = (event: React.SyntheticEvent) => {
setIsEditing(true);
};
const onEndEditName = (newName: string) => {
setIsEditing(false);
if (validationError) {
setValidationError(null);
return;
}
if (layer.options.name !== newName) {
onChange({
...layer,
options: { ...layer.options, name: newName },
});
}
};
const onInputChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
const newName = event.currentTarget.value.trim();
if (newName.length === 0) {
setValidationError('An empty layer name is not allowed');
return;
}
for (const otherLayer of layers) {
if (otherLayer.UID !== layer.UID && newName === otherLayer.options.name) {
setValidationError('Layer name already exists');
return;
}
}
if (validationError) {
setValidationError(null);
}
};
const onEditLayerBlur = (event: React.SyntheticEvent<HTMLInputElement>) => {
onEndEditName(event.currentTarget.value.trim());
};
const onKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter') {
onEndEditName((event.target as any).value);
}
};
const onFocus = (event: React.FocusEvent<HTMLInputElement>) => {
event.target.select();
};
return (
<>
<div className={styles.wrapper}>
{!isEditing && (
<button
className={styles.layerNameWrapper}
title="Edit layer name"
onClick={onEditLayer}
data-testid="layer-name-div"
>
<span className={styles.layerName}>{layer.options.name}</span>
<Icon name="pen" className={styles.layerEditIcon} size="sm" />
</button>
)}
{isEditing && (
<>
<Input
type="text"
defaultValue={layer.options.name}
onBlur={onEditLayerBlur}
autoFocus
onKeyDown={onKeyDown}
onFocus={onFocus}
invalid={validationError !== null}
onChange={onInputChange}
className={styles.layerNameInput}
data-testid="layer-name-input"
/>
{validationError && <FieldValidationMessage horizontal>{validationError}</FieldValidationMessage>}
</>
)}
</div>
</>
);
};
const getStyles = (theme: GrafanaTheme) => {
return {
wrapper: css`
label: Wrapper;
display: flex;
align-items: center;
margin-left: ${theme.spacing.xs};
`,
layerNameWrapper: css`
display: flex;
cursor: pointer;
border: 1px solid transparent;
border-radius: ${theme.border.radius.md};
align-items: center;
padding: 0 0 0 ${theme.spacing.xs};
margin: 0;
background: transparent;
&:hover {
background: ${theme.colors.bg3};
border: 1px dashed ${theme.colors.border3};
}
&:focus {
border: 2px solid ${theme.colors.formInputBorderActive};
}
&:hover,
&:focus {
.query-name-edit-icon {
visibility: visible;
}
}
`,
layerName: css`
font-weight: ${theme.typography.weight.semibold};
color: ${theme.colors.textBlue};
cursor: pointer;
overflow: hidden;
margin-left: ${theme.spacing.xs};
`,
layerEditIcon: cx(
css`
margin-left: ${theme.spacing.md};
visibility: hidden;
`,
'query-name-edit-icon'
),
layerNameInput: css`
max-width: 300px;
margin: -4px 0;
`,
};
};

View File

@ -0,0 +1,82 @@
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;
};
const onLayerNameChange = (layer: MapLayerState<any>) => {
actions.updateLayer(layer.UID, layer);
};
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];
rows.push(
<Draggable key={element.UID} draggableId={element.UID} index={rows.length}>
{(provided, snapshot) => (
<div
className={getRowStyle(i === selected)}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
onMouseDown={() => actions!.selectLayer(element.UID)}
>
<LayerHeader layer={{ ...element }} layers={layers} onChange={onLayerNameChange} />
<div className={style.textWrapper}>&nbsp; {element.options.type}</div>
<IconButton
name="trash-alt"
title={'remove'}
className={cx(style.actionIcon, style.dragIcon)}
onClick={() => actions.deleteLayer(element.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

@ -0,0 +1,47 @@
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';
import { LayersEditor } from './editor/LayersEditor/LayersEditor';
import { config } from '@grafana/runtime';
export const plugin = new PanelPlugin<GeomapPanelOptions>(GeomapPanel)

View File

@ -71,7 +71,7 @@ export interface GazetteerPathEditorConfigSettings {
// Runtime model
//-------------------
export interface MapLayerState<TConfig = any> {
UID: string; // value changes with each initalization
UID: string; // value changes with each initialization
options: MapLayerOptions<TConfig>;
handler: MapLayerHandler;
layer: BaseLayer; // the openlayers instance