Merge branch 'custom-fields/list-custom-fields' of https://github.com/strapi/strapi into list-custom-fields/add-howto-btn

This commit is contained in:
Fernando Chavez 2022-07-21 10:12:28 -04:00
commit 08ae9f65f0
17 changed files with 326 additions and 203 deletions

View File

@ -130,6 +130,10 @@
"morph_to_many": {
"type": "relation",
"relation": "morphToMany"
},
"custom_field": {
"type": "customField",
"customField": "plugin::mycustomfields.color"
}
}
}

View File

@ -0,0 +1,4 @@
<svg width="32" height="24" viewBox="0 0 32 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="31" height="23" rx="2.5" fill="#F0F0FF" stroke="#D9D8FF"/>
<path d="M14.5861 5.13768C11.8681 5.66816 9.6778 7.85296 9.14186 10.5628C8.13012 15.6761 12.7431 19.4879 16.2185 18.9492C17.3451 18.7742 17.8975 17.4562 17.3806 16.4418C16.749 15.2003 17.6514 13.7511 19.0459 13.7511H21.2252C22.2042 13.7511 22.9971 12.9417 22.9999 11.9655C22.9862 7.65608 19.065 4.2654 14.5861 5.13768ZM11.6247 13.7511C11.1407 13.7511 10.7497 13.3601 10.7497 12.8761C10.7497 12.3921 11.1407 12.0011 11.6247 12.0011C12.1087 12.0011 12.4997 12.3921 12.4997 12.8761C12.4997 13.3601 12.1087 13.7511 11.6247 13.7511ZM12.4997 10.251C12.0157 10.251 11.6247 9.86002 11.6247 9.37603C11.6247 8.89204 12.0157 8.50101 12.4997 8.50101C12.9837 8.50101 13.3747 8.89204 13.3747 9.37603C13.3747 9.86002 12.9837 10.251 12.4997 10.251ZM15.9998 8.50101C15.5158 8.50101 15.1248 8.10999 15.1248 7.626C15.1248 7.14201 15.5158 6.75099 15.9998 6.75099C16.4838 6.75099 16.8748 7.14201 16.8748 7.626C16.8748 8.10999 16.4838 8.50101 15.9998 8.50101ZM19.4998 10.251C19.0158 10.251 18.6248 9.86002 18.6248 9.37603C18.6248 8.89204 19.0158 8.50101 19.4998 8.50101C19.9838 8.50101 20.3748 8.89204 20.3748 9.37603C20.3748 9.86002 19.9838 10.251 19.4998 10.251Z" fill="#4945FF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1,6 +1,6 @@
import React from 'react';
import Puzzle from '@strapi/icons/Puzzle';
import icon from './icon.svg';
const ColorPickerIcon = () => <Puzzle />;
const ColorPickerIcon = () => <img src={icon} />;
export default ColorPickerIcon;

View File

