mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Geomap: custom layer naming (#41491)
This commit is contained in:
parent
a3b9c764d3
commit
a992447a45
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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}> ({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}> {baselayer.UID}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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} />),
|
||||
};
|
||||
}
|
||||
});
|
@ -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;
|
||||
`,
|
||||
};
|
||||
};
|
@ -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}> {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>
|
||||
);
|
||||
};
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
@ -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)
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user