mirror of
https://github.com/grafana/grafana.git
synced 2024-11-29 12:14:08 -06:00
DS Picker: Add missing props and improve autoheight logic (#70393)
* DS Picker: Add `inputId` and `noDefault` options * DS Picker: Add `disabled` state * Add tests for `disabled` * Select default DS if `current` is not provided * Remove `width` from style * Move types next to components * Only calculate height when opening
This commit is contained in:
parent
9cf685cfda
commit
3bf8dc1397
@ -8,7 +8,7 @@ import { ModalRoot, ModalsProvider } from '@grafana/ui';
|
||||
import config from 'app/core/config';
|
||||
import { defaultFileUploadQuery } from 'app/plugins/datasource/grafana/types';
|
||||
|
||||
import { DataSourceDropdown } from './DataSourceDropdown';
|
||||
import { DataSourceDropdown, DataSourceDropdownProps } from './DataSourceDropdown';
|
||||
import * as utils from './utils';
|
||||
|
||||
const pluginMetaInfo: PluginMetaInfo = {
|
||||
@ -44,14 +44,8 @@ const MockDSBuiltIn = createDS('mock.datasource.builtin', 3, true);
|
||||
|
||||
const mockDSList = [mockDS1, mockDS2, MockDSBuiltIn];
|
||||
|
||||
const setup = (onChange = () => {}, current = mockDS1.name) => {
|
||||
const props = { onChange, current };
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
return render(<DataSourceDropdown {...props}></DataSourceDropdown>);
|
||||
};
|
||||
|
||||
async function setupOpenDropdown(user: UserEvent, onChange?: () => void, current?: string) {
|
||||
const dropdown = setup(onChange, current);
|
||||
async function setupOpenDropdown(user: UserEvent, props: DataSourceDropdownProps) {
|
||||
const dropdown = render(<DataSourceDropdown {...props}></DataSourceDropdown>);
|
||||
const searchBox = dropdown.container.querySelector('input');
|
||||
expect(searchBox).toBeInTheDocument();
|
||||
await user.click(searchBox!);
|
||||
@ -87,6 +81,10 @@ jest.mock('../../hooks', () => {
|
||||
};
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
});
|
||||
|
||||
const getListMock = jest.fn();
|
||||
const getInstanceSettingsMock = jest.fn();
|
||||
beforeEach(() => {
|
||||
@ -96,7 +94,7 @@ beforeEach(() => {
|
||||
|
||||
describe('DataSourceDropdown', () => {
|
||||
it('should render', () => {
|
||||
expect(() => setup()).not.toThrow();
|
||||
expect(() => render(<DataSourceDropdown onChange={jest.fn()}></DataSourceDropdown>)).not.toThrow();
|
||||
});
|
||||
|
||||
describe('configuration', () => {
|
||||
@ -121,7 +119,6 @@ describe('DataSourceDropdown', () => {
|
||||
current: mockDS1.name,
|
||||
...filters,
|
||||
};
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
const dropdown = render(<DataSourceDropdown {...props}></DataSourceDropdown>);
|
||||
|
||||
const searchBox = dropdown.container.querySelector('input');
|
||||
@ -130,40 +127,74 @@ describe('DataSourceDropdown', () => {
|
||||
expect(getListMock.mock.lastCall[0]).toEqual(filters);
|
||||
});
|
||||
|
||||
it('should dispaly the current selected DS in the selector', async () => {
|
||||
getInstanceSettingsMock.mockReturnValue(mockDS2);
|
||||
render(<DataSourceDropdown onChange={jest.fn()} current={mockDS2}></DataSourceDropdown>);
|
||||
expect(screen.getByTestId('Select a data source')).toHaveAttribute('placeholder', mockDS2.name);
|
||||
expect(screen.getByAltText(`${mockDS2.meta.name} logo`)).toBeVisible();
|
||||
});
|
||||
|
||||
it('should display the current ds on top', async () => {
|
||||
//Mock ds is set as current, it appears on top
|
||||
getInstanceSettingsMock.mockReturnValue(mockDS1);
|
||||
await setupOpenDropdown(user, jest.fn(), mockDS1.name);
|
||||
await setupOpenDropdown(user, { onChange: jest.fn(), current: mockDS1.name });
|
||||
let cards = await screen.findAllByTestId('data-source-card');
|
||||
expect(await findByText(cards[0], mockDS1.name, { selector: 'span' })).toBeInTheDocument();
|
||||
|
||||
//xMock ds is set as current, it appears on top
|
||||
getInstanceSettingsMock.mockReturnValue(mockDS2);
|
||||
await setupOpenDropdown(user, jest.fn(), mockDS2.name);
|
||||
await setupOpenDropdown(user, { onChange: jest.fn(), current: mockDS2.name });
|
||||
cards = await screen.findAllByTestId('data-source-card');
|
||||
expect(await findByText(cards[0], mockDS2.name, { selector: 'span' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should dispaly the default DS as selected when `current` is not set', async () => {
|
||||
getInstanceSettingsMock.mockReturnValue(mockDS2);
|
||||
render(<DataSourceDropdown onChange={jest.fn()} current={undefined}></DataSourceDropdown>);
|
||||
expect(screen.getByTestId('Select a data source')).toHaveAttribute('placeholder', mockDS2.name);
|
||||
expect(screen.getByAltText(`${mockDS2.meta.name} logo`)).toBeVisible();
|
||||
});
|
||||
|
||||
it('should get the sorting function using the correct parameters', async () => {
|
||||
//The actual sorting is tested in utils.test but let's make sure we're calling getDataSourceCompareFn with the correct parameters
|
||||
const spy = jest.spyOn(utils, 'getDataSourceCompareFn');
|
||||
await setupOpenDropdown(user);
|
||||
await setupOpenDropdown(user, { onChange: jest.fn(), current: mockDS1 });
|
||||
|
||||
expect(spy.mock.lastCall).toEqual([mockDS1, [mockDS2.name], ['${foo}']]);
|
||||
});
|
||||
|
||||
it('should disable the dropdown when `disabled` is true', () => {
|
||||
render(<DataSourceDropdown onChange={jest.fn()} disabled></DataSourceDropdown>);
|
||||
expect(screen.getByTestId('Select a data source')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should assign the correct `id` to the input element to pair it with a label', () => {
|
||||
render(<DataSourceDropdown onChange={jest.fn()} inputId={'custom.input.id'}></DataSourceDropdown>);
|
||||
expect(screen.getByTestId('Select a data source')).toHaveAttribute('id', 'custom.input.id');
|
||||
});
|
||||
|
||||
it('should not set the default DS when setting `noDefault` to true and `current` is not provided', () => {
|
||||
render(<DataSourceDropdown onChange={jest.fn()} current={null} noDefault></DataSourceDropdown>);
|
||||
getListMock.mockClear();
|
||||
getInstanceSettingsMock.mockClear();
|
||||
// Doesn't try to get the default DS
|
||||
expect(getListMock).not.toBeCalled();
|
||||
expect(getInstanceSettingsMock).not.toBeCalled();
|
||||
expect(screen.getByTestId('Select a data source')).toHaveAttribute('placeholder', 'Select a data source');
|
||||
});
|
||||
});
|
||||
|
||||
describe('interactions', () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
it('should open when clicked', async () => {
|
||||
await setupOpenDropdown(user);
|
||||
await setupOpenDropdown(user, { onChange: jest.fn() });
|
||||
expect(await screen.findByText(mockDS1.name, { selector: 'span' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onChange when a data source is clicked', async () => {
|
||||
const onChange = jest.fn();
|
||||
await setupOpenDropdown(user, onChange);
|
||||
await setupOpenDropdown(user, { onChange });
|
||||
|
||||
await user.click(await screen.findByText(mockDS2.name, { selector: 'span' }));
|
||||
expect(onChange.mock.lastCall[0]['name']).toEqual(mockDS2.name);
|
||||
@ -172,7 +203,7 @@ describe('DataSourceDropdown', () => {
|
||||
|
||||
it('should push recently used datasources when a data source is clicked', async () => {
|
||||
const onChange = jest.fn();
|
||||
await setupOpenDropdown(user, onChange);
|
||||
await setupOpenDropdown(user, { onChange });
|
||||
|
||||
await user.click(await screen.findByText(mockDS2.name, { selector: 'span' }));
|
||||
expect(pushRecentlyUsedDataSourceMock.mock.lastCall[0]).toEqual(mockDS2);
|
||||
@ -180,7 +211,7 @@ describe('DataSourceDropdown', () => {
|
||||
|
||||
it('should be navigatable by keyboard', async () => {
|
||||
const onChange = jest.fn();
|
||||
await setupOpenDropdown(user, onChange);
|
||||
await setupOpenDropdown(user, { onChange });
|
||||
|
||||
await user.keyboard('[ArrowDown]');
|
||||
//Arrow down, second item is selected
|
||||
@ -202,7 +233,7 @@ describe('DataSourceDropdown', () => {
|
||||
});
|
||||
|
||||
it('should be searchable', async () => {
|
||||
await setupOpenDropdown(user);
|
||||
await setupOpenDropdown(user, { onChange: jest.fn() });
|
||||
|
||||
await user.keyboard(mockDS2.name); //Search for xMockDS
|
||||
|
||||
@ -220,7 +251,7 @@ describe('DataSourceDropdown', () => {
|
||||
it('should call onChange with the default query when add csv is clicked', async () => {
|
||||
config.featureToggles.editPanelCSVDragAndDrop = true;
|
||||
const onChange = jest.fn();
|
||||
await setupOpenDropdown(user, onChange);
|
||||
await setupOpenDropdown(user, { onChange });
|
||||
|
||||
await user.click(await screen.findByText('Add csv or spreadsheet'));
|
||||
|
||||
@ -231,7 +262,6 @@ describe('DataSourceDropdown', () => {
|
||||
|
||||
it('should open the modal when open advanced is clicked', async () => {
|
||||
const props = { onChange: jest.fn(), current: mockDS1.name };
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
render(
|
||||
<ModalsProvider>
|
||||
<DataSourceDropdown {...props}></DataSourceDropdown>
|
||||
|
@ -3,11 +3,12 @@ import { useDialog } from '@react-aria/dialog';
|
||||
import { useOverlay } from '@react-aria/overlays';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { usePopper } from 'react-popper';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { DataQuery, DataSourceJsonData } from '@grafana/schema';
|
||||
import { DataQuery, DataSourceJsonData, DataSourceRef } from '@grafana/schema';
|
||||
import { Button, CustomScrollbar, Icon, Input, ModalsController, Portal, useStyles2 } from '@grafana/ui';
|
||||
import config from 'app/core/config';
|
||||
import { useKeyNavigationListener } from 'app/features/search/hooks/useSearchKeyboardSelection';
|
||||
@ -19,7 +20,6 @@ import { DataSourceList } from './DataSourceList';
|
||||
import { DataSourceLogo, DataSourceLogoPlaceHolder } from './DataSourceLogo';
|
||||
import { DataSourceModal } from './DataSourceModal';
|
||||
import { applyMaxSize, maxSize } from './popperModifiers';
|
||||
import { PickerContentProps, DataSourceDropdownProps } from './types';
|
||||
import { dataSourceLabel, matchDataSourceWithSearch } from './utils';
|
||||
|
||||
const INTERACTION_EVENT_NAME = 'dashboards_dspicker_clicked';
|
||||
@ -31,8 +31,40 @@ const INTERACTION_ITEM = {
|
||||
CONFIG_NEW_DS_EMPTY_STATE: 'config_new_ds_empty_state',
|
||||
};
|
||||
|
||||
export interface DataSourceDropdownProps {
|
||||
onChange: (ds: DataSourceInstanceSettings<DataSourceJsonData>, defaultQueries?: DataQuery[] | GrafanaQuery[]) => void;
|
||||
current?: DataSourceInstanceSettings<DataSourceJsonData> | string | DataSourceRef | null | undefined;
|
||||
recentlyUsed?: string[];
|
||||
hideTextValue?: boolean;
|
||||
width?: number;
|
||||
inputId?: string;
|
||||
noDefault?: boolean;
|
||||
disabled?: boolean;
|
||||
|
||||
// DS filters
|
||||
tracing?: boolean;
|
||||
mixed?: boolean;
|
||||
dashboard?: boolean;
|
||||
metrics?: boolean;
|
||||
type?: string | string[];
|
||||
annotations?: boolean;
|
||||
variables?: boolean;
|
||||
alerting?: boolean;
|
||||
pluginId?: string;
|
||||
logs?: boolean;
|
||||
}
|
||||
|
||||
export function DataSourceDropdown(props: DataSourceDropdownProps) {
|
||||
const { current, onChange, hideTextValue, width, ...restProps } = props;
|
||||
const {
|
||||
current,
|
||||
onChange,
|
||||
hideTextValue = false,
|
||||
width,
|
||||
inputId,
|
||||
noDefault = false,
|
||||
disabled = false,
|
||||
...restProps
|
||||
} = props;
|
||||
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
const [inputHasFocus, setInputHasFocus] = useState(false);
|
||||
@ -44,6 +76,10 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) {
|
||||
setOpen(true);
|
||||
markerElement?.focus();
|
||||
};
|
||||
const currentDataSourceInstanceSettings = useDatasource(current);
|
||||
const currentValue = Boolean(!current && noDefault) ? undefined : currentDataSourceInstanceSettings;
|
||||
const prefixIcon =
|
||||
filterTerm && isOpen ? <DataSourceLogoPlaceHolder /> : <DataSourceLogo dataSource={currentValue} />;
|
||||
|
||||
const { onKeyDown, keyboardEvents } = useKeyNavigationListener();
|
||||
|
||||
@ -79,8 +115,6 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) {
|
||||
onChange(grafanaDS, [defaultFileUploadQuery]);
|
||||
};
|
||||
|
||||
const currentDataSourceInstanceSettings = useDatasource(current);
|
||||
|
||||
const popper = usePopper(markerElement, selectorElement, {
|
||||
placement: 'bottom-start',
|
||||
modifiers: [
|
||||
@ -114,25 +148,20 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) {
|
||||
);
|
||||
const { dialogProps } = useDialog({}, ref);
|
||||
|
||||
const styles = useStyles2(getStylesDropdown);
|
||||
const styles = useStyles2((theme: GrafanaTheme2) => getStylesDropdown(theme, props));
|
||||
|
||||
return (
|
||||
<div className={styles.container} data-testid={selectors.components.DataSourcePicker.container} style={{ width }}>
|
||||
<div className={styles.container} data-testid={selectors.components.DataSourcePicker.container}>
|
||||
{/* This clickable div is just extending the clickable area on the input element to include the prefix and suffix. */}
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div className={styles.trigger} onClick={openDropdown}>
|
||||
<Input
|
||||
id={inputId || 'data-source-picker'}
|
||||
className={inputHasFocus ? undefined : styles.input}
|
||||
data-testid={selectors.components.DataSourcePicker.inputV2}
|
||||
prefix={
|
||||
filterTerm && isOpen ? (
|
||||
<DataSourceLogoPlaceHolder />
|
||||
) : (
|
||||
<DataSourceLogo dataSource={currentDataSourceInstanceSettings} />
|
||||
)
|
||||
}
|
||||
prefix={currentValue ? prefixIcon : undefined}
|
||||
suffix={<Icon name={isOpen ? 'search' : 'angle-down'} />}
|
||||
placeholder={hideTextValue ? '' : dataSourceLabel(currentDataSourceInstanceSettings)}
|
||||
placeholder={hideTextValue ? '' : dataSourceLabel(currentValue)}
|
||||
onClick={openDropdown}
|
||||
onFocus={() => {
|
||||
setInputHasFocus(true);
|
||||
@ -148,6 +177,7 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) {
|
||||
setFilterTerm(e.currentTarget.value);
|
||||
}}
|
||||
ref={setMarkerElement}
|
||||
disabled={disabled}
|
||||
></Input>
|
||||
</div>
|
||||
{isOpen ? (
|
||||
@ -172,7 +202,7 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) {
|
||||
onChange(ds, defaultQueries);
|
||||
}}
|
||||
onClose={onClose}
|
||||
current={currentDataSourceInstanceSettings}
|
||||
current={currentValue}
|
||||
style={popper.styles.popper}
|
||||
ref={setSelectorElement}
|
||||
onClickAddCSV={onClickAddCSV}
|
||||
@ -187,25 +217,34 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function getStylesDropdown(theme: GrafanaTheme2) {
|
||||
function getStylesDropdown(theme: GrafanaTheme2, props: DataSourceDropdownProps) {
|
||||
return {
|
||||
container: css`
|
||||
position: relative;
|
||||
cursor: ${props.disabled ? 'not-allowed' : 'pointer'};
|
||||
width: ${theme.spacing(props.width || 'auto')};
|
||||
`,
|
||||
trigger: css`
|
||||
cursor: pointer;
|
||||
${props.disabled && `pointer-events: none;`}
|
||||
`,
|
||||
input: css`
|
||||
input {
|
||||
cursor: pointer;
|
||||
}
|
||||
input::placeholder {
|
||||
color: ${theme.colors.text.primary};
|
||||
color: ${props.disabled ? theme.colors.action.disabledText : theme.colors.text.primary};
|
||||
}
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
export interface PickerContentProps extends DataSourceDropdownProps {
|
||||
onClickAddCSV?: () => void;
|
||||
keyboardEvents: Observable<React.KeyboardEvent>;
|
||||
style: React.CSSProperties;
|
||||
filterTerm?: string;
|
||||
onClose: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
const PickerContent = React.forwardRef<HTMLDivElement, PickerContentProps>((props, ref) => {
|
||||
const { filterTerm, onChange, onClose, onClickAddCSV, current } = props;
|
||||
const changeCallback = useCallback(
|
||||
|
@ -40,9 +40,19 @@ interface DataSourceModalProps {
|
||||
current: DataSourceRef | string | null | undefined;
|
||||
onDismiss: () => void;
|
||||
recentlyUsed?: string[];
|
||||
dashboard?: boolean;
|
||||
mixed?: boolean;
|
||||
reportedInteractionFrom?: string;
|
||||
|
||||
// DS filters
|
||||
tracing?: boolean;
|
||||
mixed?: boolean;
|
||||
dashboard?: boolean;
|
||||
metrics?: boolean;
|
||||
type?: string | string[];
|
||||
annotations?: boolean;
|
||||
variables?: boolean;
|
||||
alerting?: boolean;
|
||||
pluginId?: string;
|
||||
logs?: boolean;
|
||||
}
|
||||
|
||||
export function DataSourceModal({
|
||||
|
@ -6,8 +6,7 @@ import {
|
||||
} from '@grafana/runtime';
|
||||
import { config } from 'app/core/config';
|
||||
|
||||
import { DataSourceDropdown } from './DataSourceDropdown';
|
||||
import { DataSourceDropdownProps } from './types';
|
||||
import { DataSourceDropdown, DataSourceDropdownProps } from './DataSourceDropdown';
|
||||
|
||||
type DataSourcePickerProps = DeprecatedDataSourcePickerProps | DataSourceDropdownProps;
|
||||
|
||||
|
@ -31,8 +31,16 @@ export const applyMaxSize: Modifier<'applyMaxSize', {}> = {
|
||||
requires: ['maxSize'],
|
||||
fn({ state }: ModifierArguments<{}>) {
|
||||
const { height, width } = state.modifiersData.maxSize;
|
||||
state.styles.popper.maxHeight = `${height - MODAL_MARGIN}px`;
|
||||
state.styles.popper.minHeight = `${FLIP_THRESHOLD}px`;
|
||||
state.styles.popper.maxWidth = width;
|
||||
|
||||
if (!state.styles.popper.maxHeight) {
|
||||
state.styles.popper.maxHeight = `${height - MODAL_MARGIN}px`;
|
||||
}
|
||||
if (!state.styles.popper.minHeight) {
|
||||
state.styles.popper.minHeight = `${FLIP_THRESHOLD}px`;
|
||||
}
|
||||
|
||||
if (!state.styles.popper.maxWidth) {
|
||||
state.styles.popper.maxWidth = width;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
@ -1,35 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { DataSourceInstanceSettings } from '@grafana/data';
|
||||
import { DataQuery, DataSourceJsonData, DataSourceRef } from '@grafana/schema';
|
||||
import { GrafanaQuery } from 'app/plugins/datasource/grafana/types';
|
||||
|
||||
export interface DataSourceDropdownProps {
|
||||
onChange: (ds: DataSourceInstanceSettings<DataSourceJsonData>, defaultQueries?: DataQuery[] | GrafanaQuery[]) => void;
|
||||
current: DataSourceInstanceSettings<DataSourceJsonData> | string | DataSourceRef | null | undefined;
|
||||
tracing?: boolean;
|
||||
mixed?: boolean;
|
||||
dashboard?: boolean;
|
||||
metrics?: boolean;
|
||||
type?: string | string[];
|
||||
annotations?: boolean;
|
||||
variables?: boolean;
|
||||
alerting?: boolean;
|
||||
pluginId?: string;
|
||||
logs?: boolean;
|
||||
recentlyUsed?: string[];
|
||||
hideTextValue?: boolean;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export interface PickerContentProps extends DataSourceDropdownProps {
|
||||
onClickAddCSV?: () => void;
|
||||
keyboardEvents: Observable<React.KeyboardEvent>;
|
||||
style: React.CSSProperties;
|
||||
filterTerm?: string;
|
||||
dashboard?: boolean;
|
||||
mixed?: boolean;
|
||||
onClose: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
@ -20,7 +20,7 @@ export function dataSourceLabel(
|
||||
dataSource: DataSourceInstanceSettings<DataSourceJsonData> | string | DataSourceRef | null | undefined
|
||||
) {
|
||||
if (!dataSource) {
|
||||
return 'Unknown';
|
||||
return 'Select a data source';
|
||||
}
|
||||
|
||||
if (typeof dataSource === 'string') {
|
||||
@ -35,7 +35,7 @@ export function dataSourceLabel(
|
||||
return `${dataSource.uid} - not found`;
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
return 'Select a data source';
|
||||
}
|
||||
|
||||
export function getDataSourceCompareFn(
|
||||
|
@ -46,10 +46,6 @@ export function useDatasources(filters: GetDataSourceListFilters) {
|
||||
export function useDatasource(dataSource: string | DataSourceRef | DataSourceInstanceSettings | null | undefined) {
|
||||
const dataSourceSrv = getDataSourceSrv();
|
||||
|
||||
if (!dataSource) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof dataSource === 'string') {
|
||||
return dataSourceSrv.getInstanceSettings(dataSource);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user