Merge pull request #13820 from strapi/custom-fields/list-custom-fields

[Custom fields] list custom fields
This commit is contained in:
markkaylor 2022-07-28 16:40:08 +02:00 committed by GitHub
commit e222cb29da
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 360 additions and 78 deletions

View File

@ -0,0 +1,7 @@
import React from 'react';
const ColorPickerInput = () => {
return <div>TODO: Map Input Component</div>;
};
export default ColorPickerInput;

View File

@ -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 }) {

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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;

View File

@ -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;

View File

@ -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>

View File

@ -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;

View File

@ -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();
});
});

View File

@ -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;

View File

@ -10,6 +10,7 @@ const INITIAL_STATE_DATA = {
kind: null,
step: null,
targetUid: null,
customFieldUid: null,
};
export { INITIAL_STATE_DATA };

View File

@ -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,

View File

@ -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);
});
});

View File

@ -49,7 +49,7 @@ DisplayedType.defaultProps = {
DisplayedType.propTypes = {
type: PropTypes.string.isRequired,
customField: PropTypes.bool,
customField: PropTypes.string,
repeatable: PropTypes.bool,
};

View File

@ -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)",