mirror of
https://github.com/strapi/strapi.git
synced 2025-11-03 11:25:17 +00:00
Merge pull request #13820 from strapi/custom-fields/list-custom-fields
[Custom fields] list custom fields
This commit is contained in:
commit
e222cb29da
@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
const ColorPickerInput = () => {
|
||||
return <div>TODO: Map Input Component</div>;
|
||||
};
|
||||
|
||||
export default ColorPickerInput;
|
||||
@ -4,26 +4,45 @@ import ColorPickerIcon from './components/ColorPicker/ColorPickerIcon';
|
||||
|
||||
export default {
|
||||
register(app) {
|
||||
app.customFields.register({
|
||||
name: 'color',
|
||||
pluginId: 'mycustomfields',
|
||||
type: 'text',
|
||||
icon: ColorPickerIcon,
|
||||
intlLabel: {
|
||||
id: 'mycustomfields.color.label',
|
||||
defaultMessage: 'Color',
|
||||
app.customFields.register([
|
||||
{
|
||||
name: 'map',
|
||||
pluginId: 'mycustomfields',
|
||||
type: 'json',
|
||||
intlLabel: {
|
||||
id: 'mycustomfields.map.label',
|
||||
defaultMessage: 'Map',
|
||||
},
|
||||
intlDescription: {
|
||||
id: 'mycustomfields.map.description',
|
||||
defaultMessage: 'Select any location',
|
||||
},
|
||||
components: {
|
||||
Input: async () =>
|
||||
import(/* webpackChunkName: "input-component" */ './components/Map/MapInput'),
|
||||
},
|
||||
},
|
||||
intlDescription: {
|
||||
id: 'mycustomfields.color.description',
|
||||
defaultMessage: 'Select any color',
|
||||
{
|
||||
name: 'color',
|
||||
pluginId: 'mycustomfields',
|
||||
type: 'text',
|
||||
icon: ColorPickerIcon,
|
||||
intlLabel: {
|
||||
id: 'mycustomfields.color.label',
|
||||
defaultMessage: 'Color',
|
||||
},
|
||||
intlDescription: {
|
||||
id: 'mycustomfields.color.description',
|
||||
defaultMessage: 'Select any color',
|
||||
},
|
||||
components: {
|
||||
Input: async () =>
|
||||
import(
|
||||
/* webpackChunkName: "input-component" */ './components/ColorPicker/ColorPickerInput'
|
||||
),
|
||||
},
|
||||
},
|
||||
components: {
|
||||
Input: async () =>
|
||||
import(
|
||||
/* webpackChunkName: "input-component" */ './components/ColorPicker/ColorPickerInput'
|
||||
),
|
||||
},
|
||||
});
|
||||
]);
|
||||
},
|
||||
bootstrap(app) {},
|
||||
async registerTrads({ locales }) {
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Box } from '@strapi/design-system/Box';
|
||||
import { Grid, GridItem } from '@strapi/design-system/Grid';
|
||||
import { KeyboardNavigable } from '@strapi/design-system/KeyboardNavigable';
|
||||
import { Stack } from '@strapi/design-system/Stack';
|
||||
import AttributeOption from '../AttributeOption';
|
||||
import getPadding from '../utils/getPadding';
|
||||
|
||||
const AttributeList = ({ attributes }) => (
|
||||
<KeyboardNavigable tagName="button">
|
||||
<Stack spacing={8}>
|
||||
{attributes.map((attributeRow, index) => {
|
||||
return (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<Grid key={index} gap={0}>
|
||||
{attributeRow.map((attribute, index) => {
|
||||
const { paddingLeft, paddingRight } = getPadding(index);
|
||||
|
||||
return (
|
||||
<GridItem key={attribute} col={6} style={{ height: '100%' }}>
|
||||
<Box
|
||||
paddingLeft={paddingLeft}
|
||||
paddingRight={paddingRight}
|
||||
paddingBottom={1}
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
<AttributeOption type={attribute} />
|
||||
</Box>
|
||||
</GridItem>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</KeyboardNavigable>
|
||||
);
|
||||
|
||||
AttributeList.propTypes = {
|
||||
attributes: PropTypes.array.isRequired,
|
||||
};
|
||||
|
||||
export default AttributeList;
|
||||
@ -13,7 +13,7 @@ import { Typography } from '@strapi/design-system/Typography';
|
||||
import useFormModalNavigation from '../../../hooks/useFormModalNavigation';
|
||||
import getTrad from '../../../utils/getTrad';
|
||||
import AttributeIcon from '../../AttributeIcon';
|
||||
import BoxWrapper from './BoxWrapper';
|
||||
import OptionBoxWrapper from '../OptionBoxWrapper';
|
||||
|
||||
const AttributeOption = ({ type }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
@ -30,7 +30,7 @@ const AttributeOption = ({ type }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<BoxWrapper padding={4} as="button" hasRadius type="button" onClick={handleClick}>
|
||||
<OptionBoxWrapper padding={4} as="button" hasRadius type="button" onClick={handleClick}>
|
||||
<Flex>
|
||||
<AttributeIcon type={type} />
|
||||
<Box paddingLeft={4}>
|
||||
@ -50,7 +50,7 @@ const AttributeOption = ({ type }) => {
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
</BoxWrapper>
|
||||
</OptionBoxWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -0,0 +1,65 @@
|
||||
/**
|
||||
*
|
||||
* AttributeOption
|
||||
*
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Box } from '@strapi/design-system/Box';
|
||||
import { Flex } from '@strapi/design-system/Flex';
|
||||
import { Typography } from '@strapi/design-system/Typography';
|
||||
import OptionBoxWrapper from '../OptionBoxWrapper';
|
||||
import AttributeIcon from '../../AttributeIcon';
|
||||
import useFormModalNavigation from '../../../hooks/useFormModalNavigation';
|
||||
|
||||
const CustomFieldOption = ({ customFieldUid, customField }) => {
|
||||
const { type, intlLabel, intlDescription } = customField;
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const { onClickSelectCustomField } = useFormModalNavigation();
|
||||
|
||||
const handleClick = () => {
|
||||
onClickSelectCustomField({
|
||||
attributeType: type,
|
||||
customFieldUid,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<OptionBoxWrapper padding={4} as="button" hasRadius type="button" onClick={handleClick}>
|
||||
<Flex>
|
||||
<AttributeIcon type={type} customField={customFieldUid} />
|
||||
<Box paddingLeft={4}>
|
||||
<Flex>
|
||||
<Typography fontWeight="bold">{formatMessage(intlLabel)}</Typography>
|
||||
</Flex>
|
||||
<Flex>
|
||||
<Typography variant="pi" textColor="neutral600">
|
||||
{formatMessage(intlDescription)}
|
||||
</Typography>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
</OptionBoxWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
CustomFieldOption.propTypes = {
|
||||
customFieldUid: PropTypes.string.isRequired,
|
||||
customField: PropTypes.shape({
|
||||
type: PropTypes.string.isRequired,
|
||||
icon: PropTypes.func,
|
||||
intlLabel: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
defaultMessage: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
intlDescription: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
defaultMessage: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default CustomFieldOption;
|
||||
@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { useCustomFields } from '@strapi/helper-plugin';
|
||||
import { Box } from '@strapi/design-system/Box';
|
||||
import { Grid, GridItem } from '@strapi/design-system/Grid';
|
||||
import { KeyboardNavigable } from '@strapi/design-system/KeyboardNavigable';
|
||||
import { Stack } from '@strapi/design-system/Stack';
|
||||
import { Link } from '@strapi/design-system/Link';
|
||||
import { useIntl } from 'react-intl';
|
||||
import EmptyAttributes from '../EmptyAttributes';
|
||||
import CustomFieldOption from '../CustomFieldOption';
|
||||
import getPadding from '../utils/getPadding';
|
||||
import { getTrad } from '../../../utils';
|
||||
|
||||
const CustomFieldsList = () => {
|
||||
const { formatMessage } = useIntl();
|
||||
const customFields = useCustomFields();
|
||||
const registeredCustomFields = Object.entries(customFields.getAll());
|
||||
|
||||
if (!registeredCustomFields.length) return <EmptyAttributes />;
|
||||
|
||||
// Sort the array alphabetically by customField name
|
||||
const sortedCustomFields = registeredCustomFields.sort((a, b) =>
|
||||
a[1].name > b[1].name ? 1 : -1
|
||||
);
|
||||
|
||||
return (
|
||||
<KeyboardNavigable tagName="button">
|
||||
<Stack spacing={3}>
|
||||
<Grid gap={0}>
|
||||
{sortedCustomFields.map(([uid, customField], index) => {
|
||||
const { paddingLeft, paddingRight } = getPadding(index);
|
||||
|
||||
return (
|
||||
<GridItem key={uid} col={6} style={{ height: '100%' }}>
|
||||
<Box
|
||||
paddingLeft={paddingLeft}
|
||||
paddingRight={paddingRight}
|
||||
paddingBottom={1}
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
<CustomFieldOption key={uid} customFieldUid={uid} customField={customField} />
|
||||
</Box>
|
||||
</GridItem>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
<Link
|
||||
href="https://docs.strapi.io/developer-docs/latest/getting-started/introduction.html"
|
||||
isExternal
|
||||
>
|
||||
{formatMessage({
|
||||
id: getTrad('modalForm.tabs.custom.howToLink'),
|
||||
defaultMessage: 'How to add custom fields',
|
||||
})}
|
||||
</Link>
|
||||
</Stack>
|
||||
</KeyboardNavigable>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomFieldsList;
|
||||
@ -9,16 +9,13 @@ import { useIntl } from 'react-intl';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Box } from '@strapi/design-system/Box';
|
||||
import { Divider } from '@strapi/design-system/Divider';
|
||||
import { Grid, GridItem } from '@strapi/design-system/Grid';
|
||||
import { KeyboardNavigable } from '@strapi/design-system/KeyboardNavigable';
|
||||
import { ModalBody } from '@strapi/design-system/ModalLayout';
|
||||
import { Stack } from '@strapi/design-system/Stack';
|
||||
import { Flex } from '@strapi/design-system/Flex';
|
||||
import { Typography } from '@strapi/design-system/Typography';
|
||||
import { Tabs, Tab, TabGroup, TabPanels, TabPanel } from '@strapi/design-system/Tabs';
|
||||
import { getTrad } from '../../utils';
|
||||
import AttributeOption from './AttributeOption';
|
||||
import EmptyAttributes from './EmptyAttributes';
|
||||
import AttributeList from './AttributeList';
|
||||
import CustomFieldsList from './CustomFieldsList';
|
||||
|
||||
const AttributeOptions = ({ attributes, forTarget, kind }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
@ -53,39 +50,10 @@ const AttributeOptions = ({ attributes, forTarget, kind }) => {
|
||||
</Box>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<KeyboardNavigable tagName="button">
|
||||
<Stack spacing={8}>
|
||||
{attributes.map((attributeRow, index) => {
|
||||
const key = index;
|
||||
|
||||
return (
|
||||
<Grid key={key} gap={0}>
|
||||
{attributeRow.map((attribute, index) => {
|
||||
const isOdd = index % 2 === 1;
|
||||
const paddingLeft = isOdd ? 2 : 0;
|
||||
const paddingRight = isOdd ? 0 : 2;
|
||||
|
||||
return (
|
||||
<GridItem key={attribute} col={6} style={{ height: '100%' }}>
|
||||
<Box
|
||||
paddingLeft={paddingLeft}
|
||||
paddingRight={paddingRight}
|
||||
paddingBottom={1}
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
<AttributeOption type={attribute} />
|
||||
</Box>
|
||||
</GridItem>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</KeyboardNavigable>
|
||||
<AttributeList attributes={attributes} />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<EmptyAttributes />
|
||||
<CustomFieldsList />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
|
||||
@ -123,17 +123,6 @@ exports[`<AttributeOptions /> renders and matches the snapshot 1`] = `
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.c15 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12,1fr);
|
||||
gap: 0px;
|
||||
}
|
||||
|
||||
.c16 {
|
||||
grid-column: span 6;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.c0 {
|
||||
padding: 24px;
|
||||
}
|
||||
@ -175,6 +164,17 @@ exports[`<AttributeOptions /> renders and matches the snapshot 1`] = `
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.c15 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12,1fr);
|
||||
gap: 0px;
|
||||
}
|
||||
|
||||
.c16 {
|
||||
grid-column: span 6;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.c21 {
|
||||
width: 2rem;
|
||||
height: 1.5rem;
|
||||
|
||||
@ -1,12 +1,41 @@
|
||||
import React from 'react';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { render, screen, getByText, fireEvent } from '@testing-library/react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { lightTheme, ThemeProvider } from '@strapi/design-system';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import FormModalNavigationProvider from '../../FormModalNavigationProvider';
|
||||
import AttributeOptions from '../index';
|
||||
|
||||
const mockCustomField = {
|
||||
'plugin::mycustomfields.test': {
|
||||
name: 'color',
|
||||
pluginId: 'mycustomfields',
|
||||
type: 'text',
|
||||
icon: jest.fn(),
|
||||
intlLabel: {
|
||||
id: 'mycustomfields.color.label',
|
||||
defaultMessage: 'Color',
|
||||
},
|
||||
intlDescription: {
|
||||
id: 'mycustomfields.color.description',
|
||||
defaultMessage: 'Select any color',
|
||||
},
|
||||
components: {
|
||||
Input: jest.fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const getAll = jest.fn().mockReturnValue({});
|
||||
jest.mock('@strapi/helper-plugin', () => ({
|
||||
...jest.requireActual('@strapi/helper-plugin'),
|
||||
useCustomFields: () => ({
|
||||
get: jest.fn().mockReturnValue(mockCustomField),
|
||||
getAll,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockAttributes = [
|
||||
[
|
||||
'text',
|
||||
@ -57,8 +86,8 @@ describe('<AttributeOptions />', () => {
|
||||
const App = makeApp();
|
||||
render(App);
|
||||
|
||||
const defaultTab = screen.getByRole('tab', { selected: true });
|
||||
const customTab = screen.getByRole('tab', { selected: false });
|
||||
const defaultTab = screen.getByRole('tab', { selected: true, name: 'Default' });
|
||||
const customTab = screen.getByRole('tab', { selected: false, name: 'Custom' });
|
||||
|
||||
expect(defaultTab).toBeVisible();
|
||||
expect(customTab).toBeVisible();
|
||||
@ -73,17 +102,34 @@ describe('<AttributeOptions />', () => {
|
||||
expect(comingSoonText).toEqual(null);
|
||||
});
|
||||
|
||||
it('switches to the custom tab', () => {
|
||||
it('switches to the custom tab without custom fields', () => {
|
||||
const App = makeApp();
|
||||
render(App);
|
||||
|
||||
const customTab = screen.getByRole('tab', { selected: false });
|
||||
getAll.mockReturnValueOnce({});
|
||||
|
||||
const customTab = screen.getByRole('tab', { selected: false, name: 'Custom' });
|
||||
fireEvent.click(customTab);
|
||||
const customTabSelected = screen.getByRole('tab', { selected: true });
|
||||
const customTabText = getByText(customTabSelected, 'Custom');
|
||||
const customTabSelected = screen.getByRole('tab', { selected: true, name: 'Custom' });
|
||||
const comingSoonText = screen.getByText('Nothing in here yet.');
|
||||
|
||||
expect(customTabText).not.toBe(null);
|
||||
expect(customTabSelected).toBeVisible();
|
||||
expect(comingSoonText).toBeVisible();
|
||||
});
|
||||
|
||||
it('switches to the custom tab with custom fields', () => {
|
||||
getAll.mockReturnValue(mockCustomField);
|
||||
const App = makeApp();
|
||||
render(App);
|
||||
|
||||
const customTab = screen.getByRole('tab', { selected: false, name: 'Custom' });
|
||||
fireEvent.click(customTab);
|
||||
const customTabSelected = screen.getByRole('tab', { selected: true, name: 'Custom' });
|
||||
const customFieldText = screen.getByText('Color');
|
||||
const howToAddLink = screen.getByRole('link', { name: 'How to add custom fields' });
|
||||
|
||||
expect(customTabSelected).toBeVisible();
|
||||
expect(customFieldText).toBeVisible();
|
||||
expect(howToAddLink).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
const getPadding = index => {
|
||||
const isOdd = index % 2 === 1;
|
||||
const paddingLeft = isOdd ? 2 : 0;
|
||||
const paddingRight = isOdd ? 0 : 2;
|
||||
|
||||
return { paddingLeft, paddingRight };
|
||||
};
|
||||
|
||||
export default getPadding;
|
||||
@ -10,6 +10,7 @@ const INITIAL_STATE_DATA = {
|
||||
kind: null,
|
||||
step: null,
|
||||
targetUid: null,
|
||||
customFieldUid: null,
|
||||
};
|
||||
|
||||
export { INITIAL_STATE_DATA };
|
||||
|
||||
@ -8,6 +8,20 @@ const FormModalNavigationProvider = ({ children }) => {
|
||||
const [state, setFormModalNavigationState] = useState(INITIAL_STATE_DATA);
|
||||
const { trackUsage } = useTracking();
|
||||
|
||||
const onClickSelectCustomField = ({ attributeType, customFieldUid }) => {
|
||||
// TODO: Add tracking for custom fields
|
||||
setFormModalNavigationState(prevState => {
|
||||
return {
|
||||
...prevState,
|
||||
actionType: 'create',
|
||||
// TODO: Create a new modalType on EXPANSION-245
|
||||
modalType: 'attribute',
|
||||
attributeType,
|
||||
customFieldUid,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const onClickSelectField = ({ attributeType, step }) => {
|
||||
if (state.forTarget === 'contentType') {
|
||||
trackUsage('didSelectContentTypeFieldType', { type: attributeType });
|
||||
@ -47,7 +61,6 @@ const FormModalNavigationProvider = ({ children }) => {
|
||||
forTarget,
|
||||
targetUid,
|
||||
modalType: 'chooseAttribute',
|
||||
|
||||
isOpen: true,
|
||||
};
|
||||
});
|
||||
@ -146,6 +159,7 @@ const FormModalNavigationProvider = ({ children }) => {
|
||||
value={{
|
||||
...state,
|
||||
onClickSelectField,
|
||||
onClickSelectCustomField,
|
||||
onCloseModal,
|
||||
onNavigateToChooseAttributeModal,
|
||||
onNavigateToAddCompoToDZModal,
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
import { renderHook, act } from '@testing-library/react-hooks';
|
||||
import { INITIAL_STATE_DATA } from '../constants';
|
||||
import FormModalNavigationProvider from '../index';
|
||||
import useFormModalNavigation from '../../../hooks/useFormModalNavigation';
|
||||
|
||||
const removeFunctionsFromObject = state => {
|
||||
const stringified = JSON.stringify(state);
|
||||
const parsed = JSON.parse(stringified);
|
||||
|
||||
return parsed;
|
||||
};
|
||||
|
||||
describe('FromModalNavigationProvider', () => {
|
||||
it('sets the initial state', () => {
|
||||
const { result } = renderHook(() => useFormModalNavigation(), {
|
||||
wrapper: FormModalNavigationProvider,
|
||||
});
|
||||
|
||||
const currentStateWithoutFunctions = removeFunctionsFromObject(result.current);
|
||||
|
||||
expect(currentStateWithoutFunctions).toEqual(INITIAL_STATE_DATA);
|
||||
});
|
||||
|
||||
it('updates the form navigation state when selecting a custom field', () => {
|
||||
const { result } = renderHook(() => useFormModalNavigation(), {
|
||||
wrapper: FormModalNavigationProvider,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.onClickSelectCustomField({
|
||||
attributeType: 'text',
|
||||
customFieldUid: 'plugin::mycustomfields.color',
|
||||
});
|
||||
});
|
||||
|
||||
const currentStateWithoutFunctions = removeFunctionsFromObject(result.current);
|
||||
const expected = {
|
||||
...INITIAL_STATE_DATA,
|
||||
actionType: 'create',
|
||||
modalType: 'attribute',
|
||||
attributeType: 'text',
|
||||
customFieldUid: 'plugin::mycustomfields.color',
|
||||
};
|
||||
|
||||
expect(currentStateWithoutFunctions).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@ -49,7 +49,7 @@ DisplayedType.defaultProps = {
|
||||
|
||||
DisplayedType.propTypes = {
|
||||
type: PropTypes.string.isRequired,
|
||||
customField: PropTypes.bool,
|
||||
customField: PropTypes.string,
|
||||
repeatable: PropTypes.bool,
|
||||
};
|
||||
|
||||
|
||||
@ -160,6 +160,7 @@
|
||||
"modalForm.sub-header.chooseAttribute.component": "Select a field for your component",
|
||||
"modalForm.sub-header.chooseAttribute.singleType": "Select a field for your single type",
|
||||
"modalForm.tabs.custom": "Custom",
|
||||
"modalForm.tabs.custom.howToLink": "How to add custom fields",
|
||||
"modalForm.tabs.default": "Default",
|
||||
"modalForm.tabs.label": "Default and Custom types tabs",
|
||||
"modelPage.attribute.relation-polymorphic": "Relation (polymorphic)",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user