Merge pull request #14590 from strapi/feature/custom-field-default-options

[Custom fields] Add default value to options
This commit is contained in:
markkaylor 2022-11-23 09:16:42 +01:00 committed by GitHub
commit a6193d04e1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 282 additions and 1 deletions

View File

@ -134,6 +134,11 @@
"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})$",
"customField": "plugin::color-picker.color"
}
}
}

View File

@ -26,6 +26,48 @@ 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 upcasedType = type.charAt(0).toUpperCase() + type.slice(1);
const customField = {
type,
pluginId: 'myplugin',
name: `custom${upcasedType}`,
intlLabel: {
id: 'customfieldtest',
defaultMessage: `custom${upcasedType}`,
},
intlDescription: {
id: 'customfieldtest',
defaultMessage: `custom${upcasedType}`,
},
components: {
Input: async () =>
import(/* webpackChunkName: "test-custom-field" */ './components/PluginIcon'),
},
};
app.customFields.register(customField);
});
},
bootstrap() {},
async registerTrads({ locales }) {

View File

@ -1,6 +1,34 @@
'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) => {
const upcasedType = type.charAt(0).toUpperCase() + type.slice(1);
strapi.customFields.register({
type,
name: `custom${upcasedType}`,
plugin: 'myplugin',
});
});
if (strapi.plugin('graphql')) {
require('./graphql')({ strapi });
}

View File

@ -19,6 +19,40 @@ const ALLOWED_TYPES = [
'uid',
];
const ALLOWED_ROOT_LEVEL_OPTIONS = [
'min',
'minLength',
'max',
'maxLength',
'required',
'regex',
'enum',
'unique',
'private',
'default',
];
const optionValidationsReducer = (acc, option) => {
if (option.items) {
return option.items.reduce(optionValidationsReducer, acc);
}
if (!option.name) {
acc.push({
isValidOptionPath: false,
errorMessage: "The 'name' property is required on an options object",
});
} else {
acc.push({
isValidOptionPath:
ALLOWED_ROOT_LEVEL_OPTIONS.includes(option.name) || option.name.startsWith('options'),
errorMessage: `'${option.name}' must be prefixed with 'options.'`,
});
}
return acc;
};
class CustomFields {
constructor() {
this.customFields = {};
@ -32,7 +66,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 +90,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 = allFormOptions.reduce(optionValidationsReducer, []);
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}`;

View File

@ -192,6 +192,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 = [
@ -290,6 +317,56 @@ 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.'"
);
});
it('requires options to have a name property', () => {
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: [{ boom: 'kapow' }],
},
};
expect(() => app.customFields.register(field)).toThrowError(
"The 'name' property is required on an options object"
);
});
});
describe('Menu api', () => {

View File

@ -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 { customFieldDefaultOptionsReducer } from './utils/customFieldDefaultOptionsReducer';
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 = allOptions.reduce(customFieldDefaultOptionsReducer, []);
if (optionDefaults.length) {
optionDefaults.forEach(({ name, defaultValue }) =>
_.set(draftState.modifiedData, name, defaultValue)
);
}
break;
}
case actions.SET_DYNAMIC_ZONE_DATA_SCHEMA: {

View File

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

View File

@ -0,0 +1,14 @@
const customFieldDefaultOptionsReducer = (acc, option) => {
if (option.items) {
return option.items.reduce(customFieldDefaultOptionsReducer, acc);
}
if ('defaultValue' in option) {
const { name, defaultValue } = option;
acc.push({ name, defaultValue });
}
return acc;
};
module.exports = { customFieldDefaultOptionsReducer };

View File

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