diff --git a/packages/grafana-data/src/geo/layer.ts b/packages/grafana-data/src/geo/layer.ts index 9993e753d23..4d25e3a622a 100644 --- a/packages/grafana-data/src/geo/layer.ts +++ b/packages/grafana-data/src/geo/layer.ts @@ -49,7 +49,7 @@ export interface FrameGeometrySource { */ export interface MapLayerOptions { type: string; - name?: string; // configured display name + name?: string; // configured unique display name // Custom options depending on the type config?: TConfig; diff --git a/public/app/plugins/panel/geomap/GeomapPanel.tsx b/public/app/plugins/panel/geomap/GeomapPanel.tsx index 6493c67c050..46bde5c1c00 100644 --- a/public/app/plugins/panel/geomap/GeomapPanel.tsx +++ b/public/app/plugins/panel/geomap/GeomapPanel.tsx @@ -46,6 +46,7 @@ interface State extends OverlayProps { export interface GeomapLayerActions { selectLayer: (uid: string) => void; deleteLayer: (uid: string) => void; + updateLayer: (uid: string, updatedLayer: MapLayerState) => void; addlayer: (type: string) => void; reorder: (src: number, dst: number) => void; } @@ -92,7 +93,7 @@ export class GeomapPanel extends Component { 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 { this.layers = layers; this.doOptionsUpdate(0); }, + updateLayer: (uid: string, updatedLayer: MapLayerState) => { + 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 { } 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 { 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 { if (isBasemap && (!options?.type || config.geomapDisableCustomBaseLayer)) { options = DEFAULT_BASEMAP_CONFIG; @@ -408,7 +436,11 @@ export class GeomapPanel extends Component { 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 { 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 diff --git a/public/app/plugins/panel/geomap/editor/LayersEditor.tsx b/public/app/plugins/panel/geomap/editor/LayersEditor.tsx deleted file mode 100644 index d9fb2e2eac8..00000000000 --- a/public/app/plugins/panel/geomap/editor/LayersEditor.tsx +++ /dev/null @@ -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; - -export class LayersEditor extends PureComponent { - 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
No layers?
; - } - const baselayer = layers[0]; - - const styles = this.style; - return ( - <> - - actions.addlayer(v.value!)} - isFullWidth={true} - /> - -
- - - - {(provided, snapshot) => ( -
- {(() => { - // reverse order - const rows: any = []; - for (let i = layers.length - 1; i > 0; i--) { - const element = layers[i]; - rows.push( - - {(provided, snapshot) => ( -
actions!.selectLayer(element.UID)} - > - {element.options.type} -
  ({element.layer.getSourceState() ?? '?'})
- - actions.deleteLayer(element.UID)} - surface="header" - /> - {layers.length > 2 && ( - - )} -
- )} -
- ); - } - return rows; - })()} - - {provided.placeholder} -
- )} -
-
- - {false && baselayer && ( - <> - -
- {baselayer.options.type} -
  {baselayer.UID}
-
- - )} - - ); - } -} diff --git a/public/app/plugins/panel/geomap/editor/LayersEditor/AddLayerButton.tsx b/public/app/plugins/panel/geomap/editor/LayersEditor/AddLayerButton.tsx new file mode 100644 index 00000000000..1d89b883f4a --- /dev/null +++ b/public/app/plugins/panel/geomap/editor/LayersEditor/AddLayerButton.tsx @@ -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 ( + actions.addlayer(v.value!)} + isFullWidth={true} + /> + ); +}; diff --git a/public/app/plugins/panel/geomap/editor/LayersEditor/LayerHeader.test.tsx b/public/app/plugins/panel/geomap/editor/LayersEditor/LayerHeader.test.tsx new file mode 100644 index 00000000000..9bb670e88cb --- /dev/null +++ b/public/app/plugins/panel/geomap/editor/LayersEditor/LayerHeader.test.tsx @@ -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) { + 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(), + }; + } +}); diff --git a/public/app/plugins/panel/geomap/editor/LayersEditor/LayerHeader.tsx b/public/app/plugins/panel/geomap/editor/LayersEditor/LayerHeader.tsx new file mode 100644 index 00000000000..5a321e7bba3 --- /dev/null +++ b/public/app/plugins/panel/geomap/editor/LayersEditor/LayerHeader.tsx @@ -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; + layers: Array>; + onChange: (layer: MapLayerState) => void; +} + +export const LayerHeader = ({ layer, layers, onChange }: LayerHeaderProps) => { + const styles = useStyles(getStyles); + + const [isEditing, setIsEditing] = useState(false); + const [validationError, setValidationError] = useState(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) => { + 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) => { + 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) => { + event.target.select(); + }; + + return ( + <> +
+ {!isEditing && ( + + )} + + {isEditing && ( + <> + + {validationError && {validationError}} + + )} +
+ + ); +}; + +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; + `, + }; +}; diff --git a/public/app/plugins/panel/geomap/editor/LayersEditor/LayerList.tsx b/public/app/plugins/panel/geomap/editor/LayersEditor/LayerList.tsx new file mode 100644 index 00000000000..f4a15aa6473 --- /dev/null +++ b/public/app/plugins/panel/geomap/editor/LayersEditor/LayerList.tsx @@ -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>; + 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) => { + actions.updateLayer(layer.UID, layer); + }; + + return ( + + + {(provided, snapshot) => ( +
+ {(() => { + // reverse order + const rows: any = []; + for (let i = layers.length - 1; i > 0; i--) { + const element = layers[i]; + rows.push( + + {(provided, snapshot) => ( +
actions!.selectLayer(element.UID)} + > + +
  {element.options.type}
+ + actions.deleteLayer(element.UID)} + surface="header" + /> + {layers.length > 2 && ( + + )} +
+ )} +
+ ); + } + return rows; + })()} + + {provided.placeholder} +
+ )} +
+
+ ); +}; diff --git a/public/app/plugins/panel/geomap/editor/LayersEditor/LayersEditor.tsx b/public/app/plugins/panel/geomap/editor/LayersEditor/LayersEditor.tsx new file mode 100644 index 00000000000..b5507f39942 --- /dev/null +++ b/public/app/plugins/panel/geomap/editor/LayersEditor/LayersEditor.tsx @@ -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; + +export const LayersEditor = (props: LayersEditorProps) => { + const { layers, selected, actions } = props.context.instanceState ?? {}; + if (!layers || !actions) { + return
No layers?
; + } + + 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 ( + <> + + + +
+ + + + ); +}; diff --git a/public/app/plugins/panel/geomap/module.tsx b/public/app/plugins/panel/geomap/module.tsx index 45c7892d737..e9654e4afa3 100644 --- a/public/app/plugins/panel/geomap/module.tsx +++ b/public/app/plugins/panel/geomap/module.tsx @@ -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(GeomapPanel) diff --git a/public/app/plugins/panel/geomap/types.ts b/public/app/plugins/panel/geomap/types.ts index 861be1c92cc..0e02b9dd74c 100644 --- a/public/app/plugins/panel/geomap/types.ts +++ b/public/app/plugins/panel/geomap/types.ts @@ -71,7 +71,7 @@ export interface GazetteerPathEditorConfigSettings { // Runtime model //------------------- export interface MapLayerState { - UID: string; // value changes with each initalization + UID: string; // value changes with each initialization options: MapLayerOptions; handler: MapLayerHandler; layer: BaseLayer; // the openlayers instance