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 f21f9781b1..c1eff02ca0 100644 --- a/examples/getstarted/src/api/kitchensink/content-types/kitchensink/schema.json +++ b/examples/getstarted/src/api/kitchensink/content-types/kitchensink/schema.json @@ -134,6 +134,12 @@ "custom_field": { "type": "customField", "customField": "plugin::color-picker.color" + }, + "custom_field_with_default_options": { + "type": "customField", + "regex": "^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$", + "required": true, + "customField": "plugin::color-picker.color" } } } diff --git a/examples/getstarted/src/plugins/myplugin/admin/src/index.js b/examples/getstarted/src/plugins/myplugin/admin/src/index.js index 1a8d2c9a79..6e19cf27c3 100644 --- a/examples/getstarted/src/plugins/myplugin/admin/src/index.js +++ b/examples/getstarted/src/plugins/myplugin/admin/src/index.js @@ -26,6 +26,47 @@ export default { id: pluginId, name, }); + + const allTypes = [ + 'biginteger', + 'boolean', + 'date', + 'datetime', + 'decimal', + 'email', + 'enumeration', + 'float', + 'integer', + 'json', + 'password', + 'richtext', + 'string', + 'text', + 'time', + 'uid', + ]; + + allTypes.forEach((type) => { + const customField = { + type, + pluginId: 'myplugin', + name: `custom${type}`, + intlLabel: { + id: 'customfieldtest', + defaultMessage: `custom${type}`, + }, + intlDescription: { + id: 'customfieldtest', + defaultMessage: `custom${type}`, + }, + components: { + Input: async () => + import(/* webpackChunkName: "test-custom-field" */ './components/PluginIcon'), + }, + }; + + app.customFields.register(customField); + }); }, bootstrap() {}, async registerTrads({ locales }) { diff --git a/examples/getstarted/src/plugins/myplugin/server/register.js b/examples/getstarted/src/plugins/myplugin/server/register.js index 49373a7061..702619c74a 100644 --- a/examples/getstarted/src/plugins/myplugin/server/register.js +++ b/examples/getstarted/src/plugins/myplugin/server/register.js @@ -1,6 +1,33 @@ 'use strict'; module.exports = ({ strapi }) => { + const allTypes = [ + 'biginteger', + 'boolean', + 'date', + 'datetime', + 'decimal', + 'email', + 'enumeration', + 'float', + 'integer', + 'json', + 'password', + 'richtext', + 'string', + 'text', + 'time', + 'uid', + ]; + + allTypes.forEach((type) => { + strapi.customFields.register({ + type, + name: `custom${type}`, + plugin: 'myplugin', + }); + }); + if (strapi.plugin('graphql')) { require('./graphql')({ strapi }); } diff --git a/packages/core/admin/admin/src/core/apis/CustomFields.js b/packages/core/admin/admin/src/core/apis/CustomFields.js index 4fb5e87948..eb17f6d129 100644 --- a/packages/core/admin/admin/src/core/apis/CustomFields.js +++ b/packages/core/admin/admin/src/core/apis/CustomFields.js @@ -19,6 +19,37 @@ const ALLOWED_TYPES = [ 'uid', ]; +const ALLOWED_ROOT_LEVEL_OPTIONS = [ + 'min', + 'minLength', + 'max', + 'maxLength', + 'required', + 'regex', + 'enum', + 'unique', + 'private', + 'default', +]; + +const getOptionValidations = (options, validations = []) => { + options.forEach((option) => { + if (option.items) { + getOptionValidations(option.items, validations); + } + + if (!option.name) return; + + validations.push({ + isValidOptionPath: + ALLOWED_ROOT_LEVEL_OPTIONS.includes(option.name) || option.name.startsWith('options'), + errorMessage: `'${option.name}' must be prefixed with 'options'`, + }); + }); + + return validations; +}; + class CustomFields { constructor() { this.customFields = {}; @@ -32,7 +63,8 @@ class CustomFields { }); } else { // Handle individual custom field - const { name, pluginId, type, intlLabel, intlDescription, components } = customFields; + const { name, pluginId, type, intlLabel, intlDescription, components, options } = + customFields; // Ensure required attributes are provided invariant(name, 'A name must be provided'); @@ -55,6 +87,16 @@ class CustomFields { `Custom field name: '${name}' is not a valid object key` ); + // Ensure options have valid name paths + const allFormOptions = [...(options?.base || []), ...(options?.advanced || [])]; + + if (allFormOptions.length) { + const optionPathValidations = getOptionValidations(allFormOptions); + optionPathValidations.forEach(({ isValidOptionPath, errorMessage }) => { + invariant(isValidOptionPath, errorMessage); + }); + } + // When no plugin is specified, default to the global namespace const uid = pluginId ? `plugin::${pluginId}.${name}` : `global::${name}`; diff --git a/packages/core/admin/admin/src/tests/StrapiApp.test.js b/packages/core/admin/admin/src/tests/StrapiApp.test.js index f37cd52569..9d1a06974b 100644 --- a/packages/core/admin/admin/src/tests/StrapiApp.test.js +++ b/packages/core/admin/admin/src/tests/StrapiApp.test.js @@ -230,6 +230,33 @@ describe('ADMIN | StrapiApp', () => { }); }); + it('should register a custom field with valid options', () => { + const app = StrapiApp({ middlewares, reducers, library }); + const field = { + name: 'optionsCustomField', + pluginId: 'myplugin', + type: 'text', + icon: jest.fn(), + intlLabel: { id: 'foo', defaultMessage: 'foo' }, + intlDescription: { id: 'foo', defaultMessage: 'foo' }, + components: { + Input: jest.fn(), + }, + options: { + base: [{ name: 'regex' }], + advanced: [ + { name: 'options.plop' }, + { name: 'required' }, + { sectionTitle: null, items: [{ name: 'options.deep' }] }, + { sectionTitle: null, items: [{ name: 'private' }] }, + ], + }, + }; + + app.customFields.register(field); + expect(app.customFields.get('plugin::myplugin.optionsCustomField')).toEqual(field); + }); + it('should register several custom fields at once', () => { const app = StrapiApp({ middlewares, reducers, library }); const fields = [ @@ -328,6 +355,34 @@ describe('ADMIN | StrapiApp', () => { expect(() => app.customFields.register(field)).toThrowError(/(a|an) .* must be provided/i); }); + + it('should validate option path names', () => { + const app = StrapiApp({ middlewares, reducers, library }); + const field = { + name: 'test', + pluginId: 'myplugin', + type: 'text', + intlLabel: { id: 'foo', defaultMessage: 'foo' }, + intlDescription: { id: 'foo', defaultMessage: 'foo' }, + components: { + Input: jest.fn(), + }, + options: { + base: [{ name: 'regex' }], + advanced: [{ name: 'plop' }], + }, + }; + + // Test shallow value + expect(() => app.customFields.register(field)).toThrowError( + "'plop' must be prefixed with 'options'" + ); + // Test deep value + field.options.advanced = [{ sectionTitle: null, items: [{ name: 'deep.plop' }] }]; + expect(() => app.customFields.register(field)).toThrowError( + "'deep.plop' must be prefixed with 'options'" + ); + }); }); describe('Menu api', () => { diff --git a/packages/core/content-type-builder/admin/src/components/FormModal/reducer.js b/packages/core/content-type-builder/admin/src/components/FormModal/reducer.js index ad90cf410b..c301fd8c17 100644 --- a/packages/core/content-type-builder/admin/src/components/FormModal/reducer.js +++ b/packages/core/content-type-builder/admin/src/components/FormModal/reducer.js @@ -2,11 +2,13 @@ import produce from 'immer'; import pluralize from 'pluralize'; import set from 'lodash/set'; import snakeCase from 'lodash/snakeCase'; +import _ from 'lodash'; import getRelationType from '../../utils/getRelationType'; import nameToSlug from '../../utils/nameToSlug'; import { createComponentUid } from './utils/createUid'; import { shouldPluralizeName, shouldPluralizeTargetAttribute } from './utils/relations'; import * as actions from './constants'; +import { getCustomFieldDefaultOptions } from './utils/getCustomFieldDefaultOptions'; const initialState = { formErrors: {}, @@ -301,6 +303,19 @@ const reducer = (state = initialState, action) => draftState.modifiedData = { ...options, type: customField.type }; + const allOptions = [ + ...(customField?.options?.base || []), + ...(customField?.options?.advanced || []), + ]; + + const optionDefaults = getCustomFieldDefaultOptions(allOptions); + + if (optionDefaults.length) { + optionDefaults.forEach(({ name, defaultValue }) => + _.set(draftState.modifiedData, name, defaultValue) + ); + } + break; } case actions.SET_DYNAMIC_ZONE_DATA_SCHEMA: { diff --git a/packages/core/content-type-builder/admin/src/components/FormModal/tests/reducer.set-custom-field-data-schema.test.js b/packages/core/content-type-builder/admin/src/components/FormModal/tests/reducer.set-custom-field-data-schema.test.js index 2e72811b6b..aad09a3bd7 100644 --- a/packages/core/content-type-builder/admin/src/components/FormModal/tests/reducer.set-custom-field-data-schema.test.js +++ b/packages/core/content-type-builder/admin/src/components/FormModal/tests/reducer.set-custom-field-data-schema.test.js @@ -51,4 +51,58 @@ describe('CTB | components | FormModal | reducer | actions | SET_CUSTOM_FIELD_DA expect(reducer(initialState, action)).toEqual(expected); }); + + it("adds a custom field's default options", () => { + const mockCustomFieldWithOptionsPath = { + ...mockCustomField, + options: { + advanced: [ + { + name: 'regex', + type: 'text', + defaultValue: '^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$', + }, + { + sectionTitle: { + id: 'global.settings', + defaultMessage: 'Settings', + }, + items: [ + { + name: 'required', + type: 'checkbox', + defaultValue: true, + }, + { + name: 'options.format', + type: 'text', + defaultValue: 'hex', + }, + ], + }, + ], + }, + }; + + const action = { + type: actions.SET_CUSTOM_FIELD_DATA_SCHEMA, + customField: mockCustomFieldWithOptionsPath, + isEditing: false, + modifiedDataToSetForEditing: { name: null }, + }; + + const expected = { + ...initialState, + modifiedData: { + type: mockCustomField.type, + required: true, + regex: '^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$', + options: { + format: 'hex', + }, + }, + }; + + expect(reducer(initialState, action)).toEqual(expected); + }); }); diff --git a/packages/core/content-type-builder/admin/src/components/FormModal/utils/getCustomFieldDefaultOptions.js b/packages/core/content-type-builder/admin/src/components/FormModal/utils/getCustomFieldDefaultOptions.js new file mode 100644 index 0000000000..c1b63e080f --- /dev/null +++ b/packages/core/content-type-builder/admin/src/components/FormModal/utils/getCustomFieldDefaultOptions.js @@ -0,0 +1,16 @@ +const getCustomFieldDefaultOptions = (options, optionDefaults = []) => { + options.forEach((option) => { + if (option.items) { + getCustomFieldDefaultOptions(option.items, optionDefaults); + } + + if ('defaultValue' in option) { + const { name, defaultValue } = option; + optionDefaults.push({ name, defaultValue }); + } + }); + + return optionDefaults; +}; + +module.exports = { getCustomFieldDefaultOptions }; diff --git a/packages/plugins/color-picker/admin/src/index.js b/packages/plugins/color-picker/admin/src/index.js index 2845534184..1f464c26fc 100644 --- a/packages/plugins/color-picker/admin/src/index.js +++ b/packages/plugins/color-picker/admin/src/index.js @@ -33,6 +33,7 @@ export default { }, name: 'regex', type: 'text', + defaultValue: '^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$', description: { id: getTrad('color-picker.options.advanced.regex.description'), defaultMessage: 'The text of the regular expression', @@ -47,6 +48,7 @@ export default { { name: 'required', type: 'checkbox', + defaultValue: true, intlLabel: { id: getTrad('color-picker.options.advanced.requiredField'), defaultMessage: 'Required field',