mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
plugin details right panel tab (#97354)
* plugin details right panel tab * fix betterer * useMedia hook, use function for currentIdPage instead of state * Rename PluginDetailsRightPanel to PluginDetailsPanel * nit changes * remove maxWidth for pluginDetailsPanel if screen is narrow * fix width prop * Add tests * Rename PluginDetailsRight Panel file, rename info prop, fix the latestVersion * delete console log * move latestVersion from info arrya * fix latestVersion test --------- Co-authored-by: Esteban Beltran <esteban@academo.me>
This commit is contained in:
parent
b8a4784a50
commit
2f40a93bf8
@ -3,9 +3,11 @@ import { useMemo } from 'react';
|
||||
|
||||
import { AppPlugin, GrafanaTheme2, PluginContextProvider, UrlQueryMap } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { PageInfoItem } from '@grafana/runtime/src/components/PluginPage';
|
||||
import { CellProps, Column, InteractiveTable, Stack, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { Changelog } from '../components/Changelog';
|
||||
import { PluginDetailsPanel } from '../components/PluginDetailsPanel';
|
||||
import { VersionList } from '../components/VersionList';
|
||||
import { usePluginConfig } from '../hooks/usePluginConfig';
|
||||
import { CatalogPlugin, Permission, PluginTabIds } from '../types';
|
||||
@ -16,13 +18,15 @@ import { PluginUsage } from './PluginUsage';
|
||||
|
||||
type Props = {
|
||||
plugin: CatalogPlugin;
|
||||
info: PageInfoItem[];
|
||||
queryParams: UrlQueryMap;
|
||||
pageId: string;
|
||||
showDetails: boolean;
|
||||
};
|
||||
|
||||
type Cell<T extends keyof Permission = keyof Permission> = CellProps<Permission, Permission[T]>;
|
||||
|
||||
export function PluginDetailsBody({ plugin, queryParams, pageId }: Props): JSX.Element {
|
||||
export function PluginDetailsBody({ plugin, queryParams, pageId, info, showDetails }: Props): JSX.Element {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { value: pluginConfig } = usePluginConfig(plugin);
|
||||
|
||||
@ -77,6 +81,14 @@ export function PluginDetailsBody({ plugin, queryParams, pageId }: Props): JSX.E
|
||||
);
|
||||
}
|
||||
|
||||
if (pageId === PluginTabIds.PLUGINDETAILS && config.featureToggles.pluginsDetailsRightPanel && showDetails) {
|
||||
return (
|
||||
<div>
|
||||
<PluginDetailsPanel pluginExtentionsInfo={info} plugin={plugin} width={'auto'} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Permissions will be returned in the iam field for installed plugins and in the details.iam field when fetching details from gcom
|
||||
const permissions = plugin.iam?.permissions || plugin.details?.iam?.permissions;
|
||||
|
||||
|
@ -0,0 +1,144 @@
|
||||
import { render, screen } from 'test/test-utils';
|
||||
|
||||
import { PluginSignatureStatus, PluginSignatureType, PluginType } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { CatalogPlugin } from '../types';
|
||||
|
||||
import { PluginDetailsPage } from './PluginDetailsPage';
|
||||
|
||||
const plugin: CatalogPlugin = {
|
||||
description: 'Test plugin description',
|
||||
downloads: 1000,
|
||||
hasUpdate: false,
|
||||
id: 'test-plugin',
|
||||
info: {
|
||||
logos: {
|
||||
small: 'small-logo-url',
|
||||
large: 'large-logo-url',
|
||||
},
|
||||
keywords: ['test', 'plugin'],
|
||||
},
|
||||
isDev: false,
|
||||
isCore: false,
|
||||
isEnterprise: false,
|
||||
isInstalled: true,
|
||||
isDisabled: false,
|
||||
isDeprecated: false,
|
||||
isManaged: false,
|
||||
isPreinstalled: { found: false, withVersion: false },
|
||||
isPublished: true,
|
||||
name: 'Test Plugin',
|
||||
orgName: 'Test Org',
|
||||
signature: PluginSignatureStatus.valid,
|
||||
signatureType: PluginSignatureType.grafana,
|
||||
signatureOrg: 'Test Signature Org',
|
||||
popularity: 4,
|
||||
publishedAt: '2023-01-01',
|
||||
type: PluginType.app,
|
||||
updatedAt: '2023-12-01',
|
||||
installedVersion: '1.0.0',
|
||||
details: {
|
||||
readme: 'Test readme',
|
||||
versions: [
|
||||
{
|
||||
version: '1.0.0',
|
||||
createdAt: '2023-01-01',
|
||||
isCompatible: true,
|
||||
grafanaDependency: '>=9.0.0',
|
||||
angularDetected: false,
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{
|
||||
name: 'Website',
|
||||
url: 'https://test-plugin.com',
|
||||
},
|
||||
],
|
||||
grafanaDependency: '>=9.0.0',
|
||||
statusContext: 'stable',
|
||||
},
|
||||
angularDetected: false,
|
||||
isFullyInstalled: true,
|
||||
accessControl: {},
|
||||
};
|
||||
|
||||
jest.mock('../state/hooks', () => ({
|
||||
useGetSingle: jest.fn(),
|
||||
useFetchStatus: jest.fn().mockReturnValue({ isLoading: false }),
|
||||
useFetchDetailsStatus: () => ({ isLoading: false }),
|
||||
useIsRemotePluginsAvailable: () => false,
|
||||
useInstallStatus: () => ({ error: null, isInstalling: false }),
|
||||
useUninstallStatus: () => ({ error: null, isUninstalling: false }),
|
||||
useInstall: () => jest.fn(),
|
||||
useUninstall: () => jest.fn(),
|
||||
useUnsetInstall: () => jest.fn(),
|
||||
useFetchDetailsLazy: () => jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseGetSingle = jest.requireMock('../state/hooks').useGetSingle;
|
||||
|
||||
describe('PluginDetailsPage', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(console, 'error').mockImplementation();
|
||||
mockUseGetSingle.mockReturnValue(plugin);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should show loader when fetching plugin details', () => {
|
||||
jest.requireMock('../state/hooks').useFetchStatus.mockReturnValueOnce({ isLoading: true });
|
||||
render(<PluginDetailsPage pluginId="test-plugin" />);
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show not found component when plugin doesnt exist', () => {
|
||||
mockUseGetSingle.mockReturnValue(undefined);
|
||||
render(<PluginDetailsPage pluginId="not-exist" />);
|
||||
expect(screen.getByText('Plugin not found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show angular deprecation notice when angular is detected', () => {
|
||||
mockUseGetSingle.mockReturnValue({ ...plugin, angularDetected: true });
|
||||
render(<PluginDetailsPage pluginId="test-plugin" />);
|
||||
expect(screen.getByText(/legacy platform based on AngularJS/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show right panel when feature toggle is disabled', () => {
|
||||
config.featureToggles.pluginsDetailsRightPanel = false;
|
||||
render(<PluginDetailsPage pluginId="test-plugin" />);
|
||||
expect(screen.queryByTestId('plugin-details-panel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show right panel when feature toggle is enabled and screen is wide', () => {
|
||||
config.featureToggles.pluginsDetailsRightPanel = true;
|
||||
window.matchMedia = jest.fn().mockImplementation((query) => ({
|
||||
matches: query !== '(max-width: 600px)',
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
render(<PluginDetailsPage pluginId="test-plugin" />);
|
||||
expect(screen.getByTestId('plugin-details-panel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show "Plugin details" tab when screen is narrow', () => {
|
||||
config.featureToggles.pluginsDetailsRightPanel = true;
|
||||
window.matchMedia = jest.fn().mockImplementation((query) => ({
|
||||
matches: query === '(max-width: 600px)',
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
render(<PluginDetailsPage pluginId="test-plugin" />);
|
||||
expect(screen.getByRole('tab', { name: 'Plugin details' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
@ -1,6 +1,7 @@
|
||||
import { css } from '@emotion/css';
|
||||
import * as React from 'react';
|
||||
import { useLocation } from 'react-router-dom-v5-compat';
|
||||
import { useMedia } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
@ -12,7 +13,7 @@ import { AngularDeprecationPluginNotice } from '../../angularDeprecation/Angular
|
||||
import { Loader } from '../components/Loader';
|
||||
import { PluginDetailsBody } from '../components/PluginDetailsBody';
|
||||
import { PluginDetailsDisabledError } from '../components/PluginDetailsDisabledError';
|
||||
import { PluginDetailsRightPanel } from '../components/PluginDetailsRightPanel';
|
||||
import { PluginDetailsPanel } from '../components/PluginDetailsPanel';
|
||||
import { PluginDetailsSignature } from '../components/PluginDetailsSignature';
|
||||
import { usePluginDetailsTabs } from '../hooks/usePluginDetailsTabs';
|
||||
import { usePluginPageExtensions } from '../hooks/usePluginPageExtensions';
|
||||
@ -45,7 +46,12 @@ export function PluginDetailsPage({
|
||||
const location = useLocation();
|
||||
const queryParams = new URLSearchParams(location.search);
|
||||
const plugin = useGetSingle(pluginId); // fetches the plugin settings for this Grafana instance
|
||||
const { navModel, activePageId } = usePluginDetailsTabs(plugin, queryParams.get('page') as PluginTabIds);
|
||||
const isNarrowScreen = useMedia('(max-width: 600px)');
|
||||
const { navModel, activePageId } = usePluginDetailsTabs(
|
||||
plugin,
|
||||
queryParams.get('page') as PluginTabIds,
|
||||
isNarrowScreen
|
||||
);
|
||||
const { actions, info, subtitle } = usePluginPageExtensions(plugin);
|
||||
const { isLoading: isFetchLoading } = useFetchStatus();
|
||||
const { isLoading: isFetchDetailsLoading } = useFetchDetailsStatus();
|
||||
@ -93,10 +99,18 @@ export function PluginDetailsPage({
|
||||
<PluginDetailsSignature plugin={plugin} className={styles.alert} />
|
||||
<PluginDetailsDisabledError plugin={plugin} className={styles.alert} />
|
||||
<PluginDetailsDeprecatedWarning plugin={plugin} className={styles.alert} />
|
||||
<PluginDetailsBody queryParams={Object.fromEntries(queryParams)} plugin={plugin} pageId={activePageId} />
|
||||
<PluginDetailsBody
|
||||
queryParams={Object.fromEntries(queryParams)}
|
||||
plugin={plugin}
|
||||
pageId={activePageId}
|
||||
info={info}
|
||||
showDetails={isNarrowScreen}
|
||||
/>
|
||||
</TabContent>
|
||||
</Page.Contents>
|
||||
{config.featureToggles.pluginsDetailsRightPanel && <PluginDetailsRightPanel info={info} plugin={plugin} />}
|
||||
{!isNarrowScreen && config.featureToggles.pluginsDetailsRightPanel && (
|
||||
<PluginDetailsPanel pluginExtentionsInfo={info} plugin={plugin} />
|
||||
)}
|
||||
</Stack>
|
||||
</Page>
|
||||
);
|
||||
|
@ -0,0 +1,121 @@
|
||||
import { render, screen } from 'test/test-utils';
|
||||
|
||||
import { PluginSignatureStatus, PluginSignatureType, PluginType } from '@grafana/data';
|
||||
|
||||
import { CatalogPlugin } from '../types';
|
||||
|
||||
import { PluginDetailsPanel } from './PluginDetailsPanel';
|
||||
|
||||
const mockPlugin: CatalogPlugin = {
|
||||
description: 'Test plugin description',
|
||||
downloads: 1000,
|
||||
hasUpdate: false,
|
||||
id: 'test-plugin',
|
||||
info: {
|
||||
logos: {
|
||||
small: 'small-logo-url',
|
||||
large: 'large-logo-url',
|
||||
},
|
||||
keywords: ['test', 'plugin'],
|
||||
},
|
||||
isDev: false,
|
||||
isCore: false,
|
||||
isEnterprise: false,
|
||||
isInstalled: true,
|
||||
isDisabled: false,
|
||||
isDeprecated: false,
|
||||
isManaged: false,
|
||||
isPreinstalled: { found: false, withVersion: false },
|
||||
isPublished: true,
|
||||
name: 'Test Plugin',
|
||||
orgName: 'Test Org',
|
||||
signature: PluginSignatureStatus.valid,
|
||||
signatureType: PluginSignatureType.grafana,
|
||||
signatureOrg: 'Test Signature Org',
|
||||
popularity: 4,
|
||||
publishedAt: '2023-01-01',
|
||||
type: PluginType.app,
|
||||
updatedAt: '2023-12-01',
|
||||
installedVersion: '1.0.0',
|
||||
latestVersion: '1.1.0',
|
||||
details: {
|
||||
readme: 'Test readme',
|
||||
versions: [
|
||||
{
|
||||
version: '1.0.0',
|
||||
createdAt: '2023-01-01',
|
||||
isCompatible: true,
|
||||
grafanaDependency: '>=9.0.0',
|
||||
angularDetected: false,
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{
|
||||
name: 'Website',
|
||||
url: 'https://test-plugin.com',
|
||||
},
|
||||
],
|
||||
grafanaDependency: '>=9.0.0',
|
||||
statusContext: 'stable',
|
||||
},
|
||||
angularDetected: false,
|
||||
isFullyInstalled: true,
|
||||
accessControl: {},
|
||||
};
|
||||
|
||||
const mockInfo = [
|
||||
{ label: 'Version', value: '1.1.0' },
|
||||
{ label: 'Author', value: 'Test Author' },
|
||||
];
|
||||
|
||||
describe('PluginDetailsPanel', () => {
|
||||
it('should render installed version when plugin is installed', () => {
|
||||
render(<PluginDetailsPanel plugin={mockPlugin} pluginExtentionsInfo={mockInfo} />);
|
||||
const installedVersionLabel = screen.getByText('Installed version:');
|
||||
// Get the version text that's next to the label
|
||||
const installedVersion = installedVersionLabel.nextElementSibling;
|
||||
expect(installedVersionLabel).toBeInTheDocument();
|
||||
expect(installedVersion).toHaveTextContent('1.0.0');
|
||||
});
|
||||
|
||||
it('should render latest version information', () => {
|
||||
render(<PluginDetailsPanel plugin={mockPlugin} pluginExtentionsInfo={mockInfo} />);
|
||||
expect(screen.getByText('Latest version:')).toBeInTheDocument();
|
||||
expect(screen.getByText('1.1.0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render links section when plugin has links', () => {
|
||||
render(<PluginDetailsPanel plugin={mockPlugin} pluginExtentionsInfo={mockInfo} />);
|
||||
const link = screen.getByText('Website');
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute('href', 'https://test-plugin.com');
|
||||
});
|
||||
|
||||
it('should not render links section when plugin has no links', () => {
|
||||
const pluginWithoutLinks = {
|
||||
...mockPlugin,
|
||||
details: { ...mockPlugin.details, links: [] },
|
||||
};
|
||||
render(<PluginDetailsPanel plugin={pluginWithoutLinks} pluginExtentionsInfo={mockInfo} />);
|
||||
expect(screen.queryByText('Links')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Website')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render report abuse section for non-core plugins', () => {
|
||||
render(<PluginDetailsPanel plugin={mockPlugin} pluginExtentionsInfo={mockInfo} />);
|
||||
expect(screen.getByText('Report a concern')).toBeInTheDocument();
|
||||
expect(screen.getByText('Contact Grafana Labs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render report abuse section for core plugins', () => {
|
||||
const corePlugin = { ...mockPlugin, isCore: true };
|
||||
render(<PluginDetailsPanel plugin={corePlugin} pluginExtentionsInfo={mockInfo} />);
|
||||
expect(screen.queryByText('Report a concern')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should respect custom width prop', () => {
|
||||
render(<PluginDetailsPanel plugin={mockPlugin} pluginExtentionsInfo={mockInfo} width="300px" />);
|
||||
const panel = screen.getByTestId('plugin-details-panel');
|
||||
expect(panel).toHaveStyle({ maxWidth: '300px' });
|
||||
});
|
||||
});
|
@ -10,15 +10,17 @@ import { getLatestCompatibleVersion } from '../helpers';
|
||||
import { CatalogPlugin } from '../types';
|
||||
|
||||
type Props = {
|
||||
info: PageInfoItem[];
|
||||
pluginExtentionsInfo: PageInfoItem[];
|
||||
plugin: CatalogPlugin;
|
||||
width?: string;
|
||||
};
|
||||
|
||||
export function PluginDetailsRightPanel(props: Props): React.ReactElement | null {
|
||||
const { info, plugin } = props;
|
||||
export function PluginDetailsPanel(props: Props): React.ReactElement | null {
|
||||
const { pluginExtentionsInfo, plugin, width = '250px' } = props;
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
return (
|
||||
<Stack direction="column" gap={3} shrink={0} grow={0} maxWidth={'250px'}>
|
||||
<Stack direction="column" gap={3} shrink={0} grow={0} maxWidth={width} data-testid="plugin-details-panel">
|
||||
<Box padding={2} borderColor="medium" borderStyle="solid">
|
||||
<Stack direction="column" gap={2}>
|
||||
{plugin.isInstalled && plugin.installedVersion && (
|
||||
@ -29,18 +31,17 @@ export function PluginDetailsRightPanel(props: Props): React.ReactElement | null
|
||||
<div className={styles.pluginVersionDetails}>{plugin.installedVersion}</div>
|
||||
</Stack>
|
||||
)}
|
||||
{info.map((infoItem, index) => {
|
||||
<Stack wrap direction="column" gap={0.5}>
|
||||
<Text color="secondary">
|
||||
<Trans i18nKey="plugins.details.labels.latestVersion">Latest version: </Trans>
|
||||
</Text>
|
||||
<div className={styles.pluginVersionDetails}>
|
||||
{plugin.latestVersion || getLatestCompatibleVersion(plugin.details?.versions)?.version}
|
||||
</div>
|
||||
</Stack>
|
||||
{pluginExtentionsInfo.map((infoItem, index) => {
|
||||
if (infoItem.label === 'Version') {
|
||||
return (
|
||||
<Stack key={index} wrap direction="column" gap={0.5}>
|
||||
<Text color="secondary">
|
||||
<Trans i18nKey="plugins.details.labels.latestVersion">Latest version: </Trans>
|
||||
</Text>
|
||||
<div className={styles.pluginVersionDetails}>
|
||||
{getLatestCompatibleVersion(plugin.details?.versions)?.version}
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Stack key={index} wrap direction="column" gap={0.5}>
|
@ -16,13 +16,29 @@ type ReturnType = {
|
||||
activePageId: PluginTabIds | string;
|
||||
};
|
||||
|
||||
export const usePluginDetailsTabs = (plugin?: CatalogPlugin, pageId?: PluginTabIds): ReturnType => {
|
||||
function getCurrentPageId(
|
||||
pageId: PluginTabIds | undefined,
|
||||
isNarrowScreen: boolean | undefined,
|
||||
defaultTab: string
|
||||
): PluginTabIds | string {
|
||||
if (!isNarrowScreen && pageId === PluginTabIds.PLUGINDETAILS) {
|
||||
return defaultTab;
|
||||
}
|
||||
return pageId || defaultTab;
|
||||
}
|
||||
|
||||
export const usePluginDetailsTabs = (
|
||||
plugin?: CatalogPlugin,
|
||||
pageId?: PluginTabIds,
|
||||
isNarrowScreen?: boolean
|
||||
): ReturnType => {
|
||||
const { loading, error, value: pluginConfig } = usePluginConfig(plugin);
|
||||
const { pathname } = useLocation();
|
||||
const defaultTab = useDefaultPage(plugin, pluginConfig);
|
||||
const isPublished = Boolean(plugin?.isPublished);
|
||||
|
||||
const currentPageId = pageId || defaultTab;
|
||||
const currentPageId = getCurrentPageId(pageId, isNarrowScreen, defaultTab);
|
||||
|
||||
const navModelChildren = useMemo(() => {
|
||||
const canConfigurePlugins = plugin && contextSrv.hasPermissionInMetadata(AccessControlAction.PluginsWrite, plugin);
|
||||
const navModelChildren: NavModelItem[] = [];
|
||||
@ -45,6 +61,16 @@ export const usePluginDetailsTabs = (plugin?: CatalogPlugin, pageId?: PluginTabI
|
||||
});
|
||||
}
|
||||
|
||||
if (isPublished && isNarrowScreen && config.featureToggles.pluginsDetailsRightPanel) {
|
||||
navModelChildren.push({
|
||||
text: PluginTabLabels.PLUGINDETAILS,
|
||||
id: PluginTabIds.PLUGINDETAILS,
|
||||
icon: 'info-circle',
|
||||
url: `${pathname}?page=${PluginTabIds.PLUGINDETAILS}`,
|
||||
active: PluginTabIds.PLUGINDETAILS === currentPageId,
|
||||
});
|
||||
}
|
||||
|
||||
// Not extending the tabs with the config pages if the plugin is not installed
|
||||
if (!pluginConfig) {
|
||||
return navModelChildren;
|
||||
@ -112,7 +138,7 @@ export const usePluginDetailsTabs = (plugin?: CatalogPlugin, pageId?: PluginTabI
|
||||
}
|
||||
|
||||
return navModelChildren;
|
||||
}, [plugin, pluginConfig, pathname, isPublished, currentPageId]);
|
||||
}, [plugin, pluginConfig, pathname, isPublished, currentPageId, isNarrowScreen]);
|
||||
|
||||
const navModel: NavModelItem = {
|
||||
text: plugin?.name ?? '',
|
||||
|
@ -256,6 +256,7 @@ export enum PluginTabLabels {
|
||||
USAGE = 'Usage',
|
||||
IAM = 'IAM',
|
||||
CHANGELOG = 'Changelog',
|
||||
PLUGINDETAILS = 'Plugin details',
|
||||
}
|
||||
|
||||
export enum PluginTabIds {
|
||||
@ -266,6 +267,7 @@ export enum PluginTabIds {
|
||||
USAGE = 'usage',
|
||||
IAM = 'iam',
|
||||
CHANGELOG = 'changelog',
|
||||
PLUGINDETAILS = 'right-panel',
|
||||
}
|
||||
|
||||
export enum RequestStatus {
|
||||
|
Loading…
Reference in New Issue
Block a user