diff --git a/examples/getstarted/src/api/kitchensink/content-types/kitchensink/schema.json b/examples/getstarted/src/api/kitchensink/content-types/kitchensink/schema.json index 1a6bd7c380..dbe74efca1 100644 --- a/examples/getstarted/src/api/kitchensink/content-types/kitchensink/schema.json +++ b/examples/getstarted/src/api/kitchensink/content-types/kitchensink/schema.json @@ -130,6 +130,10 @@ "morph_to_many": { "type": "relation", "relation": "morphToMany" + }, + "custom_field": { + "type": "customField", + "customField": "plugin::mycustomfields.color" } } } diff --git a/examples/getstarted/src/plugins/mycustomfields/admin/src/components/ColorPicker/ColorPickerIcon/icon.svg b/examples/getstarted/src/plugins/mycustomfields/admin/src/components/ColorPicker/ColorPickerIcon/icon.svg new file mode 100644 index 0000000000..991b9d4e4c --- /dev/null +++ b/examples/getstarted/src/plugins/mycustomfields/admin/src/components/ColorPicker/ColorPickerIcon/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/examples/getstarted/src/plugins/mycustomfields/admin/src/components/ColorPicker/ColorPickerIcon/index.js b/examples/getstarted/src/plugins/mycustomfields/admin/src/components/ColorPicker/ColorPickerIcon/index.js index c5d405960c..4540ff60c0 100644 --- a/examples/getstarted/src/plugins/mycustomfields/admin/src/components/ColorPicker/ColorPickerIcon/index.js +++ b/examples/getstarted/src/plugins/mycustomfields/admin/src/components/ColorPicker/ColorPickerIcon/index.js @@ -1,6 +1,6 @@ import React from 'react'; -import Puzzle from '@strapi/icons/Puzzle'; +import icon from './icon.svg'; -const ColorPickerIcon = () => ; +const ColorPickerIcon = () => ; export default ColorPickerIcon; diff --git a/examples/getstarted/src/plugins/mycustomfields/admin/src/index.js b/examples/getstarted/src/plugins/mycustomfields/admin/src/index.js index 2ac6f2fd6d..76e5020977 100644 --- a/examples/getstarted/src/plugins/mycustomfields/admin/src/index.js +++ b/examples/getstarted/src/plugins/mycustomfields/admin/src/index.js @@ -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 }) { diff --git a/packages/core/content-type-builder/admin/src/components/AttributeIcon/index.js b/packages/core/content-type-builder/admin/src/components/AttributeIcon/index.js index 25ab69a679..e88d677300 100644 --- a/packages/core/content-type-builder/admin/src/components/AttributeIcon/index.js +++ b/packages/core/content-type-builder/admin/src/components/AttributeIcon/index.js @@ -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 ; }; +AttributeIcon.defaultProps = { + customField: null, +}; + AttributeIcon.propTypes = { type: PropTypes.string.isRequired, + customField: PropTypes.string, }; export default AttributeIcon; diff --git a/packages/core/content-type-builder/admin/src/components/AttributeOptions/CustomFieldOption/index.js b/packages/core/content-type-builder/admin/src/components/AttributeOptions/CustomFieldOption/index.js index b9c5edfc35..73a70e5cfd 100644 --- a/packages/core/content-type-builder/admin/src/components/AttributeOptions/CustomFieldOption/index.js +++ b/packages/core/content-type-builder/admin/src/components/AttributeOptions/CustomFieldOption/index.js @@ -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 ( - {icon ? : } + {formatMessage(intlLabel)} @@ -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, diff --git a/packages/core/content-type-builder/admin/src/components/AttributeOptions/CustomFieldsList/index.js b/packages/core/content-type-builder/admin/src/components/AttributeOptions/CustomFieldsList/index.js index 39e728e0ce..ffc8b8b67a 100644 --- a/packages/core/content-type-builder/admin/src/components/AttributeOptions/CustomFieldsList/index.js +++ b/packages/core/content-type-builder/admin/src/components/AttributeOptions/CustomFieldsList/index.js @@ -41,7 +41,7 @@ const CustomFieldsList = () => { paddingBottom={1} style={{ height: '100%' }} > - + ); diff --git a/packages/core/content-type-builder/admin/src/components/AttributeOptions/EmptyAttributes/index.js b/packages/core/content-type-builder/admin/src/components/AttributeOptions/EmptyAttributes/index.js index 0077308efe..583247d144 100644 --- a/packages/core/content-type-builder/admin/src/components/AttributeOptions/EmptyAttributes/index.js +++ b/packages/core/content-type-builder/admin/src/components/AttributeOptions/EmptyAttributes/index.js @@ -18,20 +18,18 @@ const EmptyCard = styled(Box)` export const EmptyCardGrid = () => { return ( - - {Array(4) - .fill(null) - .map((_, idx) => { - return ( - - ); - })} + + {[...Array(4)].map((_, idx) => { + return ( + + ); + })} ); }; @@ -54,7 +52,7 @@ const EmptyAttributes = () => { })} - + {formatMessage({ id: getTrad('modalForm.empty.sub-heading'), defaultMessage: diff --git a/packages/core/content-type-builder/admin/src/components/AttributeOptions/tests/index.test.js b/packages/core/content-type-builder/admin/src/components/AttributeOptions/tests/index.test.js index f376410d11..faa5d58500 100644 --- a/packages/core/content-type-builder/admin/src/components/AttributeOptions/tests/index.test.js +++ b/packages/core/content-type-builder/admin/src/components/AttributeOptions/tests/index.test.js @@ -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('', () => { 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('', () => { }); 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); diff --git a/packages/core/content-type-builder/admin/src/components/ListRow/DisplayedType.js b/packages/core/content-type-builder/admin/src/components/ListRow/DisplayedType.js new file mode 100644 index 0000000000..e74fe0bafb --- /dev/null +++ b/packages/core/content-type-builder/admin/src/components/ListRow/DisplayedType.js @@ -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 ( + + {formatMessage({ + id: getTrad('attribute.customField'), + defaultMessage: 'Custom field', + })} + + ); + } + + return ( + + {formatMessage({ + id: getTrad(`attribute.${readableType}`), + defaultMessage: type, + })} +   + {repeatable && + formatMessage({ + id: getTrad('component.repeatable'), + defaultMessage: '(repeatable)', + })} + + ); +}; + +DisplayedType.defaultProps = { + customField: null, + repeatable: false, +}; + +DisplayedType.propTypes = { + type: PropTypes.string.isRequired, + customField: PropTypes.bool, + repeatable: PropTypes.bool, +}; + +export default DisplayedType; diff --git a/packages/core/content-type-builder/admin/src/components/ListRow/index.js b/packages/core/content-type-builder/admin/src/components/ListRow/index.js index 8cf7c8cbff..75e9c377aa 100644 --- a/packages/core/content-type-builder/admin/src/components/ListRow/index.js +++ b/packages/core/content-type-builder/admin/src/components/ListRow/index.js @@ -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({ {loopNumber !== 0 && } - + {name} @@ -118,18 +112,7 @@ function ListRow({ ) : ( - - {formatMessage({ - id: getTrad(`attribute.${readableType}`), - defaultMessage: type, - })} -   - {repeatable && - formatMessage({ - id: getTrad('component.repeatable'), - defaultMessage: '(repeatable)', - })} - + )} @@ -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, diff --git a/packages/core/content-type-builder/admin/src/translations/en.json b/packages/core/content-type-builder/admin/src/translations/en.json index 0d996fe63f..0a3f36e36e 100644 --- a/packages/core/content-type-builder/admin/src/translations/en.json +++ b/packages/core/content-type-builder/admin/src/translations/en.json @@ -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" -} \ No newline at end of file +} diff --git a/packages/core/content-type-builder/server/utils/__tests__/attributes.test.js b/packages/core/content-type-builder/server/utils/__tests__/attributes.test.js new file mode 100644 index 0000000000..66525d33ad --- /dev/null +++ b/packages/core/content-type-builder/server/utils/__tests__/attributes.test.js @@ -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); + }); +}); diff --git a/packages/core/content-type-builder/server/utils/attributes.js b/packages/core/content-type-builder/server/utils/attributes.js index e90fc1fd1a..620a537811 100644 --- a/packages/core/content-type-builder/server/utils/attributes.js +++ b/packages/core/content-type-builder/server/utils/attributes.js @@ -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; }; diff --git a/packages/core/strapi/lib/core/registries/__tests__/custom-fields.test.js b/packages/core/strapi/lib/core/registries/__tests__/custom-fields.test.js index b4330905de..60cab67922 100644 --- a/packages/core/strapi/lib/core/registries/__tests__/custom-fields.test.js +++ b/packages/core/strapi/lib/core/registries/__tests__/custom-fields.test.js @@ -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` + ); + }); }); }); diff --git a/packages/core/strapi/lib/core/registries/custom-fields.js b/packages/core/strapi/lib/core/registries/custom-fields.js index db850c8773..f254c3f961 100644 --- a/packages/core/strapi/lib/core/registries/custom-fields.js +++ b/packages/core/strapi/lib/core/registries/custom-fields.js @@ -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]; diff --git a/packages/plugins/documentation/server/services/helpers/utils/clean-schema-attributes.js b/packages/plugins/documentation/server/services/helpers/utils/clean-schema-attributes.js index 0c8bc4a467..b3fdc73925 100644 --- a/packages/plugins/documentation/server/services/helpers/utils/clean-schema-attributes.js +++ b/packages/plugins/documentation/server/services/helpers/utils/clean-schema-attributes.js @@ -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) {