mirror of
https://github.com/apache/superset.git
synced 2026-04-27 20:14:54 +00:00
fix(SqlLab): South pane visual changes (#35601)
(cherry picked from commit 6e60a00d69)
This commit is contained in:
committed by
Joe Li
parent
78983a6f25
commit
5ecd067ed3
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen, userEvent } from '@superset-ui/core/spec';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { ActionButton } from '.';
|
||||
|
||||
const defaultProps = {
|
||||
label: 'test-action',
|
||||
icon: <Icons.EditOutlined />,
|
||||
onClick: jest.fn(),
|
||||
};
|
||||
|
||||
test('renders action button with icon', () => {
|
||||
render(<ActionButton {...defaultProps} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveAttribute('data-test', 'test-action');
|
||||
expect(button).toHaveClass('action-button');
|
||||
});
|
||||
|
||||
test('calls onClick when clicked', async () => {
|
||||
const onClick = jest.fn();
|
||||
render(<ActionButton {...defaultProps} onClick={onClick} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
userEvent.click(button);
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('renders with tooltip when tooltip prop is provided', async () => {
|
||||
const tooltipText = 'This is a tooltip';
|
||||
render(<ActionButton {...defaultProps} tooltip={tooltipText} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
userEvent.hover(button);
|
||||
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
expect(tooltip).toBeInTheDocument();
|
||||
expect(tooltip).toHaveTextContent(tooltipText);
|
||||
});
|
||||
|
||||
test('renders without tooltip when tooltip prop is not provided', async () => {
|
||||
render(<ActionButton {...defaultProps} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
userEvent.hover(button);
|
||||
|
||||
const tooltip = screen.queryByRole('tooltip');
|
||||
expect(tooltip).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('supports ReactElement tooltip', async () => {
|
||||
const tooltipElement = <div>Custom tooltip content</div>;
|
||||
render(<ActionButton {...defaultProps} tooltip={tooltipElement} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
userEvent.hover(button);
|
||||
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
expect(tooltip).toBeInTheDocument();
|
||||
expect(tooltip).toHaveTextContent('Custom tooltip content');
|
||||
});
|
||||
|
||||
test('renders different icons correctly', () => {
|
||||
render(<ActionButton {...defaultProps} icon={<Icons.DeleteOutlined />} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders with custom placement for tooltip', async () => {
|
||||
const tooltipText = 'Tooltip with custom placement';
|
||||
render(
|
||||
<ActionButton {...defaultProps} tooltip={tooltipText} placement="bottom" />,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
userEvent.hover(button);
|
||||
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
expect(tooltip).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('has proper accessibility attributes', () => {
|
||||
render(<ActionButton {...defaultProps} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveAttribute('tabIndex', '0');
|
||||
expect(button).toHaveAttribute('role', 'button');
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import type { ReactElement } from 'react';
|
||||
import {
|
||||
Tooltip,
|
||||
type TooltipPlacement,
|
||||
type IconType,
|
||||
} from '@superset-ui/core/components';
|
||||
import { css, useTheme } from '@superset-ui/core';
|
||||
|
||||
export interface ActionProps {
|
||||
label: string;
|
||||
tooltip?: string | ReactElement;
|
||||
placement?: TooltipPlacement;
|
||||
icon: IconType;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const ActionButton = ({
|
||||
label,
|
||||
tooltip,
|
||||
placement,
|
||||
icon,
|
||||
onClick,
|
||||
}: ActionProps) => {
|
||||
const theme = useTheme();
|
||||
const actionButton = (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
css={css`
|
||||
cursor: pointer;
|
||||
color: ${theme.colorIcon};
|
||||
margin-right: ${theme.sizeUnit}px;
|
||||
&:hover {
|
||||
path {
|
||||
fill: ${theme.colorPrimary};
|
||||
}
|
||||
}
|
||||
`}
|
||||
className="action-button"
|
||||
data-test={label}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
);
|
||||
|
||||
const tooltipId = `${label.replaceAll(' ', '-').toLowerCase()}-tooltip`;
|
||||
|
||||
return tooltip ? (
|
||||
<Tooltip id={tooltipId} title={tooltip} placement={placement}>
|
||||
{actionButton}
|
||||
</Tooltip>
|
||||
) : (
|
||||
actionButton
|
||||
);
|
||||
};
|
||||
@@ -21,15 +21,18 @@ import { css, styled, useTheme } from '@superset-ui/core';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Tabs as AntdTabs, TabsProps as AntdTabsProps } from 'antd';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import type { SerializedStyles } from '@emotion/react';
|
||||
|
||||
export interface TabsProps extends AntdTabsProps {
|
||||
allowOverflow?: boolean;
|
||||
contentStyle?: SerializedStyles;
|
||||
}
|
||||
|
||||
const StyledTabs = ({
|
||||
animated = false,
|
||||
allowOverflow = true,
|
||||
tabBarStyle,
|
||||
contentStyle,
|
||||
...props
|
||||
}: TabsProps) => {
|
||||
const theme = useTheme();
|
||||
@@ -46,6 +49,7 @@ const StyledTabs = ({
|
||||
|
||||
.ant-tabs-content-holder {
|
||||
overflow: ${allowOverflow ? 'visible' : 'auto'};
|
||||
${contentStyle}
|
||||
}
|
||||
.ant-tabs-tab {
|
||||
flex: 1 1 auto;
|
||||
@@ -85,9 +89,10 @@ const Tabs = Object.assign(StyledTabs, {
|
||||
});
|
||||
|
||||
const StyledEditableTabs = styled(StyledTabs)`
|
||||
${({ theme }) => `
|
||||
${({ theme, contentStyle }) => `
|
||||
.ant-tabs-content-holder {
|
||||
background: ${theme.colorBgContainer};
|
||||
${contentStyle}
|
||||
}
|
||||
|
||||
& > .ant-tabs-nav {
|
||||
|
||||
@@ -181,3 +181,4 @@ export {
|
||||
setupAGGridModules,
|
||||
defaultModules,
|
||||
} from './ThemedAgGridReact';
|
||||
export { ActionButton, type ActionProps } from './ActionButton';
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
css,
|
||||
FeatureFlag,
|
||||
isFeatureEnabled,
|
||||
useTheme,
|
||||
} from '@superset-ui/core';
|
||||
import QueryTable from 'src/SqlLab/components/QueryTable';
|
||||
import { SqlLabRootState } from 'src/SqlLab/types';
|
||||
@@ -67,6 +68,7 @@ const QueryHistory = ({
|
||||
const { id, tabViewId } = useQueryEditor(String(queryEditorId), [
|
||||
'tabViewId',
|
||||
]);
|
||||
const theme = useTheme();
|
||||
const editorId = tabViewId ?? id;
|
||||
const [ref, hasReachedBottom] = useInView({ threshold: 0 });
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
@@ -118,7 +120,11 @@ const QueryHistory = ({
|
||||
}
|
||||
|
||||
return editorQueries.length > 0 ? (
|
||||
<>
|
||||
<div
|
||||
css={css`
|
||||
padding-left: ${theme.sizeUnit * 4}px;
|
||||
`}
|
||||
>
|
||||
<QueryTable
|
||||
columns={[
|
||||
'state',
|
||||
@@ -144,7 +150,7 @@ const QueryHistory = ({
|
||||
/>
|
||||
)}
|
||||
{isFetching && <Skeleton active />}
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
<StyledEmptyStateWrapper>
|
||||
<EmptyState
|
||||
|
||||
@@ -149,6 +149,7 @@ const ReturnedRows = styled.div`
|
||||
const ResultSetControls = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-left: ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
`;
|
||||
|
||||
const ResultSetButtons = styled.div`
|
||||
@@ -663,6 +664,7 @@ const ResultSet = ({
|
||||
css={css`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-left: ${theme.sizeUnit * 4}px;
|
||||
align-items: center;
|
||||
gap: ${GAP}px;
|
||||
`}
|
||||
@@ -698,6 +700,7 @@ const ResultSet = ({
|
||||
<div
|
||||
css={css`
|
||||
flex: 1 1 auto;
|
||||
padding-left: ${theme.sizeUnit * 4}px;
|
||||
`}
|
||||
>
|
||||
<AutoSizer disableWidth>
|
||||
|
||||
@@ -191,6 +191,7 @@ const StyledSqlEditor = styled.div`
|
||||
.queryPane {
|
||||
flex: 1 1 auto;
|
||||
padding: ${theme.sizeUnit * 2}px;
|
||||
padding-left: 0px;
|
||||
overflow-y: auto;
|
||||
overflow-x: scroll;
|
||||
}
|
||||
|
||||
@@ -137,32 +137,30 @@ test('renders preview', async () => {
|
||||
|
||||
describe('table actions', () => {
|
||||
test('refreshes table metadata when triggered', async () => {
|
||||
const { getByRole, getByText } = render(<TablePreview {...mockedProps} />, {
|
||||
const { getByRole } = render(<TablePreview {...mockedProps} />, {
|
||||
useRedux: true,
|
||||
initialState,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(fetchMock.calls(getTableMetadataEndpoint)).toHaveLength(1),
|
||||
);
|
||||
const menuButton = getByRole('button', { name: /Table actions/i });
|
||||
fireEvent.click(menuButton);
|
||||
fireEvent.click(getByText('Refresh table schema'));
|
||||
const refreshButton = getByRole('button', { name: 'sync' });
|
||||
fireEvent.click(refreshButton);
|
||||
await waitFor(() =>
|
||||
expect(fetchMock.calls(getTableMetadataEndpoint)).toHaveLength(2),
|
||||
);
|
||||
});
|
||||
|
||||
test('shows CREATE VIEW statement', async () => {
|
||||
const { getByRole, getByText } = render(<TablePreview {...mockedProps} />, {
|
||||
const { getByRole } = render(<TablePreview {...mockedProps} />, {
|
||||
useRedux: true,
|
||||
initialState,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(fetchMock.calls(getTableMetadataEndpoint)).toHaveLength(1),
|
||||
);
|
||||
const menuButton = getByRole('button', { name: /Table actions/i });
|
||||
fireEvent.click(menuButton);
|
||||
fireEvent.click(getByText('Show CREATE VIEW statement'));
|
||||
const viewButton = getByRole('button', { name: 'eye' });
|
||||
fireEvent.click(viewButton);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: 'CREATE VIEW statement' }),
|
||||
|
||||
@@ -25,15 +25,15 @@ import {
|
||||
getExtensionsRegistry,
|
||||
styled,
|
||||
t,
|
||||
useTheme,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
SafeMarkdown,
|
||||
Alert,
|
||||
Breadcrumb,
|
||||
Button,
|
||||
Card,
|
||||
Dropdown,
|
||||
Skeleton,
|
||||
Flex,
|
||||
} from '@superset-ui/core/components';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
useTableMetadataQuery,
|
||||
} from 'src/hooks/apiResources';
|
||||
import { runTablePreviewQuery } from 'src/SqlLab/actions/sqlLab';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
import { ActionButton } from '@superset-ui/core/components/ActionButton';
|
||||
import ResultSet from '../ResultSet';
|
||||
import ShowSQL from '../ShowSQL';
|
||||
|
||||
@@ -68,23 +68,6 @@ const TABS_KEYS = {
|
||||
INDEXES: 'indexes',
|
||||
SAMPLE: 'sample',
|
||||
};
|
||||
const MENUS = [
|
||||
{
|
||||
key: 'refresh-table',
|
||||
label: t('Refresh table schema'),
|
||||
icon: <Icons.SyncOutlined iconSize="s" aria-hidden />,
|
||||
},
|
||||
{
|
||||
key: 'copy-select-statement',
|
||||
label: t('Copy SELECT statement'),
|
||||
icon: <Icons.CopyOutlined iconSize="s" aria-hidden />,
|
||||
},
|
||||
{
|
||||
key: 'show-create-view-statement',
|
||||
label: t('Show CREATE VIEW statement'),
|
||||
icon: <Icons.EyeOutlined iconSize="s" aria-hidden />,
|
||||
},
|
||||
];
|
||||
const TAB_HEADER_HEIGHT = 80;
|
||||
const PREVIEW_TOP_ACTION_HEIGHT = 30;
|
||||
const PREVIEW_QUERY_LIMIT = 100;
|
||||
@@ -97,6 +80,8 @@ const Title = styled.div`
|
||||
column-gap: ${theme.sizeUnit}px;
|
||||
font-size: ${theme.fontSizeLG}px;
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
padding-top: ${theme.sizeUnit * 2}px;
|
||||
padding-left: ${theme.sizeUnit * 4}px;
|
||||
`}
|
||||
`;
|
||||
const renderWell = (partitions: TableMetaData['partitions']) => {
|
||||
@@ -134,6 +119,7 @@ const renderWell = (partitions: TableMetaData['partitions']) => {
|
||||
|
||||
const TablePreview: FC<Props> = ({ dbId, catalog, schema, tableName }) => {
|
||||
const dispatch = useDispatch();
|
||||
const theme = useTheme();
|
||||
const [databaseName, backend, disableDataPreview] = useSelector<
|
||||
SqlLabRootState,
|
||||
string[]
|
||||
@@ -241,16 +227,37 @@ const TablePreview: FC<Props> = ({ dbId, catalog, schema, tableName }) => {
|
||||
],
|
||||
);
|
||||
|
||||
const dropdownMenu = useMemo(() => {
|
||||
let menus = [...MENUS];
|
||||
if (!tableData.selectStar) {
|
||||
menus = menus.filter(({ key }) => key !== 'copy-select-statement');
|
||||
}
|
||||
if (!tableData.view) {
|
||||
menus = menus.filter(({ key }) => key !== 'show-create-view-statement');
|
||||
}
|
||||
return menus;
|
||||
}, [tableData.view, tableData.selectStar]);
|
||||
const titleActions = () => (
|
||||
<Flex
|
||||
align="center"
|
||||
css={css`
|
||||
padding-left: ${theme.sizeUnit * 2}px;
|
||||
`}
|
||||
>
|
||||
<ActionButton
|
||||
label={t('Refresh table schema')}
|
||||
tooltip={t('Refresh table schema')}
|
||||
icon={<Icons.SyncOutlined iconSize="m" />}
|
||||
onClick={refreshTableMetadata}
|
||||
/>
|
||||
{tableData.selectStar && (
|
||||
<ActionButton
|
||||
label={t('Copy SELECT statement')}
|
||||
icon={<Icons.CopyOutlined iconSize="m" />}
|
||||
tooltip={t('Copy SELECT statement')}
|
||||
onClick={() => copyStatementActionRef.current?.click()}
|
||||
/>
|
||||
)}
|
||||
{tableData.view && (
|
||||
<ActionButton
|
||||
label={t('Show CREATE VIEW statement')}
|
||||
icon={<Icons.EyeOutlined iconSize="m" />}
|
||||
tooltip={t('Show CREATE VIEW statement')}
|
||||
onClick={() => showViewStatementActionRef.current?.click()}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (isMetadataLoading) {
|
||||
return <Skeleton active />;
|
||||
@@ -283,7 +290,12 @@ const TablePreview: FC<Props> = ({ dbId, catalog, schema, tableName }) => {
|
||||
flex-direction: column;
|
||||
`}
|
||||
>
|
||||
<Breadcrumb separator=">">
|
||||
<Breadcrumb
|
||||
separator=">"
|
||||
css={css`
|
||||
padding-left: ${theme.sizeUnit * 4}px;
|
||||
`}
|
||||
>
|
||||
<Breadcrumb.Item>{backend}</Breadcrumb.Item>
|
||||
<Breadcrumb.Item>{databaseName}</Breadcrumb.Item>
|
||||
{catalog && <Breadcrumb.Item>{catalog}</Breadcrumb.Item>}
|
||||
@@ -316,33 +328,7 @@ const TablePreview: FC<Props> = ({ dbId, catalog, schema, tableName }) => {
|
||||
<Title>
|
||||
<Icons.InsertRowAboveOutlined iconSize="l" />
|
||||
{tableName}
|
||||
<Dropdown
|
||||
popupRender={() => (
|
||||
<Menu
|
||||
onClick={({ key }) => {
|
||||
if (key === 'refresh-table') {
|
||||
refreshTableMetadata();
|
||||
}
|
||||
if (key === 'copy-select-statement') {
|
||||
copyStatementActionRef.current?.click();
|
||||
}
|
||||
if (key === 'show-create-view-statement') {
|
||||
showViewStatementActionRef.current?.click();
|
||||
}
|
||||
}}
|
||||
items={dropdownMenu}
|
||||
/>
|
||||
)}
|
||||
trigger={['click']}
|
||||
>
|
||||
<Button buttonSize="xsmall" buttonStyle="link">
|
||||
<Icons.DownSquareOutlined
|
||||
iconSize="m"
|
||||
style={{ marginTop: 2, marginLeft: 4 }}
|
||||
aria-label={t('Table actions')}
|
||||
/>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
{titleActions()}
|
||||
</Title>
|
||||
{isMetadataRefreshing ? (
|
||||
<Skeleton active />
|
||||
@@ -444,7 +430,11 @@ const TablePreview: FC<Props> = ({ dbId, catalog, schema, tableName }) => {
|
||||
css={css`
|
||||
height: ${height}px;
|
||||
`}
|
||||
tabBarStyle={{ paddingLeft: theme.sizeUnit * 4 }}
|
||||
items={tabItems}
|
||||
contentStyle={css`
|
||||
padding-left: ${theme.sizeUnit * 4}px;
|
||||
`}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -38,10 +38,10 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { styled } from '@superset-ui/core';
|
||||
import {
|
||||
Icons,
|
||||
IconNameType,
|
||||
Tooltip,
|
||||
Icons,
|
||||
type TooltipPlacement,
|
||||
ActionButton,
|
||||
} from '@superset-ui/core/components';
|
||||
|
||||
export type ActionProps = {
|
||||
@@ -59,47 +59,19 @@ interface ActionsBarProps {
|
||||
const StyledActions = styled.span`
|
||||
white-space: nowrap;
|
||||
min-width: 100px;
|
||||
.action-button {
|
||||
cursor: pointer;
|
||||
color: ${({ theme }) => theme.colorIcon};
|
||||
margin-right: ${({ theme }) => theme.sizeUnit}px;
|
||||
&:hover {
|
||||
path {
|
||||
fill: ${({ theme }) => theme.colorPrimary};
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function ActionsBar({ actions }: ActionsBarProps) {
|
||||
return (
|
||||
<StyledActions className="actions">
|
||||
{actions.map((action, index) => {
|
||||
const ActionIcon = Icons[action.icon as IconNameType];
|
||||
const actionButton = (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
style={{ cursor: 'pointer' }}
|
||||
className="action-button"
|
||||
data-test={action.label}
|
||||
onClick={action.onClick}
|
||||
key={action.tooltip ? undefined : index}
|
||||
>
|
||||
<ActionIcon iconSize="l" />
|
||||
</span>
|
||||
);
|
||||
return action.tooltip ? (
|
||||
<Tooltip
|
||||
id={`${action.label}-tooltip`}
|
||||
title={action.tooltip}
|
||||
placement={action.placement}
|
||||
key={index}
|
||||
>
|
||||
{actionButton}
|
||||
</Tooltip>
|
||||
) : (
|
||||
actionButton
|
||||
{actions.map(({ icon, tooltip, ...rest }, index) => {
|
||||
const IconComponent = Icons[icon as IconNameType];
|
||||
return (
|
||||
<ActionButton
|
||||
key={tooltip ? undefined : index}
|
||||
icon={<IconComponent />}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</StyledActions>
|
||||
|
||||
Reference in New Issue
Block a user