mirror of
https://github.com/Kong/insomnia
synced 2024-11-07 22:30:15 +00:00
Add drag and drop to 'Manage Environments' (#5940)
* wip: environments drag and drop with react-aria Co-authored-by: James Gatz <jamesgatzos@gmail.com> * wip * wip * 1st working version * wip * working! * wip * rm outline * type issues * add redux hack * set active * refactor and rename * add aria label to fix warning --------- Co-authored-by: James Gatz <jamesgatzos@gmail.com> Co-authored-by: jackkav <jackkav@gmail.com>
This commit is contained in:
parent
953ed0d80c
commit
f5d6d529a5
@ -1,6 +1,8 @@
|
|||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import React, { FC, forwardRef, Fragment, useImperativeHandle, useRef, useState } from 'react';
|
import React, { FC, forwardRef, Fragment, useImperativeHandle, useRef, useState } from 'react';
|
||||||
|
import { ListDropTargetDelegate, ListKeyboardDelegate, mergeProps, useDraggableCollection, useDraggableItem, useDropIndicator, useDroppableCollection, useDroppableItem, useFocusRing, useListBox, useOption } from 'react-aria';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
import { DraggableCollectionState, DroppableCollectionState, Item, ListState, useDraggableCollectionState, useDroppableCollectionState, useListState } from 'react-stately';
|
||||||
|
|
||||||
import { docsTemplateTags } from '../../../common/documentation';
|
import { docsTemplateTags } from '../../../common/documentation';
|
||||||
import * as models from '../../../models';
|
import * as models from '../../../models';
|
||||||
@ -17,38 +19,24 @@ import { PromptButton } from '../base/prompt-button';
|
|||||||
import { EnvironmentEditor, EnvironmentEditorHandle } from '../editors/environment-editor';
|
import { EnvironmentEditor, EnvironmentEditorHandle } from '../editors/environment-editor';
|
||||||
import { HelpTooltip } from '../help-tooltip';
|
import { HelpTooltip } from '../help-tooltip';
|
||||||
import { Tooltip } from '../tooltip';
|
import { Tooltip } from '../tooltip';
|
||||||
|
|
||||||
const ROOT_ENVIRONMENT_NAME = 'Base Environment';
|
const ROOT_ENVIRONMENT_NAME = 'Base Environment';
|
||||||
|
|
||||||
interface SidebarListProps {
|
|
||||||
environments: Environment[];
|
|
||||||
changeEnvironmentName: (environment: Environment, name?: string) => void;
|
|
||||||
selectedEnvironmentId: string | null;
|
|
||||||
showEnvironment: (id: string) => void;
|
|
||||||
}
|
|
||||||
interface SidebarListItemProps {
|
interface SidebarListItemProps {
|
||||||
environment: Environment;
|
environment: Environment;
|
||||||
changeEnvironmentName: (environment: Environment, name?: string) => void;
|
|
||||||
selectedEnvironmentId: string | null;
|
|
||||||
showEnvironment: (id: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarListItem: FC<SidebarListItemProps> = ({
|
const SidebarListItem: FC<SidebarListItemProps> = ({
|
||||||
changeEnvironmentName,
|
|
||||||
environment,
|
environment,
|
||||||
selectedEnvironmentId,
|
|
||||||
showEnvironment,
|
|
||||||
}: SidebarListItemProps) => {
|
}: SidebarListItemProps) => {
|
||||||
const workspaceMeta = useSelector(selectActiveWorkspaceMeta);
|
const workspaceMeta = useSelector(selectActiveWorkspaceMeta);
|
||||||
|
return (
|
||||||
return (<li
|
<div
|
||||||
key={environment._id}
|
className={classnames({
|
||||||
className={classnames({
|
'env-modal__sidebar-item': true,
|
||||||
'env-modal__sidebar-item': true,
|
'env-modal__sidebar-item--active': workspaceMeta?.activeEnvironmentId === environment._id,
|
||||||
'env-modal__sidebar-item--active': selectedEnvironmentId === environment._id,
|
})}
|
||||||
// Specify theme because dragging will pull it out to <body>
|
>
|
||||||
'theme--dialog': true,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<button onClick={() => showEnvironment(environment._id)}>
|
|
||||||
{environment.color ? (
|
{environment.color ? (
|
||||||
<i
|
<i
|
||||||
className="space-right fa fa-circle"
|
className="space-right fa fa-circle"
|
||||||
@ -65,53 +53,177 @@ const SidebarListItem: FC<SidebarListItemProps> = ({
|
|||||||
<i className="fa fa-eye-slash faint space-right" />
|
<i className="fa fa-eye-slash faint space-right" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
<>{environment.name}</>
|
||||||
<Editable
|
</div>);
|
||||||
className="inline-block"
|
|
||||||
onSubmit={name => changeEnvironmentName(environment, name)}
|
|
||||||
value={environment.name}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div className="env-status">
|
|
||||||
{environment._id === workspaceMeta?.activeEnvironmentId ? (
|
|
||||||
<i className="fa fa-square active" title="Active Environment" />
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (environment && environment._id !== workspaceMeta?.activeEnvironmentId && workspaceMeta) {
|
|
||||||
models.workspaceMeta.update(workspaceMeta, { activeEnvironmentId: environment._id });
|
|
||||||
showEnvironment(environment._id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<i className="fa fa-square-o inactive" title="Click to activate Environment" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</li>);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const SidebarList: FC<SidebarListProps> =
|
// @ts-expect-error props any
|
||||||
({
|
const DropIndicator = props => {
|
||||||
changeEnvironmentName,
|
const ref = React.useRef(null);
|
||||||
environments,
|
const { dropIndicatorProps, isHidden, isDropTarget } =
|
||||||
selectedEnvironmentId,
|
useDropIndicator(props, props.dropState, ref);
|
||||||
showEnvironment,
|
if (isHidden) {
|
||||||
}: SidebarListProps) => {
|
return null;
|
||||||
return (
|
}
|
||||||
<ul>
|
|
||||||
{environments.map(environment =>
|
return (
|
||||||
(<SidebarListItem
|
<li
|
||||||
changeEnvironmentName={changeEnvironmentName}
|
{...dropIndicatorProps}
|
||||||
environment={environment}
|
role="option"
|
||||||
key={environment._id}
|
ref={ref}
|
||||||
selectedEnvironmentId={selectedEnvironmentId}
|
style={{
|
||||||
showEnvironment={showEnvironment}
|
width: '100%',
|
||||||
/>
|
height: '2px',
|
||||||
)
|
outline: 'none',
|
||||||
|
marginBottom: '-2px',
|
||||||
|
marginLeft: 0,
|
||||||
|
background: isDropTarget ? 'var(--hl)' : '0 0',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-expect-error Node not generic?
|
||||||
|
const ReorderableOption = ({ item, state, dragState, dropState }: { item: Node<Environment>; state: ListState<Node<Environment>>; dragState: DraggableCollectionState; dropState: DroppableCollectionState }): JSX.Element => {
|
||||||
|
const ref = React.useRef(null);
|
||||||
|
const { optionProps } = useOption({ key: item.key }, state, ref);
|
||||||
|
const { focusProps } = useFocusRing();
|
||||||
|
|
||||||
|
// Register the item as a drop target.
|
||||||
|
const { dropProps } = useDroppableItem(
|
||||||
|
{
|
||||||
|
target: { type: 'item', key: item.key, dropPosition: 'on' },
|
||||||
|
},
|
||||||
|
dropState,
|
||||||
|
ref
|
||||||
|
);
|
||||||
|
// Register the item as a drag source.
|
||||||
|
const { dragProps } = useDraggableItem({
|
||||||
|
key: item.key,
|
||||||
|
}, dragState);
|
||||||
|
|
||||||
|
const environment = item.value as unknown as Environment;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropIndicator
|
||||||
|
target={{
|
||||||
|
type: 'item',
|
||||||
|
key: item.key,
|
||||||
|
dropPosition: 'before',
|
||||||
|
}}
|
||||||
|
dropState={dropState}
|
||||||
|
/>
|
||||||
|
<li
|
||||||
|
style={{
|
||||||
|
gap: '1rem',
|
||||||
|
display: 'flex',
|
||||||
|
padding: '5px',
|
||||||
|
outlineStyle: 'none',
|
||||||
|
}}
|
||||||
|
{...mergeProps(
|
||||||
|
optionProps,
|
||||||
|
dragProps,
|
||||||
|
dropProps,
|
||||||
|
focusProps
|
||||||
)}
|
)}
|
||||||
</ul>);
|
ref={ref}
|
||||||
};
|
className={classnames({
|
||||||
|
'env-modal__sidebar-item': true,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<SidebarListItem environment={environment} />
|
||||||
|
</li>
|
||||||
|
{state.collection.getKeyAfter(item.key) == null &&
|
||||||
|
(
|
||||||
|
<DropIndicator
|
||||||
|
target={{
|
||||||
|
type: 'item',
|
||||||
|
key: item.key,
|
||||||
|
dropPosition: 'after',
|
||||||
|
}}
|
||||||
|
dropState={dropState}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-expect-error props any
|
||||||
|
const ReorderableListBox = props => {
|
||||||
|
// See useListBox docs for more details.
|
||||||
|
const state = useListState(props);
|
||||||
|
const ref = React.useRef(null);
|
||||||
|
const { listBoxProps } = useListBox(
|
||||||
|
{
|
||||||
|
...props,
|
||||||
|
shouldSelectOnPressUp: true,
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
ref
|
||||||
|
);
|
||||||
|
|
||||||
|
const dropState = useDroppableCollectionState({
|
||||||
|
...props,
|
||||||
|
collection: state.collection,
|
||||||
|
selectionManager: state.selectionManager,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { collectionProps } = useDroppableCollection(
|
||||||
|
{
|
||||||
|
...props,
|
||||||
|
keyboardDelegate: new ListKeyboardDelegate(
|
||||||
|
state.collection,
|
||||||
|
state.disabledKeys,
|
||||||
|
ref
|
||||||
|
),
|
||||||
|
dropTargetDelegate: new ListDropTargetDelegate(
|
||||||
|
state.collection,
|
||||||
|
ref
|
||||||
|
),
|
||||||
|
},
|
||||||
|
dropState,
|
||||||
|
ref
|
||||||
|
);
|
||||||
|
|
||||||
|
// Setup drag state for the collection.
|
||||||
|
const dragState = useDraggableCollectionState({
|
||||||
|
...props,
|
||||||
|
// Collection and selection manager come from list state.
|
||||||
|
collection: state.collection,
|
||||||
|
selectionManager: state.selectionManager,
|
||||||
|
// Provide data for each dragged item. This function could
|
||||||
|
// also be provided by the user of the component.
|
||||||
|
getItems: props.getItems || (keys => {
|
||||||
|
return [...keys].map(key => {
|
||||||
|
const item = state.collection.getItem(key);
|
||||||
|
|
||||||
|
return {
|
||||||
|
'text/plain': item.textValue,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
useDraggableCollection(props, dragState, ref);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
{...mergeProps(listBoxProps, collectionProps)}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
{[...state.collection].map(item => (
|
||||||
|
<ReorderableOption
|
||||||
|
key={item.key}
|
||||||
|
item={item}
|
||||||
|
state={state}
|
||||||
|
dragState={dragState}
|
||||||
|
dropState={dropState}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
interface State {
|
interface State {
|
||||||
baseEnvironment: Environment | null;
|
baseEnvironment: Environment | null;
|
||||||
selectedEnvironmentId: string | null;
|
selectedEnvironmentId: string | null;
|
||||||
@ -152,13 +264,17 @@ export const WorkspaceEnvironmentsEditModal = forwardRef<WorkspaceEnvironmentsEd
|
|||||||
},
|
},
|
||||||
}), [workspace, workspaceMeta?.activeEnvironmentId]);
|
}), [workspace, workspaceMeta?.activeEnvironmentId]);
|
||||||
|
|
||||||
function handleShowEnvironment(environmentId: string | null) {
|
function onSelectionChange(e: any) {
|
||||||
// Don't allow switching if the current one has errors
|
// Only switch if valid
|
||||||
if (environmentEditorRef.current?.isValid() && environmentId !== selectedEnvironmentId) {
|
if (environmentEditorRef.current?.isValid() && e.anchorKey) {
|
||||||
|
const environment = subEnvironments.filter(evt => evt._id === e.anchorKey)[0];
|
||||||
setState(state => ({
|
setState(state => ({
|
||||||
...state,
|
...state,
|
||||||
selectedEnvironmentId: environmentId || null,
|
selectedEnvironmentId: environment._id || null,
|
||||||
}));
|
}));
|
||||||
|
if (workspaceMeta?.activeEnvironmentId !== environment._id && workspaceMeta) {
|
||||||
|
models.workspaceMeta.update(workspaceMeta, { activeEnvironmentId: environment._id });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -202,27 +318,57 @@ export const WorkspaceEnvironmentsEditModal = forwardRef<WorkspaceEnvironmentsEd
|
|||||||
: environments.filter(e => e.parentId === baseEnvironment?._id).find(subEnvironment => subEnvironment._id === selectedEnvironmentId) || null;
|
: environments.filter(e => e.parentId === baseEnvironment?._id).find(subEnvironment => subEnvironment._id === selectedEnvironmentId) || null;
|
||||||
const selectedEnvironmentName = selectedEnvironment?.name || '';
|
const selectedEnvironmentName = selectedEnvironment?.name || '';
|
||||||
const selectedEnvironmentColor = selectedEnvironment?.color || null;
|
const selectedEnvironmentColor = selectedEnvironment?.color || null;
|
||||||
|
const subEnvironments = environments
|
||||||
|
.filter(environment => environment.parentId === (baseEnvironment && baseEnvironment._id))
|
||||||
|
.sort((e1, e2) => e1.metaSortKey - e2.metaSortKey);
|
||||||
if (inputRef.current && selectedEnvironmentColor) {
|
if (inputRef.current && selectedEnvironmentColor) {
|
||||||
inputRef.current.value = selectedEnvironmentColor;
|
inputRef.current.value = selectedEnvironmentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onReorder(e: any) {
|
||||||
|
const source = [...e.keys][0];
|
||||||
|
const sourceEnv = subEnvironments.find(evt => evt._id === source);
|
||||||
|
const targetEnv = subEnvironments.find(evt => evt._id === e.target.key);
|
||||||
|
if (!sourceEnv || !targetEnv) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dropPosition = e.target.dropPosition;
|
||||||
|
if (dropPosition === 'before') {
|
||||||
|
sourceEnv.metaSortKey = targetEnv.metaSortKey - 1;
|
||||||
|
}
|
||||||
|
if (dropPosition === 'after') {
|
||||||
|
sourceEnv.metaSortKey = targetEnv.metaSortKey + 1;
|
||||||
|
}
|
||||||
|
updateEnvironment(sourceEnv._id, { metaSortKey: sourceEnv.metaSortKey });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal ref={modalRef} wide tall {...props}>
|
<Modal ref={modalRef} wide tall {...props}>
|
||||||
<ModalHeader>Manage Environments</ModalHeader>
|
<ModalHeader>Manage Environments</ModalHeader>
|
||||||
<ModalBody noScroll className="env-modal">
|
<ModalBody noScroll className="env-modal">
|
||||||
<div className="env-modal__sidebar">
|
<div className="env-modal__sidebar">
|
||||||
<li
|
<div
|
||||||
className={classnames('env-modal__sidebar-root-item', {
|
className={classnames('env-modal__sidebar-root-item', {
|
||||||
'env-modal__sidebar-item--active': selectedEnvironmentId === baseEnvironment?._id,
|
'env-modal__sidebar-item--active': selectedEnvironmentId === baseEnvironment?._id,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<button onClick={() => handleShowEnvironment(baseEnvironment?._id || null)}>
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (environmentEditorRef.current?.isValid() && selectedEnvironmentId === baseEnvironment?._id) {
|
||||||
|
setState(state => ({
|
||||||
|
...state,
|
||||||
|
selectedEnvironmentId: baseEnvironment?._id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
{ROOT_ENVIRONMENT_NAME}
|
{ROOT_ENVIRONMENT_NAME}
|
||||||
<HelpTooltip className="space-left">
|
<HelpTooltip className="space-left">
|
||||||
The variables in this environment are always available, regardless of which
|
The variables in this environment are always available, regardless of which
|
||||||
sub-environment is active. Useful for storing default or fallback values.
|
sub-environment is active. Useful for storing default or fallback values.
|
||||||
</HelpTooltip>
|
</HelpTooltip>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</div>
|
||||||
<div className="pad env-modal__sidebar-heading">
|
<div className="pad env-modal__sidebar-heading">
|
||||||
<h3 className="no-margin">Sub Environments</h3>
|
<h3 className="no-margin">Sub Environments</h3>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
@ -274,12 +420,20 @@ export const WorkspaceEnvironmentsEditModal = forwardRef<WorkspaceEnvironmentsEd
|
|||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
<SidebarList
|
<ReorderableListBox
|
||||||
environments={environments.filter(e => e.parentId === baseEnvironment?._id)}
|
items={subEnvironments}
|
||||||
selectedEnvironmentId={selectedEnvironmentId}
|
onSelectionChange={onSelectionChange}
|
||||||
showEnvironment={handleShowEnvironment}
|
onReorder={onReorder}
|
||||||
changeEnvironmentName={(environment, name) => updateEnvironment(environment._id, { name })}
|
selectionMode="multiple"
|
||||||
/>
|
selectionBehavior="replace"
|
||||||
|
aria-label="list of subenvironments"
|
||||||
|
>
|
||||||
|
{(environment: any) =>
|
||||||
|
<Item key={environment._id}>
|
||||||
|
{environment.name}
|
||||||
|
</Item>
|
||||||
|
}
|
||||||
|
</ReorderableListBox>
|
||||||
</div>
|
</div>
|
||||||
<div className="env-modal__main">
|
<div className="env-modal__main">
|
||||||
<div className="env-modal__main__header">
|
<div className="env-modal__main__header">
|
||||||
|
@ -18,12 +18,12 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: var(--padding-sm) var(--padding-sm) 0;
|
padding: var(--padding-sm) var(--padding-sm) 0;
|
||||||
|
|
||||||
& > * {
|
&>* {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
& > *:first-child {
|
&>*:first-child {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--padding-sm) 0 var(--padding-sm) var(--padding-sm);
|
padding: var(--padding-sm) 0 var(--padding-sm) var(--padding-sm);
|
||||||
}
|
}
|
||||||
@ -64,7 +64,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
h1 > * {
|
h1>* {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: var(--font-size-xl);
|
font-size: var(--font-size-xl);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -75,66 +75,21 @@
|
|||||||
// These are at the top-level because sorting pulls the row out into <body>
|
// These are at the top-level because sorting pulls the row out into <body>
|
||||||
.env-modal__sidebar-root-item,
|
.env-modal__sidebar-root-item,
|
||||||
.env-modal__sidebar-item {
|
.env-modal__sidebar-item {
|
||||||
z-index: 100000;
|
display: flex;
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
grid-template-rows: var(--line-height-xs);
|
|
||||||
color: var(--hl);
|
color: var(--hl);
|
||||||
|
|
||||||
& > button {
|
|
||||||
padding: 0 var(--padding-md) 0 var(--padding-md);
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&:first-child {
|
padding: var(--padding-sm) 0;
|
||||||
padding-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.editable {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
display: block;
|
|
||||||
padding-right: var(--padding-xs);
|
|
||||||
box-sizing: border-box;
|
|
||||||
width: 100%;
|
|
||||||
line-height: var(--line-height-xs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.env-status {
|
|
||||||
padding: 0 var(--padding-sm) 0 var(--padding-sm);
|
|
||||||
align-self: center;
|
|
||||||
|
|
||||||
.active {
|
|
||||||
color: var(--hl-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inactive {
|
|
||||||
color: var(--hl-xl);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--hl-xs);
|
background-color: var(--hl-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover .inactive {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover .inactive:hover {
|
|
||||||
color: var(--color-font);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.env-modal__sidebar-item {
|
.env-modal__sidebar-item--active {
|
||||||
& > button {
|
color: var(--color-font);
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
padding-left: var(--padding-md);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user