FieldConfig: Set field config defaults based on config registry (#23376)
* Set field config defaults based on config registry * Rename FIeldPropertyEditorItem to FieldConfigPopertyItem * Remove console.log * Simplify overrides persistence model and add support for nested properties in overrides * Review updates
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Registry } from '../utils';
|
||||
import { FieldPropertyEditorItem } from '../types';
|
||||
import { FieldConfigPropertyItem } from '../types';
|
||||
|
||||
export class FieldConfigOptionsRegistry extends Registry<FieldPropertyEditorItem> {}
|
||||
export class FieldConfigOptionsRegistry extends Registry<FieldConfigPropertyItem> {}
|
||||
|
||||
@@ -2,12 +2,13 @@ import {
|
||||
FieldOverrideEnv,
|
||||
findNumericFieldMinMax,
|
||||
setFieldConfigDefaults,
|
||||
setDynamicConfigValue,
|
||||
applyFieldOverrides,
|
||||
} from './fieldOverrides';
|
||||
import { MutableDataFrame, toDataFrame } from '../dataframe';
|
||||
import {
|
||||
FieldConfig,
|
||||
FieldPropertyEditorItem,
|
||||
FieldConfigPropertyItem,
|
||||
GrafanaTheme,
|
||||
FieldType,
|
||||
DataFrame,
|
||||
@@ -22,6 +23,7 @@ import { FieldConfigOptionsRegistry } from './FieldConfigOptionsRegistry';
|
||||
const property1 = {
|
||||
id: 'custom.property1', // Match field properties
|
||||
path: 'property1', // Match field properties
|
||||
isCustom: true,
|
||||
process: (value: any) => value,
|
||||
shouldApply: () => true,
|
||||
} as any;
|
||||
@@ -29,12 +31,21 @@ const property1 = {
|
||||
const property2 = {
|
||||
id: 'custom.property2', // Match field properties
|
||||
path: 'property2', // Match field properties
|
||||
isCustom: true,
|
||||
process: (value: any) => value,
|
||||
shouldApply: () => true,
|
||||
} as any;
|
||||
|
||||
export const customFieldRegistry: FieldConfigOptionsRegistry = new Registry<FieldPropertyEditorItem>(() => {
|
||||
return [property1, property2, ...mockStandardProperties()];
|
||||
const property3 = {
|
||||
id: 'custom.property3.nested', // Match field properties
|
||||
path: 'property3.nested', // Match field properties
|
||||
isCustom: true,
|
||||
process: (value: any) => value,
|
||||
shouldApply: () => true,
|
||||
} as any;
|
||||
|
||||
export const customFieldRegistry: FieldConfigOptionsRegistry = new Registry<FieldConfigPropertyItem>(() => {
|
||||
return [property1, property2, property3, ...mockStandardProperties()];
|
||||
});
|
||||
|
||||
describe('Global MinMax', () => {
|
||||
@@ -241,6 +252,7 @@ describe('setFieldConfigDefaults', () => {
|
||||
|
||||
expect(dsFieldConfig).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"custom": Object {},
|
||||
"decimals": 2,
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
@@ -276,10 +288,125 @@ describe('setFieldConfigDefaults', () => {
|
||||
expect(dsFieldConfig).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"custom": Object {
|
||||
"property1": 20,
|
||||
"property1": 10,
|
||||
"property2": 10,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setDynamicConfigValue', () => {
|
||||
it('applies dynamic config values', () => {
|
||||
const config = {
|
||||
title: 'test',
|
||||
// custom: {
|
||||
// property1: 1,
|
||||
// },
|
||||
};
|
||||
setDynamicConfigValue(
|
||||
config,
|
||||
{
|
||||
id: 'title',
|
||||
value: 'applied',
|
||||
},
|
||||
{
|
||||
fieldConfigRegistry: customFieldRegistry,
|
||||
data: [] as any,
|
||||
field: { type: FieldType.number } as any,
|
||||
dataFrameIndex: 0,
|
||||
}
|
||||
);
|
||||
|
||||
expect(config.title).toEqual('applied');
|
||||
});
|
||||
|
||||
it('applies custom dynamic config values', () => {
|
||||
const config = {
|
||||
custom: {
|
||||
property1: 1,
|
||||
},
|
||||
};
|
||||
setDynamicConfigValue(
|
||||
config,
|
||||
{
|
||||
id: 'custom.property1',
|
||||
value: 'applied',
|
||||
},
|
||||
{
|
||||
fieldConfigRegistry: customFieldRegistry,
|
||||
data: [] as any,
|
||||
field: { type: FieldType.number } as any,
|
||||
dataFrameIndex: 0,
|
||||
}
|
||||
);
|
||||
|
||||
expect(config.custom.property1).toEqual('applied');
|
||||
});
|
||||
|
||||
it('applies nested custom dynamic config values', () => {
|
||||
const config = {
|
||||
custom: {
|
||||
property3: {
|
||||
nested: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
setDynamicConfigValue(
|
||||
config,
|
||||
{
|
||||
id: 'custom.property3.nested',
|
||||
value: 'applied',
|
||||
},
|
||||
{
|
||||
fieldConfigRegistry: customFieldRegistry,
|
||||
data: [] as any,
|
||||
field: { type: FieldType.number } as any,
|
||||
dataFrameIndex: 0,
|
||||
}
|
||||
);
|
||||
|
||||
expect(config.custom.property3.nested).toEqual('applied');
|
||||
});
|
||||
|
||||
it('removes properties', () => {
|
||||
const config = {
|
||||
title: 'title',
|
||||
custom: {
|
||||
property3: {
|
||||
nested: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
setDynamicConfigValue(
|
||||
config,
|
||||
{
|
||||
id: 'custom.property3.nested',
|
||||
value: undefined,
|
||||
},
|
||||
{
|
||||
fieldConfigRegistry: customFieldRegistry,
|
||||
data: [] as any,
|
||||
field: { type: FieldType.number } as any,
|
||||
dataFrameIndex: 0,
|
||||
}
|
||||
);
|
||||
|
||||
setDynamicConfigValue(
|
||||
config,
|
||||
{
|
||||
id: 'title',
|
||||
value: undefined,
|
||||
},
|
||||
{
|
||||
fieldConfigRegistry: customFieldRegistry,
|
||||
data: [] as any,
|
||||
field: { type: FieldType.number } as any,
|
||||
dataFrameIndex: 0,
|
||||
}
|
||||
);
|
||||
|
||||
expect(config.custom.property3).toEqual({});
|
||||
expect(config.title).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,10 +10,14 @@ import {
|
||||
FieldOverrideContext,
|
||||
ScopedVars,
|
||||
ApplyFieldOverrideOptions,
|
||||
FieldConfigPropertyItem,
|
||||
} from '../types';
|
||||
import { fieldMatchers, ReducerID, reduceField } from '../transformations';
|
||||
import { FieldMatcher } from '../types/transformations';
|
||||
import isNumber from 'lodash/isNumber';
|
||||
import set from 'lodash/set';
|
||||
import unset from 'lodash/unset';
|
||||
import get from 'lodash/get';
|
||||
import { getDisplayProcessor } from './displayProcessor';
|
||||
import { guessFieldTypeForField } from '../dataframe';
|
||||
import { standardFieldConfigEditorRegistry } from './standardFieldConfigEditorRegistry';
|
||||
@@ -193,9 +197,8 @@ export interface FieldOverrideEnv extends FieldOverrideContext {
|
||||
fieldConfigRegistry: FieldConfigOptionsRegistry;
|
||||
}
|
||||
|
||||
function setDynamicConfigValue(config: FieldConfig, value: DynamicConfigValue, context: FieldOverrideEnv) {
|
||||
export function setDynamicConfigValue(config: FieldConfig, value: DynamicConfigValue, context: FieldOverrideEnv) {
|
||||
const reg = context.fieldConfigRegistry;
|
||||
|
||||
const item = reg.getIfExists(value.id);
|
||||
if (!item || !item.shouldApply(context.field!)) {
|
||||
return;
|
||||
@@ -206,19 +209,19 @@ function setDynamicConfigValue(config: FieldConfig, value: DynamicConfigValue, c
|
||||
const remove = val === undefined || val === null;
|
||||
|
||||
if (remove) {
|
||||
if (value.isCustom && config.custom) {
|
||||
delete config.custom[item.path];
|
||||
if (item.isCustom && config.custom) {
|
||||
unset(config.custom, item.path);
|
||||
} else {
|
||||
delete (config as any)[item.path];
|
||||
unset(config, item.path);
|
||||
}
|
||||
} else {
|
||||
if (value.isCustom) {
|
||||
if (item.isCustom) {
|
||||
if (!config.custom) {
|
||||
config.custom = {};
|
||||
}
|
||||
config.custom[item.path] = val;
|
||||
set(config.custom, item.path, val);
|
||||
} else {
|
||||
(config as any)[item.path] = val;
|
||||
set(config, item.path, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,26 +229,16 @@ function setDynamicConfigValue(config: FieldConfig, value: DynamicConfigValue, c
|
||||
// config -> from DS
|
||||
// defaults -> from Panel config
|
||||
export function setFieldConfigDefaults(config: FieldConfig, defaults: FieldConfig, context: FieldOverrideEnv) {
|
||||
if (defaults) {
|
||||
const keys = Object.keys(defaults);
|
||||
for (const key of keys) {
|
||||
if (key === 'custom') {
|
||||
if (!context.fieldConfigRegistry) {
|
||||
continue;
|
||||
}
|
||||
if (!config.custom) {
|
||||
config.custom = {};
|
||||
}
|
||||
|
||||
const customKeys = Object.keys(defaults.custom!);
|
||||
for (const customKey of customKeys) {
|
||||
processFieldConfigValue(config.custom!, defaults.custom!, `custom.${customKey}`, context);
|
||||
}
|
||||
} else {
|
||||
// when config from ds exists for a given field -> use it
|
||||
processFieldConfigValue(config, defaults, key, context);
|
||||
}
|
||||
for (const fieldConfigProperty of context.fieldConfigRegistry.list()) {
|
||||
if (fieldConfigProperty.isCustom && !config.custom) {
|
||||
config.custom = {};
|
||||
}
|
||||
processFieldConfigValue(
|
||||
fieldConfigProperty.isCustom ? config.custom : config,
|
||||
fieldConfigProperty.isCustom ? defaults.custom : defaults,
|
||||
fieldConfigProperty,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
validateFieldConfig(config);
|
||||
@@ -254,20 +247,21 @@ export function setFieldConfigDefaults(config: FieldConfig, defaults: FieldConfi
|
||||
const processFieldConfigValue = (
|
||||
destination: Record<string, any>, // it's mutable
|
||||
source: Record<string, any>,
|
||||
key: string,
|
||||
fieldConfigProperty: FieldConfigPropertyItem,
|
||||
context: FieldOverrideEnv
|
||||
) => {
|
||||
const currentConfig = destination[key];
|
||||
const currentConfig = get(destination, fieldConfigProperty.path);
|
||||
|
||||
if (currentConfig === null || currentConfig === undefined) {
|
||||
const item = context.fieldConfigRegistry.getIfExists(key);
|
||||
const item = context.fieldConfigRegistry.getIfExists(fieldConfigProperty.id);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item && item.shouldApply(context.field!)) {
|
||||
const val = item.process(source[item.path], context, item.settings);
|
||||
const val = item.process(get(source, item.path), context, item.settings);
|
||||
if (val !== undefined && val !== null) {
|
||||
destination[item.path] = val;
|
||||
set(destination, item.path, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +276,6 @@ export class PanelPlugin<TOptions = any, TFieldConfigOptions extends object = an
|
||||
}
|
||||
} else {
|
||||
for (const fieldConfigProp of standardFieldConfigEditorRegistry.list()) {
|
||||
console.log(fieldConfigProp);
|
||||
registry.register(fieldConfigProp);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import { OptionsEditorItem } from './OptionsUIRegistryBuilder';
|
||||
export interface DynamicConfigValue {
|
||||
id: string;
|
||||
value?: any;
|
||||
isCustom?: boolean;
|
||||
}
|
||||
|
||||
export interface ConfigOverrideRule {
|
||||
@@ -43,14 +42,14 @@ export interface FieldOverrideContext {
|
||||
|
||||
export interface FieldConfigEditorProps<TValue, TSettings>
|
||||
extends Omit<StandardEditorProps<TValue, TSettings>, 'item'> {
|
||||
item: FieldPropertyEditorItem<TValue, TSettings>; // The property info
|
||||
item: FieldConfigPropertyItem<TValue, TSettings>; // The property info
|
||||
value: TValue;
|
||||
context: FieldOverrideContext;
|
||||
onChange: (value?: TValue) => void;
|
||||
}
|
||||
|
||||
export interface FieldOverrideEditorProps<TValue, TSettings> extends Omit<StandardEditorProps<TValue>, 'item'> {
|
||||
item: FieldPropertyEditorItem<TValue, TSettings>;
|
||||
item: FieldConfigPropertyItem<TValue, TSettings>;
|
||||
context: FieldOverrideContext;
|
||||
}
|
||||
|
||||
@@ -63,7 +62,7 @@ export interface FieldConfigEditorConfig<TOptions, TSettings = any, TValue = any
|
||||
defaultValue?: TValue;
|
||||
}
|
||||
|
||||
export interface FieldPropertyEditorItem<TOptions = any, TValue = any, TSettings extends {} = any>
|
||||
export interface FieldConfigPropertyItem<TOptions = any, TValue = any, TSettings extends {} = any>
|
||||
extends OptionsEditorItem<TOptions, TSettings, FieldConfigEditorProps<TValue, TSettings>, TValue> {
|
||||
// An editor that can be filled in with context info (template variables etc)
|
||||
override: ComponentType<FieldOverrideEditorProps<TValue, TSettings>>;
|
||||
@@ -85,7 +84,7 @@ export interface ApplyFieldOverrideOptions {
|
||||
theme: GrafanaTheme;
|
||||
timeZone?: TimeZone;
|
||||
autoMinMax?: boolean;
|
||||
fieldConfigRegistry?: Registry<FieldPropertyEditorItem>;
|
||||
fieldConfigRegistry?: Registry<FieldConfigPropertyItem>;
|
||||
}
|
||||
|
||||
export enum FieldConfigProperty {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
FieldType,
|
||||
FieldConfigEditorProps,
|
||||
FieldPropertyEditorItem,
|
||||
FieldConfigPropertyItem,
|
||||
PanelOptionsEditorConfig,
|
||||
PanelOptionsEditorItem,
|
||||
FieldConfigEditorConfig,
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
export class FieldConfigEditorBuilder<TOptions> extends OptionsUIRegistryBuilder<
|
||||
TOptions,
|
||||
FieldConfigEditorProps<any, any>,
|
||||
FieldPropertyEditorItem<TOptions>
|
||||
FieldConfigPropertyItem<TOptions>
|
||||
> {
|
||||
addNumberInput<TSettings>(config: FieldConfigEditorConfig<TOptions, TSettings & NumberFieldConfigSettings, number>) {
|
||||
return this.addCustomEditor({
|
||||
|
||||
@@ -105,8 +105,8 @@ export const BarGaugeCell = () => {
|
||||
{
|
||||
matcher: { id: FieldMatcherID.byName, options: 'Progress' },
|
||||
properties: [
|
||||
{ id: 'width', value: '200', isCustom: true },
|
||||
{ id: 'displayMode', value: 'gradient-gauge', isCustom: true },
|
||||
{ id: 'width', value: '200' },
|
||||
{ id: 'displayMode', value: 'gradient-gauge' },
|
||||
{ id: 'min', value: '0' },
|
||||
{ id: 'max', value: '100' },
|
||||
],
|
||||
@@ -141,8 +141,8 @@ export const ColoredCells = () => {
|
||||
{
|
||||
matcher: { id: FieldMatcherID.byName, options: 'Progress' },
|
||||
properties: [
|
||||
{ id: 'width', value: '80', isCustom: true },
|
||||
{ id: 'displayMode', value: 'color-background', isCustom: true },
|
||||
{ id: 'width', value: '80' },
|
||||
{ id: 'displayMode', value: 'color-background' },
|
||||
{ id: 'min', value: '0' },
|
||||
{ id: 'max', value: '100' },
|
||||
{ id: 'thresholds', value: defaultThresholds },
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import {
|
||||
DataLink,
|
||||
dataLinksOverrideProcessor,
|
||||
FieldPropertyEditorItem,
|
||||
FieldConfigPropertyItem,
|
||||
FieldType,
|
||||
identityOverrideProcessor,
|
||||
NumberFieldConfigSettings,
|
||||
@@ -31,7 +31,7 @@ import { StatsPickerEditor } from '../components/OptionsUI/stats';
|
||||
* Returns collection of common field config properties definitions
|
||||
*/
|
||||
export const getStandardFieldConfigs = () => {
|
||||
const title: FieldPropertyEditorItem<any, string, StringFieldConfigSettings> = {
|
||||
const title: FieldConfigPropertyItem<any, string, StringFieldConfigSettings> = {
|
||||
id: 'title',
|
||||
path: 'title',
|
||||
name: 'Title',
|
||||
@@ -46,7 +46,7 @@ export const getStandardFieldConfigs = () => {
|
||||
shouldApply: field => field.type !== FieldType.time,
|
||||
};
|
||||
|
||||
const unit: FieldPropertyEditorItem<any, string, StringFieldConfigSettings> = {
|
||||
const unit: FieldConfigPropertyItem<any, string, StringFieldConfigSettings> = {
|
||||
id: 'unit',
|
||||
path: 'unit',
|
||||
name: 'Unit',
|
||||
@@ -63,7 +63,7 @@ export const getStandardFieldConfigs = () => {
|
||||
shouldApply: field => field.type === FieldType.number,
|
||||
};
|
||||
|
||||
const min: FieldPropertyEditorItem<any, number, NumberFieldConfigSettings> = {
|
||||
const min: FieldConfigPropertyItem<any, number, NumberFieldConfigSettings> = {
|
||||
id: 'min',
|
||||
path: 'min',
|
||||
name: 'Min',
|
||||
@@ -79,7 +79,7 @@ export const getStandardFieldConfigs = () => {
|
||||
shouldApply: field => field.type === FieldType.number,
|
||||
};
|
||||
|
||||
const max: FieldPropertyEditorItem<any, number, NumberFieldConfigSettings> = {
|
||||
const max: FieldConfigPropertyItem<any, number, NumberFieldConfigSettings> = {
|
||||
id: 'max',
|
||||
path: 'max',
|
||||
name: 'Max',
|
||||
@@ -96,7 +96,7 @@ export const getStandardFieldConfigs = () => {
|
||||
shouldApply: field => field.type === FieldType.number,
|
||||
};
|
||||
|
||||
const decimals: FieldPropertyEditorItem<any, number, NumberFieldConfigSettings> = {
|
||||
const decimals: FieldConfigPropertyItem<any, number, NumberFieldConfigSettings> = {
|
||||
id: 'decimals',
|
||||
path: 'decimals',
|
||||
name: 'Decimals',
|
||||
@@ -116,7 +116,7 @@ export const getStandardFieldConfigs = () => {
|
||||
shouldApply: field => field.type === FieldType.number,
|
||||
};
|
||||
|
||||
const thresholds: FieldPropertyEditorItem<any, ThresholdsConfig, ThresholdsFieldConfigSettings> = {
|
||||
const thresholds: FieldConfigPropertyItem<any, ThresholdsConfig, ThresholdsFieldConfigSettings> = {
|
||||
id: 'thresholds',
|
||||
path: 'thresholds',
|
||||
name: 'Thresholds',
|
||||
@@ -136,7 +136,7 @@ export const getStandardFieldConfigs = () => {
|
||||
shouldApply: field => field.type === FieldType.number,
|
||||
};
|
||||
|
||||
const mappings: FieldPropertyEditorItem<any, ValueMapping[], ValueMappingFieldConfigSettings> = {
|
||||
const mappings: FieldConfigPropertyItem<any, ValueMapping[], ValueMappingFieldConfigSettings> = {
|
||||
id: 'mappings',
|
||||
path: 'mappings',
|
||||
name: 'Value mappings',
|
||||
@@ -150,7 +150,7 @@ export const getStandardFieldConfigs = () => {
|
||||
shouldApply: field => field.type === FieldType.number,
|
||||
};
|
||||
|
||||
const noValue: FieldPropertyEditorItem<any, string, StringFieldConfigSettings> = {
|
||||
const noValue: FieldConfigPropertyItem<any, string, StringFieldConfigSettings> = {
|
||||
id: 'noValue',
|
||||
path: 'noValue',
|
||||
name: 'No Value',
|
||||
@@ -167,7 +167,7 @@ export const getStandardFieldConfigs = () => {
|
||||
shouldApply: () => true,
|
||||
};
|
||||
|
||||
const links: FieldPropertyEditorItem<any, DataLink[], StringFieldConfigSettings> = {
|
||||
const links: FieldConfigPropertyItem<any, DataLink[], StringFieldConfigSettings> = {
|
||||
id: 'links',
|
||||
path: 'links',
|
||||
name: 'DataLinks',
|
||||
@@ -181,7 +181,7 @@ export const getStandardFieldConfigs = () => {
|
||||
shouldApply: () => true,
|
||||
};
|
||||
|
||||
const color: FieldPropertyEditorItem<any, string, StringFieldConfigSettings> = {
|
||||
const color: FieldConfigPropertyItem<any, string, StringFieldConfigSettings> = {
|
||||
id: 'color',
|
||||
path: 'color',
|
||||
name: 'Color',
|
||||
|
||||
@@ -3,7 +3,7 @@ import cloneDeep from 'lodash/cloneDeep';
|
||||
import {
|
||||
FieldConfigSource,
|
||||
DataFrame,
|
||||
FieldPropertyEditorItem,
|
||||
FieldConfigPropertyItem,
|
||||
VariableSuggestionsScope,
|
||||
PanelPlugin,
|
||||
SelectableValue,
|
||||
@@ -142,7 +142,7 @@ export const DefaultFieldConfigEditor: React.FC<Props> = ({ data, onChange, conf
|
||||
);
|
||||
|
||||
const renderEditor = useCallback(
|
||||
(item: FieldPropertyEditorItem) => {
|
||||
(item: FieldConfigPropertyItem) => {
|
||||
const defaults = config.defaults;
|
||||
const value = item.isCustom
|
||||
? defaults.custom
|
||||
|
||||
@@ -49,10 +49,9 @@ export const OverrideEditor: React.FC<OverrideEditorProps> = ({ data, override,
|
||||
);
|
||||
|
||||
const onDynamicConfigValueAdd = useCallback(
|
||||
(id: string, custom?: boolean) => {
|
||||
(id: string) => {
|
||||
const propertyConfig: DynamicConfigValue = {
|
||||
id,
|
||||
isCustom: custom,
|
||||
};
|
||||
if (override.properties) {
|
||||
override.properties.push(propertyConfig);
|
||||
@@ -69,7 +68,6 @@ export const OverrideEditor: React.FC<OverrideEditorProps> = ({ data, override,
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
description: item.description,
|
||||
custom: item.isCustom,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -117,7 +115,7 @@ export const OverrideEditor: React.FC<OverrideEditorProps> = ({ data, override,
|
||||
options={configPropertiesOptions}
|
||||
variant={'link'}
|
||||
onChange={o => {
|
||||
onDynamicConfigValueAdd(o.value, o.custom);
|
||||
onDynamicConfigValueAdd(o.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user