mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
VizRepeater/BarGauge: Use common font dimensions across repeated visualisations (#19983)
* calculate metrics * fix tests * update test * update names * BarGauge: measure title width * BarGauge: added tests * BarGauge: Improved font size handling * Removed unused var * BarGauge: Further font size tweaks * BarGauge: added comments * BarGauge: final tweak * Updated snapshot* * Fixed issues
This commit is contained in:
parent
662e514f1d
commit
cbdca6cce8
@ -13,10 +13,6 @@ import {
|
||||
import { VizOrientation } from '@grafana/data';
|
||||
import { getTheme } from '../../themes';
|
||||
|
||||
// jest.mock('jquery', () => ({
|
||||
// plot: jest.fn(),
|
||||
// }));
|
||||
|
||||
const green = '#73BF69';
|
||||
const orange = '#FF9830';
|
||||
// const red = '#BB';
|
||||
@ -136,6 +132,38 @@ describe('BarGauge', () => {
|
||||
const styles = getTitleStyles(props);
|
||||
expect(styles.wrapper.flexDirection).toBe('row');
|
||||
});
|
||||
|
||||
it('should calculate title width based on title', () => {
|
||||
const props = getProps({
|
||||
height: 30,
|
||||
value: getValue(100, 'AA'),
|
||||
orientation: VizOrientation.Horizontal,
|
||||
});
|
||||
const styles = getTitleStyles(props);
|
||||
expect(styles.title.width).toBe('17px');
|
||||
|
||||
const props2 = getProps({
|
||||
height: 30,
|
||||
value: getValue(120, 'Longer title with many words'),
|
||||
orientation: VizOrientation.Horizontal,
|
||||
});
|
||||
const styles2 = getTitleStyles(props2);
|
||||
expect(styles2.title.width).toBe('43px');
|
||||
});
|
||||
|
||||
it('should use alignmentFactors if provided', () => {
|
||||
const props = getProps({
|
||||
height: 30,
|
||||
value: getValue(100, 'AA'),
|
||||
alignmentFactors: {
|
||||
title: 'Super duper long title',
|
||||
text: '1000',
|
||||
},
|
||||
orientation: VizOrientation.Horizontal,
|
||||
});
|
||||
const styles = getTitleStyles(props);
|
||||
expect(styles.title.width).toBe('37px');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gradient', () => {
|
||||
|
@ -5,6 +5,7 @@ import { Threshold, TimeSeriesValue, getActiveThreshold, DisplayValue } from '@g
|
||||
|
||||
// Utils
|
||||
import { getColorFromHexRgbOrName } from '@grafana/data';
|
||||
import { measureText } from '../../utils/measureText';
|
||||
|
||||
// Types
|
||||
import { VizOrientation } from '@grafana/data';
|
||||
@ -16,6 +17,19 @@ const MIN_VALUE_WIDTH = 50;
|
||||
const MAX_VALUE_WIDTH = 150;
|
||||
const TITLE_LINE_HEIGHT = 1.5;
|
||||
const VALUE_LINE_HEIGHT = 1;
|
||||
const VALUE_LEFT_PADDING = 10;
|
||||
|
||||
/**
|
||||
* These values calculate the internal font sizes and
|
||||
* placement. For consistent behavior across repeating
|
||||
* panels, we can optionally pass in the maximum values.
|
||||
*
|
||||
* If performace becomes a problem, we can cache the results
|
||||
*/
|
||||
export interface BarGaugeAlignmentFactors {
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface Props extends Themeable {
|
||||
height: number;
|
||||
@ -29,6 +43,7 @@ export interface Props extends Themeable {
|
||||
displayMode: 'basic' | 'lcd' | 'gradient';
|
||||
onClick?: React.MouseEventHandler<HTMLElement>;
|
||||
className?: string;
|
||||
alignmentFactors?: BarGaugeAlignmentFactors;
|
||||
}
|
||||
|
||||
export class BarGauge extends PureComponent<Props> {
|
||||
@ -137,7 +152,7 @@ export class BarGauge extends PureComponent<Props> {
|
||||
}
|
||||
|
||||
renderRetroBars(): ReactNode {
|
||||
const { maxValue, minValue, value, itemSpacing } = this.props;
|
||||
const { maxValue, minValue, value, itemSpacing, alignmentFactors, orientation } = this.props;
|
||||
const {
|
||||
valueHeight,
|
||||
valueWidth,
|
||||
@ -147,7 +162,7 @@ export class BarGauge extends PureComponent<Props> {
|
||||
wrapperHeight,
|
||||
} = calculateBarAndValueDimensions(this.props);
|
||||
|
||||
const isVert = isVertical(this.props);
|
||||
const isVert = isVertical(orientation);
|
||||
const valueRange = maxValue - minValue;
|
||||
const maxSize = isVert ? maxBarHeight : maxBarWidth;
|
||||
const cellSpacing = itemSpacing!;
|
||||
@ -155,7 +170,9 @@ export class BarGauge extends PureComponent<Props> {
|
||||
const cellCount = Math.floor(maxSize / cellWidth);
|
||||
const cellSize = Math.floor((maxSize - cellSpacing * cellCount) / cellCount);
|
||||
const valueColor = getValueColor(this.props);
|
||||
const valueStyles = getValueStyles(value.text, valueColor, valueWidth, valueHeight);
|
||||
|
||||
const valueTextToBaseSizeOn = alignmentFactors ? alignmentFactors.text : value.text;
|
||||
const valueStyles = getValueStyles(valueTextToBaseSizeOn, valueColor, valueWidth, valueHeight, orientation);
|
||||
|
||||
const containerStyles: CSSProperties = {
|
||||
width: `${wrapperWidth}px`,
|
||||
@ -166,11 +183,9 @@ export class BarGauge extends PureComponent<Props> {
|
||||
if (isVert) {
|
||||
containerStyles.flexDirection = 'column-reverse';
|
||||
containerStyles.alignItems = 'center';
|
||||
valueStyles.justifyContent = 'center';
|
||||
} else {
|
||||
containerStyles.flexDirection = 'row';
|
||||
containerStyles.alignItems = 'center';
|
||||
valueStyles.justifyContent = 'flex-end';
|
||||
}
|
||||
|
||||
const cells: JSX.Element[] = [];
|
||||
@ -226,19 +241,19 @@ interface TitleDimensions {
|
||||
height: number;
|
||||
}
|
||||
|
||||
function isVertical(props: Props) {
|
||||
return props.orientation === VizOrientation.Vertical;
|
||||
function isVertical(orientation: VizOrientation) {
|
||||
return orientation === VizOrientation.Vertical;
|
||||
}
|
||||
|
||||
function calculateTitleDimensions(props: Props): TitleDimensions {
|
||||
const { title } = props.value;
|
||||
const { height, width } = props;
|
||||
const { height, width, alignmentFactors, orientation } = props;
|
||||
const title = alignmentFactors ? alignmentFactors.title : props.value.title;
|
||||
|
||||
if (!title) {
|
||||
return { fontSize: 0, width: 0, height: 0, placement: 'above' };
|
||||
}
|
||||
|
||||
if (isVertical(props)) {
|
||||
if (isVertical(orientation)) {
|
||||
return {
|
||||
fontSize: 14,
|
||||
width: width,
|
||||
@ -262,13 +277,14 @@ function calculateTitleDimensions(props: Props): TitleDimensions {
|
||||
|
||||
// title to left of bar scenario
|
||||
const maxTitleHeightRatio = 0.6;
|
||||
const maxTitleWidthRatio = 0.2;
|
||||
const titleHeight = Math.max(height * maxTitleHeightRatio, MIN_VALUE_HEIGHT);
|
||||
const titleFontSize = titleHeight / TITLE_LINE_HEIGHT;
|
||||
const textSize = measureText(title, titleFontSize);
|
||||
|
||||
return {
|
||||
fontSize: titleHeight / TITLE_LINE_HEIGHT,
|
||||
fontSize: titleFontSize,
|
||||
height: 0,
|
||||
width: Math.min(Math.max(width * maxTitleWidthRatio, 50), 200),
|
||||
width: textSize.width + 15,
|
||||
placement: 'left',
|
||||
};
|
||||
}
|
||||
@ -291,7 +307,7 @@ export function getTitleStyles(props: Props): { wrapper: CSSProperties; title: C
|
||||
alignSelf: 'center',
|
||||
};
|
||||
|
||||
if (isVertical(props)) {
|
||||
if (isVertical(props.orientation)) {
|
||||
wrapperStyles.flexDirection = 'column-reverse';
|
||||
titleStyles.textAlign = 'center';
|
||||
} else {
|
||||
@ -328,7 +344,7 @@ interface BarAndValueDimensions {
|
||||
}
|
||||
|
||||
function calculateBarAndValueDimensions(props: Props): BarAndValueDimensions {
|
||||
const { height, width } = props;
|
||||
const { height, width, orientation } = props;
|
||||
const titleDim = calculateTitleDimensions(props);
|
||||
|
||||
let maxBarHeight = 0;
|
||||
@ -338,7 +354,7 @@ function calculateBarAndValueDimensions(props: Props): BarAndValueDimensions {
|
||||
let wrapperWidth = 0;
|
||||
let wrapperHeight = 0;
|
||||
|
||||
if (isVertical(props)) {
|
||||
if (isVertical(orientation)) {
|
||||
valueHeight = Math.min(Math.max(height * 0.1, MIN_VALUE_HEIGHT), MAX_VALUE_HEIGHT);
|
||||
valueWidth = width;
|
||||
maxBarHeight = height - (titleDim.height + valueHeight);
|
||||
@ -378,14 +394,16 @@ export function getValuePercent(value: number, minValue: number, maxValue: numbe
|
||||
* Only exported to for unit test
|
||||
*/
|
||||
export function getBasicAndGradientStyles(props: Props): BasicAndGradientStyles {
|
||||
const { displayMode, maxValue, minValue, value } = props;
|
||||
const { displayMode, maxValue, minValue, value, alignmentFactors, orientation } = props;
|
||||
const { valueWidth, valueHeight, maxBarHeight, maxBarWidth } = calculateBarAndValueDimensions(props);
|
||||
|
||||
const valuePercent = getValuePercent(value.numeric, minValue, maxValue);
|
||||
const valueColor = getValueColor(props);
|
||||
const valueStyles = getValueStyles(value.text, valueColor, valueWidth, valueHeight);
|
||||
const isBasic = displayMode === 'basic';
|
||||
|
||||
const valueTextToBaseSizeOn = alignmentFactors ? alignmentFactors.text : value.text;
|
||||
const valueStyles = getValueStyles(valueTextToBaseSizeOn, valueColor, valueWidth, valueHeight, orientation);
|
||||
|
||||
const isBasic = displayMode === 'basic';
|
||||
const wrapperStyles: CSSProperties = {
|
||||
display: 'flex',
|
||||
};
|
||||
@ -394,7 +412,7 @@ export function getBasicAndGradientStyles(props: Props): BasicAndGradientStyles
|
||||
borderRadius: '3px',
|
||||
};
|
||||
|
||||
if (isVertical(props)) {
|
||||
if (isVertical(orientation)) {
|
||||
const barHeight = Math.max(valuePercent * maxBarHeight, 1);
|
||||
|
||||
// vertical styles
|
||||
@ -405,9 +423,6 @@ export function getBasicAndGradientStyles(props: Props): BasicAndGradientStyles
|
||||
barStyles.height = `${barHeight}px`;
|
||||
barStyles.width = `${maxBarWidth}px`;
|
||||
|
||||
// value styles centered
|
||||
valueStyles.justifyContent = 'center';
|
||||
|
||||
if (isBasic) {
|
||||
// Basic styles
|
||||
barStyles.background = `${tinycolor(valueColor)
|
||||
@ -430,8 +445,6 @@ export function getBasicAndGradientStyles(props: Props): BasicAndGradientStyles
|
||||
barStyles.height = `${maxBarHeight}px`;
|
||||
barStyles.width = `${barWidth}px`;
|
||||
|
||||
valueStyles.paddingLeft = '10px';
|
||||
|
||||
if (isBasic) {
|
||||
// Basic styles
|
||||
barStyles.background = `${tinycolor(valueColor)
|
||||
@ -455,8 +468,8 @@ export function getBasicAndGradientStyles(props: Props): BasicAndGradientStyles
|
||||
* Only exported to for unit test
|
||||
*/
|
||||
export function getBarGradient(props: Props, maxSize: number): string {
|
||||
const { minValue, maxValue, thresholds, value } = props;
|
||||
const cssDirection = isVertical(props) ? '0deg' : '90deg';
|
||||
const { minValue, maxValue, thresholds, value, orientation } = props;
|
||||
const cssDirection = isVertical(orientation) ? '0deg' : '90deg';
|
||||
|
||||
let gradient = '';
|
||||
let lastpos = 0;
|
||||
@ -496,29 +509,42 @@ export function getValueColor(props: Props): string {
|
||||
return getColorFromHexRgbOrName('gray', theme.type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only exported to for unit test
|
||||
*/
|
||||
function getValueStyles(value: string, color: string, width: number, height: number): CSSProperties {
|
||||
const heightFont = height / VALUE_LINE_HEIGHT;
|
||||
const guess = width / (value.length * 1.1);
|
||||
const fontSize = Math.min(Math.max(guess, 14), heightFont);
|
||||
|
||||
return {
|
||||
function getValueStyles(
|
||||
value: string,
|
||||
color: string,
|
||||
width: number,
|
||||
height: number,
|
||||
orientation: VizOrientation
|
||||
): CSSProperties {
|
||||
const valueStyles: CSSProperties = {
|
||||
color: color,
|
||||
height: `${height}px`,
|
||||
width: `${width}px`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
lineHeight: VALUE_LINE_HEIGHT,
|
||||
fontSize: fontSize.toFixed(4) + 'px',
|
||||
};
|
||||
}
|
||||
|
||||
// function getTextWidth(text: string): number {
|
||||
// const canvas = getTextWidth.canvas || (getTextWidth.canvas = document.createElement("canvas"));
|
||||
// var context = canvas.getContext("2d");
|
||||
// context.font = "'Roboto', 'Helvetica Neue', Arial, sans-serif";
|
||||
// var metrics = context.measureText(text);
|
||||
// return metrics.width;
|
||||
// }
|
||||
// how many pixels in wide can the text be?
|
||||
let textWidth = width;
|
||||
|
||||
if (isVertical(orientation)) {
|
||||
valueStyles.justifyContent = `center`;
|
||||
} else {
|
||||
valueStyles.justifyContent = `flex-start`;
|
||||
valueStyles.paddingLeft = `${VALUE_LEFT_PADDING}px`;
|
||||
// Need to remove the left padding from the text width constraints
|
||||
textWidth -= VALUE_LEFT_PADDING;
|
||||
}
|
||||
|
||||
// calculate width in 14px
|
||||
const textSize = measureText(value, 14);
|
||||
// how much bigger than 14px can we make it while staying within our width constraints
|
||||
const fontSizeBasedOnWidth = (textWidth / (textSize.width + 2)) * 14;
|
||||
const fontSizeBasedOnHeight = height / VALUE_LINE_HEIGHT;
|
||||
|
||||
// final fontSize
|
||||
valueStyles.fontSize = Math.min(fontSizeBasedOnHeight, fontSizeBasedOnWidth).toFixed(4) + 'px';
|
||||
|
||||
return valueStyles;
|
||||
}
|
||||
|
@ -27,8 +27,9 @@ exports[`BarGauge Render with basic options should render 1`] = `
|
||||
"alignItems": "center",
|
||||
"color": "#73BF69",
|
||||
"display": "flex",
|
||||
"fontSize": "27.2727px",
|
||||
"fontSize": "175.0000px",
|
||||
"height": "300px",
|
||||
"justifyContent": "flex-start",
|
||||
"lineHeight": 1,
|
||||
"paddingLeft": "10px",
|
||||
"width": "60px",
|
||||
|
@ -1,12 +1,23 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { VizOrientation } from '@grafana/data';
|
||||
|
||||
interface Props<T> {
|
||||
renderValue: (value: T, width: number, height: number) => JSX.Element;
|
||||
interface Props<V, D> {
|
||||
/**
|
||||
* Optionally precalculate dimensions to support consistent behavior between repeated
|
||||
* values. Two typical patterns are:
|
||||
* 1) Calculate raw values like font size etc and pass them to each vis
|
||||
* 2) find the maximum input values and pass that to the vis
|
||||
*/
|
||||
getAlignmentFactors?: (values: V[], width: number, height: number) => D;
|
||||
|
||||
/**
|
||||
* Render a single value
|
||||
*/
|
||||
renderValue: (value: V, width: number, height: number, dims: D) => JSX.Element;
|
||||
height: number;
|
||||
width: number;
|
||||
source: any; // If this changes, new values will be requested
|
||||
getValues: () => T[];
|
||||
getValues: () => V[];
|
||||
renderCounter: number; // force update of values & render
|
||||
orientation: VizOrientation;
|
||||
itemSpacing?: number;
|
||||
@ -16,18 +27,18 @@ interface DefaultProps {
|
||||
itemSpacing: number;
|
||||
}
|
||||
|
||||
type PropsWithDefaults<T> = Props<T> & DefaultProps;
|
||||
type PropsWithDefaults<V, D> = Props<V, D> & DefaultProps;
|
||||
|
||||
interface State<T> {
|
||||
values: T[];
|
||||
interface State<V> {
|
||||
values: V[];
|
||||
}
|
||||
|
||||
export class VizRepeater<T> extends PureComponent<Props<T>, State<T>> {
|
||||
export class VizRepeater<V, D = {}> extends PureComponent<Props<V, D>, State<V>> {
|
||||
static defaultProps: DefaultProps = {
|
||||
itemSpacing: 10,
|
||||
};
|
||||
|
||||
constructor(props: Props<T>) {
|
||||
constructor(props: Props<V, D>) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
@ -35,7 +46,7 @@ export class VizRepeater<T> extends PureComponent<Props<T>, State<T>> {
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props<T>) {
|
||||
componentDidUpdate(prevProps: Props<V, D>) {
|
||||
const { renderCounter, source } = this.props;
|
||||
if (renderCounter !== prevProps.renderCounter || source !== prevProps.source) {
|
||||
this.setState({ values: this.props.getValues() });
|
||||
@ -57,7 +68,7 @@ export class VizRepeater<T> extends PureComponent<Props<T>, State<T>> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { renderValue, height, width, itemSpacing } = this.props as PropsWithDefaults<T>;
|
||||
const { renderValue, height, width, itemSpacing, getAlignmentFactors } = this.props as PropsWithDefaults<V, D>;
|
||||
const { values } = this.state;
|
||||
const orientation = this.getOrientation();
|
||||
|
||||
@ -87,12 +98,13 @@ export class VizRepeater<T> extends PureComponent<Props<T>, State<T>> {
|
||||
itemStyles.width = `${vizWidth}px`;
|
||||
itemStyles.height = `${vizHeight}px`;
|
||||
|
||||
const dims = getAlignmentFactors ? getAlignmentFactors(values, vizWidth, vizHeight) : ({} as D);
|
||||
return (
|
||||
<div style={repeaterStyle}>
|
||||
{values.map((value, index) => {
|
||||
return (
|
||||
<div key={index} style={itemStyles}>
|
||||
{renderValue(value, vizWidth, vizHeight)}
|
||||
{renderValue(value, vizWidth, vizHeight, dims)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
@ -53,8 +53,8 @@ export { Gauge } from './Gauge/Gauge';
|
||||
export { Graph } from './Graph/Graph';
|
||||
export { GraphLegend } from './Graph/GraphLegend';
|
||||
export { GraphWithLegend } from './Graph/GraphWithLegend';
|
||||
export { BarGauge, BarGaugeAlignmentFactors } from './BarGauge/BarGauge';
|
||||
export { GraphTooltipOptions } from './Graph/GraphTooltip/types';
|
||||
export { BarGauge } from './BarGauge/BarGauge';
|
||||
export { VizRepeater } from './VizRepeater/VizRepeater';
|
||||
export {
|
||||
LegendOptions,
|
||||
|
@ -3,6 +3,7 @@ export * from './validate';
|
||||
export * from './slate';
|
||||
export * from './dataLinks';
|
||||
export * from './tags';
|
||||
export * from './measureText';
|
||||
export { default as ansicolor } from './ansicolor';
|
||||
|
||||
// Export with a namespace
|
||||
|
27
packages/grafana-ui/src/utils/measureText.ts
Normal file
27
packages/grafana-ui/src/utils/measureText.ts
Normal file
@ -0,0 +1,27 @@
|
||||
let canvas: HTMLCanvasElement | null = null;
|
||||
const cache: Record<string, TextMetrics> = {};
|
||||
|
||||
export function measureText(text: string, fontSize: number): TextMetrics {
|
||||
const fontStyle = `${fontSize}px 'Roboto'`;
|
||||
const cacheKey = text + fontStyle;
|
||||
const fromCache = cache[cacheKey];
|
||||
|
||||
if (fromCache) {
|
||||
return fromCache;
|
||||
}
|
||||
|
||||
if (canvas === null) {
|
||||
canvas = document.createElement('canvas');
|
||||
}
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('Could not create context');
|
||||
}
|
||||
|
||||
context.font = fontStyle;
|
||||
const metrics = context.measureText(text);
|
||||
|
||||
cache[cacheKey] = metrics;
|
||||
return metrics;
|
||||
}
|
@ -4,16 +4,37 @@ import React, { PureComponent } from 'react';
|
||||
// Services & Utils
|
||||
import { config } from 'app/core/config';
|
||||
|
||||
// Components
|
||||
import { BarGauge, VizRepeater, DataLinksContextMenu } from '@grafana/ui';
|
||||
|
||||
// Types
|
||||
import { BarGauge, BarGaugeAlignmentFactors, VizRepeater, DataLinksContextMenu } from '@grafana/ui';
|
||||
import { BarGaugeOptions } from './types';
|
||||
import { getFieldDisplayValues, FieldDisplay, PanelProps } from '@grafana/data';
|
||||
import { getFieldLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
|
||||
|
||||
export class BarGaugePanel extends PureComponent<PanelProps<BarGaugeOptions>> {
|
||||
renderValue = (value: FieldDisplay, width: number, height: number): JSX.Element => {
|
||||
findMaximumInput = (values: FieldDisplay[], width: number, height: number): BarGaugeAlignmentFactors => {
|
||||
const info: BarGaugeAlignmentFactors = {
|
||||
title: '',
|
||||
text: '',
|
||||
};
|
||||
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const v = values[i].display;
|
||||
if (v.text && v.text.length > info.text.length) {
|
||||
info.text = v.text;
|
||||
}
|
||||
|
||||
if (v.title && v.title.length > info.title.length) {
|
||||
info.title = v.title;
|
||||
}
|
||||
}
|
||||
return info;
|
||||
};
|
||||
|
||||
renderValue = (
|
||||
value: FieldDisplay,
|
||||
width: number,
|
||||
height: number,
|
||||
alignmentFactors: BarGaugeAlignmentFactors
|
||||
): JSX.Element => {
|
||||
const { options } = this.props;
|
||||
const { field, display } = value;
|
||||
|
||||
@ -34,6 +55,7 @@ export class BarGaugePanel extends PureComponent<PanelProps<BarGaugeOptions>> {
|
||||
maxValue={field.max}
|
||||
onClick={openMenu}
|
||||
className={targetClassName}
|
||||
alignmentFactors={alignmentFactors}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
@ -65,6 +87,7 @@ export class BarGaugePanel extends PureComponent<PanelProps<BarGaugeOptions>> {
|
||||
return (
|
||||
<VizRepeater
|
||||
source={data}
|
||||
getAlignmentFactors={this.findMaximumInput}
|
||||
getValues={this.getValues}
|
||||
renderValue={this.renderValue}
|
||||
renderCounter={renderCounter}
|
||||
|
Loading…
Reference in New Issue
Block a user