@ -4,47 +4,26 @@ 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',
},
intlDescription: {
id: 'mycustomfields.color.description',
defaultMessage: 'Select any color',
},
components: {
Input: async () =>
import(
/* webpackChunkName: "input-component" */ './components/ColorPicker/ColorPickerInput'
),
},
app.customFields.register({
name: 'color',
pluginId: 'mycustomfields',
type: 'text',
icon: ColorPickerIcon,
intlLabel: {
id: 'mycustomfields.color.label',
defaultMessage: 'Color',
},
{
name: 'aMap',
pluginId: 'mycustomfields',
type: 'json',
intlLabel: {
id: 'mycustomfields.map.label',
defaultMessage: 'aMap',
},
intlDescription: {
id: 'mycustomfields.map.description',
defaultMessage: 'Select any location',
},
components: {
Input: async () =>
import(
/* webpackChunkName: "input-component" */ './components/ColorPicker/ColorPickerInput'
),
},
intlDescription: {
id: 'mycustomfields.color.description',
defaultMessage: 'Select any color',
},
]);
components: {
Input: async () =>
import(
/* webpackChunkName: "input-component" */ './components/ColorPicker/ColorPickerInput'
),
},
});
},
bootstrap(app) {},
async registerTrads({ locales }) {

View File

@ -18,7 +18,7 @@ import SingleType from '@strapi/icons/SingleType';
import Text from '@strapi/icons/Text';
import Uid from '@strapi/icons/Uid';
import Numbers from '@strapi/icons/Number';
import { pxToRem } from '@strapi/helper-plugin';
import { pxToRem, useCustomFields } from '@strapi/helper-plugin';
const iconByTypes = {
biginteger: Numbers,
@ -52,14 +52,24 @@ const iconByTypes = {
uid: Uid,
};
export const IconBox = styled(Box)`
const IconBox = styled(Box)`
width: ${pxToRem(32)};
height: ${pxToRem(24)};
box-sizing: content-box;
`;
const AttributeIcon = ({ type, ...rest }) => {
const Compo = iconByTypes[type];
const AttributeIcon = ({ type, customField, ...rest }) => {
const customFieldsRegistry = useCustomFields();
let Compo = iconByTypes[type];
if (customField) {
const { icon } = customFieldsRegistry.get(customField);
if (icon) {
Compo = icon;
}
}
if (!iconByTypes[type]) {
return null;
@ -68,8 +78,13 @@ const AttributeIcon = ({ type, ...rest }) => {
return <IconBox as={Compo} {...rest} />;
};
AttributeIcon.defaultProps = {
customField: null,
};
AttributeIcon.propTypes = {
type: PropTypes.string.isRequired,
customField: PropTypes.string,
};
export default AttributeIcon;

View File

@ -11,11 +11,11 @@ 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, { IconBox } from '../../AttributeIcon';
import AttributeIcon from '../../AttributeIcon';
import useFormModalNavigation from '../../../hooks/useFormModalNavigation';
const CustomFieldOption = ({ uid, customField }) => {
const { type, icon, intlLabel, intlDescription } = customField;
const CustomFieldOption = ({ customFieldUid, customField }) => {
const { type, intlLabel, intlDescription } = customField;
const { formatMessage } = useIntl();
const { onClickSelectCustomField } = useFormModalNavigation();
@ -23,14 +23,14 @@ const CustomFieldOption = ({ uid, customField }) => {
const handleClick = () => {
onClickSelectCustomField({
attributeType: type,
customFieldUid: uid,
customFieldUid,
});
};
return (
<OptionBoxWrapper padding={4} as="button" hasRadius type="button" onClick={handleClick}>
<Flex>
{icon ? <IconBox as={icon} /> : <AttributeIcon type={type} />}
<AttributeIcon type={type} customField={customFieldUid} />
<Box paddingLeft={4}>
<Flex>
<Typography fontWeight="bold">{formatMessage(intlLabel)}</Typography>
@ -47,7 +47,7 @@ const CustomFieldOption = ({ uid, customField }) => {
};
CustomFieldOption.propTypes = {
uid: PropTypes.string.isRequired,
customFieldUid: PropTypes.string.isRequired,
customField: PropTypes.shape({
type: PropTypes.string.isRequired,
icon: PropTypes.func,

View File

@ -41,7 +41,7 @@ const CustomFieldsList = () => {
paddingBottom={1}
style={{ height: '100%' }}
>
<CustomFieldOption key={uid} uid={uid} customField={customField} />
<CustomFieldOption key={uid} customFieldUid={uid} customField={customField} />
</Box>
</GridItem>
);

View File

@ -18,20 +18,18 @@ const EmptyCard = styled(Box)`
export const EmptyCardGrid = () => {
return (
<Flex wrap="wrap" gap="16px">
{Array(4)
.fill(null)
.map((_, idx) => {
return (
<EmptyCard
// eslint-disable-next-line react/no-array-index-key
key={`empty-card-${idx}`}
height="138px"
width="375px"
hasRadius
/>
);
})}
<Flex wrap="wrap" gap={4}>
{[...Array(4)].map((_, idx) => {
return (
<EmptyCard
// eslint-disable-next-line react/no-array-index-key
key={`empty-card-${idx}`}
height="138px"
width="375px"
hasRadius
/>
);
})}
</Flex>
);
};
@ -54,7 +52,7 @@ const EmptyAttributes = () => {
})}
</Typography>
<Box paddingTop={4}>
<Typography variant="delta" as="p" textColor="neutral600" lineHeight="2.25">
<Typography variant="delta" as="p" textColor="neutral600">
{formatMessage({
id: getTrad('modalForm.empty.sub-heading'),
defaultMessage:

View File

@ -3,16 +3,37 @@ import { Router } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { render, screen, getByText, fireEvent } from '@testing-library/react';
import { lightTheme, ThemeProvider } from '@strapi/design-system';
import { useCustomFields } from '@strapi/helper-plugin';
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: jest.fn(() => ({
getAll: jest.fn(() => ({})),
})),
useCustomFields: () => ({
get: jest.fn().mockReturnValue(mockCustomField),
getAll,
}),
}));
const mockAttributes = [
@ -85,6 +106,8 @@ describe('<AttributeOptions />', () => {
const App = makeApp();
render(App);
getAll.mockReturnValueOnce({});
const customTab = screen.getByRole('tab', { selected: false });
fireEvent.click(customTab);
const customTabSelected = screen.getByRole('tab', { selected: true });
@ -96,29 +119,7 @@ describe('<AttributeOptions />', () => {
});
it('switches to the custom tab with custom fields', () => {
useCustomFields.mockImplementationOnce(
jest.fn(() => ({
getAll: jest.fn(() => ({
'plugin::mycustomfields.test': {
name: 'color',
pluginId: 'mycustomfields',
type: 'text',
intlLabel: {
id: 'mycustomfields.color.label',
defaultMessage: 'Color',
},
intlDescription: {
id: 'mycustomfields.color.description',
defaultMessage: 'Select any color',
},
components: {
Input: jest.fn(),
},
},
})),
}))
);
getAll.mockReturnValue(mockCustomField);
const App = makeApp();
render(App);

View File

@ -0,0 +1,56 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
import { Typography } from '@strapi/design-system/Typography';
import getTrad from '../../utils/getTrad';
const DisplayedType = ({ type, customField, repeatable }) => {
const { formatMessage } = useIntl();
let readableType = type;
if (['integer', 'biginteger', 'float', 'decimal'].includes(type)) {
readableType = 'number';
} else if (['string'].includes(type)) {
readableType = 'text';
}
if (customField) {
return (
<Typography>
{formatMessage({
id: getTrad('attribute.customField'),
defaultMessage: 'Custom field',
})}
</Typography>
);
}
return (
<Typography>
{formatMessage({
id: getTrad(`attribute.${readableType}`),
defaultMessage: type,
})}
&nbsp;
{repeatable &&
formatMessage({
id: getTrad('component.repeatable'),
defaultMessage: '(repeatable)',
})}
</Typography>
);
};
DisplayedType.defaultProps = {
customField: null,
repeatable: false,
};
DisplayedType.propTypes = {
type: PropTypes.string.isRequired,
customField: PropTypes.bool,
repeatable: PropTypes.bool,
};
export default DisplayedType;

View File

@ -17,9 +17,11 @@ import Curve from '../../icons/Curve';
import UpperFist from '../UpperFirst';
import BoxWrapper from './BoxWrapper';
import AttributeIcon from '../AttributeIcon';
import DisplayedType from './DisplayedType';
function ListRow({
configurable,
customField,
editTarget,
firstLoopComponentUid,
isFromDynamicZone,
@ -38,14 +40,6 @@ function ListRow({
const isMorph = type === 'relation' && relation.includes('morph');
const ico = ['integer', 'biginteger', 'float', 'decimal'].includes(type) ? 'number' : type;
let readableType = type;
if (['integer', 'biginteger', 'float', 'decimal'].includes(type)) {
readableType = 'number';
} else if (['string'].includes(type)) {
readableType = 'text';
}
const contentType = get(contentTypes, [target], {});
const contentTypeFriendlyName = get(contentType, ['schema', 'displayName'], '');
const isPluginContentType = get(contentType, 'plugin');
@ -93,7 +87,7 @@ function ListRow({
<td style={{ position: 'relative' }}>
{loopNumber !== 0 && <Curve color={isFromDynamicZone ? 'primary200' : 'neutral150'} />}
<Stack paddingLeft={2} spacing={4} horizontal>
<AttributeIcon key={src} type={src} />
<AttributeIcon type={src} customField={customField} />
<Typography fontWeight="bold">{name}</Typography>
</Stack>
</td>
@ -118,18 +112,7 @@ function ListRow({
</span>
</Typography>
) : (
<Typography>
{formatMessage({
id: getTrad(`attribute.${readableType}`),
defaultMessage: type,
})}
&nbsp;
{repeatable &&
formatMessage({
id: getTrad('component.repeatable'),
defaultMessage: '(repeatable)',
})}
</Typography>
<DisplayedType type={type} customField={customField} repeatable={repeatable} />
)}
</td>
<td>
@ -184,6 +167,7 @@ function ListRow({
ListRow.defaultProps = {
configurable: true,
customField: null,
firstLoopComponentUid: null,
isFromDynamicZone: false,
onClick: () => {},
@ -197,6 +181,7 @@ ListRow.defaultProps = {
ListRow.propTypes = {
configurable: PropTypes.bool,
customField: PropTypes.string,
editTarget: PropTypes.string.isRequired,
firstLoopComponentUid: PropTypes.string,
isFromDynamicZone: PropTypes.bool,

View File

@ -4,6 +4,7 @@
"attribute.boolean.description": "Yes or no, 1 or 0, true or false",
"attribute.component": "Component",
"attribute.component.description": "Group of fields that you can repeat or reuse",
"attribute.customField": "Custom field",
"attribute.date": "Date",
"attribute.date.description": "A date picker with hours, minutes and seconds",
"attribute.datetime": "Datetime",
@ -192,4 +193,4 @@
"table.content.create-first-content-type": "Create your first Collection-Type",
"table.content.no-fields.collection-type": "Add your first field to this Collection-Type",
"table.content.no-fields.component": "Add your first field to this component"
}
}

View File

@ -0,0 +1,34 @@
'use strict';
const { formatAttribute } = require('../attributes');
describe('format attributes', () => {
it('replaces type customField with the underlying data type', () => {
const mockAttribute = {
type: 'customField',
customField: 'plugin::mycustomfields.color',
};
global.strapi = {
container: {
// mock container.get('custom-fields')
get: jest.fn(() => ({
// mock container.get('custom-fields').get(uid)
get: jest.fn(() => ({
name: 'color',
plugin: 'mycustomfields',
type: 'text',
})),
})),
},
};
const formattedAttribute = formatAttribute('key', mockAttribute);
const expected = {
type: 'text',
customField: 'plugin::mycustomfields.color',
};
expect(formattedAttribute).toEqual(expected);
});
});

View File

@ -67,6 +67,15 @@ const formatAttribute = (key, attribute) => {
};
}
if (attribute.type === 'customField') {
const customField = strapi.container.get('custom-fields').get(attribute.customField);
return {
...attribute,
type: customField.type,
};
}
return attribute;
};

View File

@ -8,99 +8,123 @@ const strapi = {
};
describe('Custom fields registry', () => {
it('adds a custom field registered in a plugin', () => {
const mockCF = {
name: 'test',
plugin: 'plugintest',
type: 'text',
};
describe('add', () => {
it('adds a custom field registered in a plugin', () => {
const mockCF = {
name: 'test',
plugin: 'plugintest',
type: 'text',
};
const customFields = customFieldsRegistry(strapi);
customFields.add(mockCF);
const customFields = customFieldsRegistry(strapi);
customFields.add(mockCF);
const expected = {
'plugin::plugintest.test': mockCF,
};
expect(customFields.getAll()).toEqual(expected);
const expected = {
'plugin::plugintest.test': mockCF,
};
expect(customFields.getAll()).toEqual(expected);
});
it('adds a custom field not registered in a plugin', () => {
const mockCF = {
name: 'test',
type: 'text',
};
const customFields = customFieldsRegistry(strapi);
customFields.add(mockCF);
const expected = {
'global::test': mockCF,
};
expect(customFields.getAll()).toEqual(expected);
});
it('requires a name key on the custom field', () => {
const mockCF = {
type: 'test',
};
const customFields = customFieldsRegistry(strapi);
expect(() => customFields.add(mockCF)).toThrowError(
`Custom fields require a 'name' and 'type' key`
);
});
it('requires a type key on the custom field', () => {
const mockCF = {
name: 'test',
};
const customFields = customFieldsRegistry(strapi);
expect(() => customFields.add(mockCF)).toThrowError(
`Custom fields require a 'name' and 'type' key`
);
});
it('validates the name can be used as an object key', () => {
const mockCF = {
name: 'test.boom',
type: 'text',
};
const customFields = customFieldsRegistry(strapi);
expect(() => customFields.add(mockCF)).toThrowError(
`Custom field name: 'test.boom' is not a valid object key`
);
});
it('validates the type is a Strapi type', () => {
const mockCF = {
name: 'test',
type: 'geojson',
};
const customFields = customFieldsRegistry(strapi);
expect(() => customFields.add(mockCF)).toThrowError(
`Custom field type: 'geojson' is not a valid Strapi type`
);
});
it('confirms the custom field does not already exist', () => {
const mockCF = {
name: 'test',
plugin: 'plugintest',
type: 'text',
};
const customFields = customFieldsRegistry(strapi);
customFields.add(mockCF);
expect(() => customFields.add(mockCF)).toThrowError(
`Custom field: 'plugin::plugintest.test' has already been registered`
);
});
});
describe('get', () => {
it('gets a registered custom field', () => {
const mockCF = {
name: 'test',
plugin: 'plugintest',
type: 'text',
};
it('adds a custom field not registered in a plugin', () => {
const mockCF = {
name: 'test',
type: 'text',
};
const customFields = customFieldsRegistry(strapi);
customFields.add(mockCF);
const customFields = customFieldsRegistry(strapi);
customFields.add(mockCF);
expect(customFields.get('plugin::plugintest.test')).toEqual(mockCF);
});
const expected = {
'global::test': mockCF,
};
expect(customFields.getAll()).toEqual(expected);
});
it('throws when a custom field is not registered', () => {
const customFields = customFieldsRegistry(strapi);
it('requires a name key on the custom field', () => {
const mockCF = {
type: 'test',
};
const customFields = customFieldsRegistry(strapi);
expect(() => customFields.add(mockCF)).toThrowError(
`Custom fields require a 'name' and 'type' key`
);
});
it('requires a type key on the custom field', () => {
const mockCF = {
name: 'test',
};
const customFields = customFieldsRegistry(strapi);
expect(() => customFields.add(mockCF)).toThrowError(
`Custom fields require a 'name' and 'type' key`
);
});
it('validates the name can be used as an object key', () => {
const mockCF = {
name: 'test.boom',
type: 'text',
};
const customFields = customFieldsRegistry(strapi);
expect(() => customFields.add(mockCF)).toThrowError(
`Custom field name: 'test.boom' is not a valid object key`
);
});
it('validates the type is a Strapi type', () => {
const mockCF = {
name: 'test',
type: 'geojson',
};
const customFields = customFieldsRegistry(strapi);
expect(() => customFields.add(mockCF)).toThrowError(
`Custom field type: 'geojson' is not a valid Strapi type`
);
});
it('confirms the custom field does not already exist', () => {
const mockCF = {
name: 'test',
plugin: 'plugintest',
type: 'text',
};
const customFields = customFieldsRegistry(strapi);
customFields.add(mockCF);
expect(() => customFields.add(mockCF)).toThrowError(
`Custom field: 'plugin::plugintest.test' has already been registered`
);
expect(() => customFields.get('plugin::plugintest.test')).toThrowError(
`Could not find Custom Field: plugin::plugintest.test`
);
});
});
});

View File

@ -10,6 +10,14 @@ const customFieldsRegistry = strapi => {
getAll() {
return customFields;
},
get(customField) {
const registeredCustomField = customFields[customField];
if (!registeredCustomField) {
throw new Error(`Could not find Custom Field: ${customField}`);
}
return registeredCustomField;
},
add(customField) {
const customFieldList = Array.isArray(customField) ? customField : [customField];

View File

@ -19,6 +19,11 @@ const cleanSchemaAttributes = (attributes, { typeMap = new Map(), isRequest = fa
delete attributesCopy[prop].default;
}
if (attribute.type === 'customField') {
const customField = strapi.container.get('custom-fields').get(attribute.customField);
attribute.type = customField.type;
}
switch (attribute.type) {
case 'password': {
if (!isRequest) {