mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-18 07:56:56 +00:00
feat: trace detail page actionables (#8761)
* feat: add table view with actionables to span details drawer attributes * feat: span actions functionality * refactor: overall improvements * feat: revert to key-value pair UI with hover actions * feat: add support for copying trace attribute value on click * refactor: prevent prop drilling and access return values from useTraceActions in AttributeActions * refactor: integrate filter conversion logic into useTraceActions for improved query handling
This commit is contained in:
parent
03359a40a2
commit
fdcad997f5
@ -0,0 +1,160 @@
|
|||||||
|
import { Button, Popover, Spin, Tooltip } from 'antd';
|
||||||
|
import GroupByIcon from 'assets/CustomIcons/GroupByIcon';
|
||||||
|
import { OPERATORS } from 'constants/antlrQueryConstants';
|
||||||
|
import { useTraceActions } from 'hooks/trace/useTraceActions';
|
||||||
|
import { ArrowDownToDot, ArrowUpFromDot, Copy, Ellipsis } from 'lucide-react';
|
||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
interface AttributeRecord {
|
||||||
|
field: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AttributeActionsProps {
|
||||||
|
record: AttributeRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AttributeActions({
|
||||||
|
record,
|
||||||
|
}: AttributeActionsProps): JSX.Element {
|
||||||
|
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||||
|
const [isFilterInLoading, setIsFilterInLoading] = useState<boolean>(false);
|
||||||
|
const [isFilterOutLoading, setIsFilterOutLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
onAddToQuery,
|
||||||
|
onGroupByAttribute,
|
||||||
|
onCopyFieldName,
|
||||||
|
onCopyFieldValue,
|
||||||
|
} = useTraceActions();
|
||||||
|
|
||||||
|
const textToCopy = useMemo(() => {
|
||||||
|
const str = record.value == null ? '' : String(record.value);
|
||||||
|
// Remove surrounding double-quotes only (e.g., JSON-encoded string values)
|
||||||
|
return str.replace(/^"|"$/g, '');
|
||||||
|
}, [record.value]);
|
||||||
|
|
||||||
|
const handleFilterIn = useCallback(async (): Promise<void> => {
|
||||||
|
if (!onAddToQuery || isFilterInLoading) return;
|
||||||
|
setIsFilterInLoading(true);
|
||||||
|
try {
|
||||||
|
await Promise.resolve(
|
||||||
|
onAddToQuery(record.field, record.value, OPERATORS['=']),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsFilterInLoading(false);
|
||||||
|
}
|
||||||
|
}, [onAddToQuery, record.field, record.value, isFilterInLoading]);
|
||||||
|
|
||||||
|
const handleFilterOut = useCallback(async (): Promise<void> => {
|
||||||
|
if (!onAddToQuery || isFilterOutLoading) return;
|
||||||
|
setIsFilterOutLoading(true);
|
||||||
|
try {
|
||||||
|
await Promise.resolve(
|
||||||
|
onAddToQuery(record.field, record.value, OPERATORS['!=']),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsFilterOutLoading(false);
|
||||||
|
}
|
||||||
|
}, [onAddToQuery, record.field, record.value, isFilterOutLoading]);
|
||||||
|
|
||||||
|
const handleGroupBy = useCallback((): void => {
|
||||||
|
if (onGroupByAttribute) {
|
||||||
|
onGroupByAttribute(record.field);
|
||||||
|
}
|
||||||
|
setIsOpen(false);
|
||||||
|
}, [onGroupByAttribute, record.field]);
|
||||||
|
|
||||||
|
const handleCopyFieldName = useCallback((): void => {
|
||||||
|
if (onCopyFieldName) {
|
||||||
|
onCopyFieldName(record.field);
|
||||||
|
}
|
||||||
|
setIsOpen(false);
|
||||||
|
}, [onCopyFieldName, record.field]);
|
||||||
|
|
||||||
|
const handleCopyFieldValue = useCallback((): void => {
|
||||||
|
if (onCopyFieldValue) {
|
||||||
|
onCopyFieldValue(textToCopy);
|
||||||
|
}
|
||||||
|
setIsOpen(false);
|
||||||
|
}, [onCopyFieldValue, textToCopy]);
|
||||||
|
|
||||||
|
const moreActionsContent = (
|
||||||
|
<div className="attribute-actions-menu">
|
||||||
|
<Button
|
||||||
|
className="group-by-clause"
|
||||||
|
type="text"
|
||||||
|
icon={<GroupByIcon />}
|
||||||
|
onClick={handleGroupBy}
|
||||||
|
block
|
||||||
|
>
|
||||||
|
Group By Attribute
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<Copy size={14} />}
|
||||||
|
onClick={handleCopyFieldName}
|
||||||
|
block
|
||||||
|
>
|
||||||
|
Copy Field Name
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<Copy size={14} />}
|
||||||
|
onClick={handleCopyFieldValue}
|
||||||
|
block
|
||||||
|
>
|
||||||
|
Copy Field Value
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="action-btn">
|
||||||
|
<Tooltip title="Filter for value">
|
||||||
|
<Button
|
||||||
|
className="filter-btn periscope-btn"
|
||||||
|
aria-label="Filter for value"
|
||||||
|
disabled={isFilterInLoading}
|
||||||
|
icon={
|
||||||
|
isFilterInLoading ? (
|
||||||
|
<Spin size="small" />
|
||||||
|
) : (
|
||||||
|
<ArrowDownToDot size={14} style={{ transform: 'rotate(90deg)' }} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={handleFilterIn}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Filter out value">
|
||||||
|
<Button
|
||||||
|
className="filter-btn periscope-btn"
|
||||||
|
aria-label="Filter out value"
|
||||||
|
disabled={isFilterOutLoading}
|
||||||
|
icon={
|
||||||
|
isFilterOutLoading ? (
|
||||||
|
<Spin size="small" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpFromDot size={14} style={{ transform: 'rotate(90deg)' }} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={handleFilterOut}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Popover
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={setIsOpen}
|
||||||
|
arrow={false}
|
||||||
|
content={moreActionsContent}
|
||||||
|
rootClassName="attribute-actions-content"
|
||||||
|
trigger="hover"
|
||||||
|
placement="bottomLeft"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={<Ellipsis size={14} />}
|
||||||
|
className="filter-btn periscope-btn"
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -24,6 +24,13 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.item-key {
|
.item-key {
|
||||||
color: var(--bg-vanilla-100);
|
color: var(--bg-vanilla-100);
|
||||||
@ -40,11 +47,12 @@
|
|||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
max-width: 100%;
|
max-width: calc(100% - 120px); /* Reserve space for action buttons */
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
border-radius: 50px;
|
border-radius: 50px;
|
||||||
border: 1px solid var(--bg-slate-400);
|
border: 1px solid var(--bg-slate-400);
|
||||||
background: var(--bg-slate-500);
|
background: var(--bg-slate-500);
|
||||||
|
|
||||||
.item-value {
|
.item-value {
|
||||||
color: var(--bg-vanilla-400);
|
color: var(--bg-vanilla-400);
|
||||||
font-family: Inter;
|
font-family: Inter;
|
||||||
@ -55,6 +63,35 @@
|
|||||||
letter-spacing: 0.56px;
|
letter-spacing: 0.56px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
gap: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px;
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--bg-slate-400);
|
||||||
|
padding: 4px;
|
||||||
|
gap: 3px;
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-slate-300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,6 +100,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.attribute-actions-menu {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.ant-btn {
|
||||||
|
text-align: left;
|
||||||
|
height: auto;
|
||||||
|
padding: 6px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--bg-slate-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-by-clause {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.attribute-actions-content {
|
||||||
|
.ant-popover-inner {
|
||||||
|
padding: 8px;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.lightMode {
|
.lightMode {
|
||||||
.attributes-corner {
|
.attributes-corner {
|
||||||
.attributes-container {
|
.attributes-container {
|
||||||
@ -79,6 +146,18 @@
|
|||||||
color: var(--bg-ink-400);
|
color: var(--bg-ink-400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
background: var(--bg-vanilla-200);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-vanilla-100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,4 +165,12 @@
|
|||||||
border-top: 1px solid var(--bg-vanilla-300);
|
border-top: 1px solid var(--bg-vanilla-300);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.attribute-actions-menu {
|
||||||
|
.ant-btn {
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--bg-vanilla-200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,11 +2,13 @@ import './Attributes.styles.scss';
|
|||||||
|
|
||||||
import { Input, Tooltip, Typography } from 'antd';
|
import { Input, Tooltip, Typography } from 'antd';
|
||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
|
import CopyClipboardHOC from 'components/Logs/CopyClipboardHOC';
|
||||||
import { flattenObject } from 'container/LogDetailedView/utils';
|
import { flattenObject } from 'container/LogDetailedView/utils';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Span } from 'types/api/trace/getTraceV2';
|
import { Span } from 'types/api/trace/getTraceV2';
|
||||||
|
|
||||||
import NoData from '../NoData/NoData';
|
import NoData from '../NoData/NoData';
|
||||||
|
import AttributeActions from './AttributeActions';
|
||||||
|
|
||||||
interface IAttributesProps {
|
interface IAttributesProps {
|
||||||
span: Span;
|
span: Span;
|
||||||
@ -53,10 +55,13 @@ function Attributes(props: IAttributesProps): JSX.Element {
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<div className="value-wrapper">
|
<div className="value-wrapper">
|
||||||
<Tooltip title={item.value}>
|
<Tooltip title={item.value}>
|
||||||
<Typography.Text className="item-value" ellipsis>
|
<CopyClipboardHOC entityKey={item.value} textToCopy={item.value}>
|
||||||
{item.value}
|
<Typography.Text className="item-value" ellipsis>
|
||||||
</Typography.Text>
|
{item.value}
|
||||||
|
</Typography.Text>
|
||||||
|
</CopyClipboardHOC>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<AttributeActions record={item} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
193
frontend/src/hooks/trace/useTraceActions.ts
Normal file
193
frontend/src/hooks/trace/useTraceActions.ts
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
|
||||||
|
import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBuilderV2/utils';
|
||||||
|
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||||
|
import { QueryBuilderKeys } from 'constants/queryBuilder';
|
||||||
|
import ROUTES from 'constants/routes';
|
||||||
|
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||||
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
|
import { useNotifications } from 'hooks/useNotifications';
|
||||||
|
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useQueryClient } from 'react-query';
|
||||||
|
import { useCopyToClipboard } from 'react-use';
|
||||||
|
import {
|
||||||
|
BaseAutocompleteData,
|
||||||
|
DataTypes,
|
||||||
|
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||||
|
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
import { DataSource } from 'types/common/queryBuilder';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
|
export interface UseTraceActionsReturn {
|
||||||
|
onAddToQuery: (
|
||||||
|
fieldKey: string,
|
||||||
|
fieldValue: string,
|
||||||
|
operator: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
onGroupByAttribute: (fieldKey: string) => Promise<void>;
|
||||||
|
onCopyFieldName: (fieldName: string) => void;
|
||||||
|
onCopyFieldValue: (fieldValue: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTraceActions = (): UseTraceActionsReturn => {
|
||||||
|
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { notifications } = useNotifications();
|
||||||
|
const [, setCopy] = useCopyToClipboard();
|
||||||
|
|
||||||
|
const removeExistingFieldFilters = useCallback(
|
||||||
|
(filters: TagFilterItem[], fieldKey: BaseAutocompleteData): TagFilterItem[] =>
|
||||||
|
filters.filter((filter: TagFilterItem) => filter.key?.key !== fieldKey.key),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getAutocompleteKey = useCallback(
|
||||||
|
async (fieldKey: string): Promise<BaseAutocompleteData> => {
|
||||||
|
const keysAutocompleteResponse = await queryClient.fetchQuery(
|
||||||
|
[QueryBuilderKeys.GET_AGGREGATE_KEYS, fieldKey],
|
||||||
|
async () =>
|
||||||
|
getAggregateKeys({
|
||||||
|
searchText: fieldKey,
|
||||||
|
aggregateOperator:
|
||||||
|
currentQuery.builder.queryData[0].aggregateOperator || '',
|
||||||
|
dataSource: DataSource.TRACES,
|
||||||
|
aggregateAttribute:
|
||||||
|
currentQuery.builder.queryData[0].aggregateAttribute?.key || '',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const keysAutocomplete: BaseAutocompleteData[] =
|
||||||
|
keysAutocompleteResponse.payload?.attributeKeys || [];
|
||||||
|
|
||||||
|
return chooseAutocompleteFromCustomValue(
|
||||||
|
keysAutocomplete,
|
||||||
|
fieldKey,
|
||||||
|
false,
|
||||||
|
DataTypes.String,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[queryClient, currentQuery.builder.queryData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onAddToQuery = useCallback(
|
||||||
|
async (
|
||||||
|
fieldKey: string,
|
||||||
|
fieldValue: string,
|
||||||
|
operator: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const existAutocompleteKey = await getAutocompleteKey(fieldKey);
|
||||||
|
const currentOperator = getOperatorValue(operator);
|
||||||
|
|
||||||
|
const nextQuery: Query = {
|
||||||
|
...currentQuery,
|
||||||
|
builder: {
|
||||||
|
...currentQuery.builder,
|
||||||
|
queryData: currentQuery.builder.queryData.map((item) => {
|
||||||
|
// Get existing filters and remove any for the same field
|
||||||
|
const currentFilters = item.filters?.items || [];
|
||||||
|
const cleanedFilters = removeExistingFieldFilters(
|
||||||
|
currentFilters,
|
||||||
|
existAutocompleteKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add the new filter to the cleaned list
|
||||||
|
const newFilters = [
|
||||||
|
...cleanedFilters,
|
||||||
|
{
|
||||||
|
id: uuid(),
|
||||||
|
key: existAutocompleteKey,
|
||||||
|
op: currentOperator,
|
||||||
|
value: fieldValue,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const convertedFilter = convertFiltersToExpressionWithExistingQuery(
|
||||||
|
{
|
||||||
|
items: newFilters,
|
||||||
|
op: item.filters?.op || 'AND',
|
||||||
|
},
|
||||||
|
item.filter?.expression || '',
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
dataSource: DataSource.TRACES,
|
||||||
|
filters: convertedFilter.filters,
|
||||||
|
filter: convertedFilter.filter,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
redirectWithQueryBuilderData(nextQuery, {}, ROUTES.TRACES_EXPLORER);
|
||||||
|
} catch {
|
||||||
|
notifications.error({ message: SOMETHING_WENT_WRONG });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
currentQuery,
|
||||||
|
notifications,
|
||||||
|
getAutocompleteKey,
|
||||||
|
redirectWithQueryBuilderData,
|
||||||
|
removeExistingFieldFilters,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onGroupByAttribute = useCallback(
|
||||||
|
async (fieldKey: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const existAutocompleteKey = await getAutocompleteKey(fieldKey);
|
||||||
|
|
||||||
|
const nextQuery: Query = {
|
||||||
|
...currentQuery,
|
||||||
|
builder: {
|
||||||
|
...currentQuery.builder,
|
||||||
|
queryData: currentQuery.builder.queryData.map((item) => ({
|
||||||
|
...item,
|
||||||
|
dataSource: DataSource.TRACES,
|
||||||
|
groupBy: [...item.groupBy, existAutocompleteKey],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
redirectWithQueryBuilderData(nextQuery, {}, ROUTES.TRACES_EXPLORER);
|
||||||
|
} catch {
|
||||||
|
notifications.error({ message: SOMETHING_WENT_WRONG });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
currentQuery,
|
||||||
|
notifications,
|
||||||
|
getAutocompleteKey,
|
||||||
|
redirectWithQueryBuilderData,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onCopyFieldName = useCallback(
|
||||||
|
(fieldName: string): void => {
|
||||||
|
setCopy(fieldName);
|
||||||
|
notifications.success({
|
||||||
|
message: 'Field name copied to clipboard',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setCopy, notifications],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onCopyFieldValue = useCallback(
|
||||||
|
(fieldValue: string): void => {
|
||||||
|
setCopy(fieldValue);
|
||||||
|
notifications.success({
|
||||||
|
message: 'Field value copied to clipboard',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setCopy, notifications],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
onAddToQuery,
|
||||||
|
onGroupByAttribute,
|
||||||
|
onCopyFieldName,
|
||||||
|
onCopyFieldValue,
|
||||||
|
};
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user