mirror of
https://github.com/strapi/strapi.git
synced 2025-12-26 14:44:31 +00:00
Merge pull request #14590 from strapi/feature/custom-field-default-options
[Custom fields] Add default value to options
This commit is contained in:
commit
a6193d04e1
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }) {
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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}`;
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 };
|
||||
@ -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',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user