From 3561ab6db9a7c42dc4fa2414f90ab5d378abd964 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20MANCA?= Date: Tue, 10 Jun 2025 16:34:28 +0200 Subject: [PATCH] feat(uid): add regex attribute (#23141) --- .../EditView/components/FormInputs/UID.tsx | 10 ++++- .../admin/src/utils/validation.ts | 4 +- .../src/controllers/validation/index.ts | 43 +++++++++++++++++-- .../FormModal/attributes/advancedForm.ts | 1 + .../validation/__tests__/types.test.ts | 17 ++++++++ .../src/controllers/validation/schema.ts | 6 +++ .../src/controllers/validation/types.ts | 17 +++++++- .../services/entity-validator/validators.ts | 4 ++ .../src/schema/attribute/definitions/uid.ts | 1 + .../content-manager/uid.test.api.js | 5 ++- 10 files changed, 98 insertions(+), 10 deletions(-) diff --git a/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/UID.tsx b/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/UID.tsx index 76133a7bb9..91d617f766 100644 --- a/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/UID.tsx +++ b/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/UID.tsx @@ -42,11 +42,12 @@ import type { Schema } from '@strapi/types'; const UID_REGEX = /^[A-Za-z0-9-_.~]*$/; interface UIDInputProps extends Omit { + attribute?: Pick; type: Schema.Attribute.TypeOf; } const UIDInput = React.forwardRef( - ({ hint, label, labelAction, name, required, ...props }, ref) => { + ({ hint, label, labelAction, name, required, attribute = {}, ...props }, ref) => { const { currentDocumentMeta } = useDocumentContext('UIDInput'); const allFormValues = useForm('InputUID', (form) => form.values); const [availability, setAvailability] = React.useState(); @@ -61,6 +62,9 @@ const UIDInput = React.forwardRef( const [{ query }] = useQueryParams(); const params = React.useMemo(() => buildValidParams(query), [query]); + const { regex } = attribute; + const validationRegExp = regex ? new RegExp(regex) : UID_REGEX; + const { data: defaultGeneratedUID, isLoading: isGeneratingDefaultUID, @@ -143,7 +147,9 @@ const UIDInput = React.forwardRef( { // Don't check availability if the value is empty or wasn't changed skip: !Boolean( - (hasChanged || isCloning) && debouncedValue && UID_REGEX.test(debouncedValue.trim()) + (hasChanged || isCloning) && + debouncedValue && + validationRegExp.test(debouncedValue.trim()) ), } ); diff --git a/packages/core/content-manager/admin/src/utils/validation.ts b/packages/core/content-manager/admin/src/utils/validation.ts index e1a1e70e44..ddef147b1d 100644 --- a/packages/core/content-manager/admin/src/utils/validation.ts +++ b/packages/core/content-manager/admin/src/utils/validation.ts @@ -234,7 +234,9 @@ const createAttributeSchema = ( case 'text': return yup.string(); case 'uid': - return yup.string().matches(/^[A-Za-z0-9-_.~]*$/); + return yup + .string() + .matches(attribute.regex ? new RegExp(attribute.regex) : /^[A-Za-z0-9-_.~]*$/); default: /** * This allows any value. diff --git a/packages/core/content-manager/server/src/controllers/validation/index.ts b/packages/core/content-manager/server/src/controllers/validation/index.ts index c8c4a4489d..7a68c2d4cb 100644 --- a/packages/core/content-manager/server/src/controllers/validation/index.ts +++ b/packages/core/content-manager/server/src/controllers/validation/index.ts @@ -1,5 +1,8 @@ import _ from 'lodash'; +import { Schema, UID } from '@strapi/types'; import { yup, validateYupSchema, errors } from '@strapi/utils'; +import { ValidateOptions } from 'yup/lib/types'; +import { TestContext } from 'yup'; import createModelConfigurationSchema from './model-configuration'; const { PaginationError, ValidationError } = errors; @@ -27,8 +30,19 @@ const checkUIDAvailabilityInputSchema = yup.object({ field: yup.string().required(), value: yup .string() - .matches(/^[A-Za-z0-9-_.~]*$/) - .required(), + .required() + .test( + 'isValueMatchingRegex', + `\${path} must match the custom regex or the default one "/^[A-Za-z0-9-_.~]*$/"`, + function (value, context: TestContext<{ regex?: string }>) { + return ( + value === '' || + (context.options.context?.regex + ? new RegExp(context.options?.context.regex).test(value as string) + : /^[A-Za-z0-9-_.~]*$/.test(value as string)) + ); + } + ), }); const validateUIDField = (contentTypeUID: any, field: any) => { @@ -61,7 +75,30 @@ const validatePagination = ({ page, pageSize }: any) => { const validateKind = validateYupSchema(kindSchema); const validateBulkActionInput = validateYupSchema(bulkActionInputSchema); const validateGenerateUIDInput = validateYupSchema(generateUIDInputSchema); -const validateCheckUIDAvailabilityInput = validateYupSchema(checkUIDAvailabilityInputSchema); +const validateCheckUIDAvailabilityInput = (body: { + contentTypeUID: UID.ContentType; + field: string; + value: string; +}) => { + const options: ValidateOptions<{ regex?: string }> = {}; + + const contentType = + body.contentTypeUID in strapi.contentTypes ? strapi.contentTypes[body.contentTypeUID] : null; + + if ( + contentType?.attributes[body.field] && + `regex` in contentType.attributes[body.field] && + (contentType.attributes[body.field] as Schema.Attribute.UID).regex + ) { + options.context = { + regex: (contentType?.attributes[body.field] as Schema.Attribute.UID).regex, + }; + } + + const validator = validateYupSchema(checkUIDAvailabilityInputSchema, options); + + return validator(body); +}; export { createModelConfigurationSchema, diff --git a/packages/core/content-type-builder/admin/src/components/FormModal/attributes/advancedForm.ts b/packages/core/content-type-builder/admin/src/components/FormModal/attributes/advancedForm.ts index 9acdd2b043..9c7ca3c4c2 100644 --- a/packages/core/content-type-builder/admin/src/components/FormModal/attributes/advancedForm.ts +++ b/packages/core/content-type-builder/admin/src/components/FormModal/attributes/advancedForm.ts @@ -403,6 +403,7 @@ export const advancedForm = { attributeOptions.maxLength, attributeOptions.minLength, attributeOptions.private, + attributeOptions.regex, ], }, ], diff --git a/packages/core/content-type-builder/server/src/controllers/validation/__tests__/types.test.ts b/packages/core/content-type-builder/server/src/controllers/validation/__tests__/types.test.ts index a12aa3ac69..239cd78800 100644 --- a/packages/core/content-type-builder/server/src/controllers/validation/__tests__/types.test.ts +++ b/packages/core/content-type-builder/server/src/controllers/validation/__tests__/types.test.ts @@ -227,6 +227,23 @@ describe('Type validators', () => { expect(validator.isValidSync(attributes.slug)).toBe(isValid); }); + test('Default value must match regex if a custom regex is defined', () => { + const attributes = { + slug: { + type: 'uid', + default: 'some/value', + regex: '^[A-Za-z0-9-_.~/]*$', + }, + } satisfies Struct.SchemaAttributes; + + const validator = getTypeValidator(attributes.slug, { + types: ['uid'], + attributes, + }); + + expect(validator.isValidSync(attributes.slug)).toBe(true); + }); + test('Default should not be defined if targetField is defined', () => { const attributes = { title: { diff --git a/packages/core/content-type-builder/server/src/controllers/validation/schema.ts b/packages/core/content-type-builder/server/src/controllers/validation/schema.ts index 369e794214..1be143badd 100644 --- a/packages/core/content-type-builder/server/src/controllers/validation/schema.ts +++ b/packages/core/content-type-builder/server/src/controllers/validation/schema.ts @@ -523,6 +523,12 @@ const uidSchema = basePropertiesSchema.extend({ preserveLeadingUnderscore: z.boolean().optional(), }) .optional(), + regex: z + .string() + .optional() + .refine((value) => { + return value === '' || !!new RegExp(value as string); + }, 'Invalid regular expression pattern'), }); const customFieldSchema = basePropertiesSchema.extend({ diff --git a/packages/core/content-type-builder/server/src/controllers/validation/types.ts b/packages/core/content-type-builder/server/src/controllers/validation/types.ts index 0b03fef29e..732afd3bb9 100644 --- a/packages/core/content-type-builder/server/src/controllers/validation/types.ts +++ b/packages/core/content-type-builder/server/src/controllers/validation/types.ts @@ -11,8 +11,8 @@ import { isValidDefaultJSON, isValidName, isValidEnum, - isValidUID, isValidRegExpPattern, + UID_REGEX, } from './common'; export type GetTypeValidatorOptions = { @@ -84,7 +84,19 @@ const getTypeShape = (attribute: Schema.Attribute.AnyAttribute, { attributes }: return !!(_.isNil(targetField) || _.isNil(value)); } ) - .test(isValidUID), + .test( + 'isValidDefaultRegexUID', + `\${path} must match the custom regex or the default one "${UID_REGEX}"`, + function (value) { + const { regex } = this.parent; + + if (regex) { + return !_.isNil(value) && (value === '' || new RegExp(regex).test(value)); + } + + return value === '' || UID_REGEX.test(value as string); + } + ), minLength: validators.minLength, maxLength: validators.maxLength.max(256).test(maxLengthIsGreaterThanOrEqualToMinLength), options: yup.object().shape({ @@ -94,6 +106,7 @@ const getTypeShape = (attribute: Schema.Attribute.AnyAttribute, { attributes }: customReplacements: yup.array().of(yup.array().of(yup.string()).min(2).max(2)), preserveLeadingUnderscore: yup.boolean(), }), + regex: yup.string().test(isValidRegExpPattern), }; } diff --git a/packages/core/core/src/services/entity-validator/validators.ts b/packages/core/core/src/services/entity-validator/validators.ts index 77dc4d743a..736cf5fecf 100644 --- a/packages/core/core/src/services/entity-validator/validators.ts +++ b/packages/core/core/src/services/entity-validator/validators.ts @@ -443,6 +443,10 @@ export const uidValidator = ( return schema; } + if (metas.attr.regex) { + return schema.matches(new RegExp(metas.attr.regex)); + } + return schema.matches(/^[A-Za-z0-9-_.~]*$/); }; diff --git a/packages/core/types/src/schema/attribute/definitions/uid.ts b/packages/core/types/src/schema/attribute/definitions/uid.ts index bb1101e90a..4b75b4fe1e 100644 --- a/packages/core/types/src/schema/attribute/definitions/uid.ts +++ b/packages/core/types/src/schema/attribute/definitions/uid.ts @@ -15,6 +15,7 @@ export interface UIDProperties< > { targetField?: TTargetAttribute; options?: UIDOptions & TOptions; + regex?: RegExp['source']; } /** diff --git a/tests/api/core/content-manager/content-manager/uid.test.api.js b/tests/api/core/content-manager/content-manager/uid.test.api.js index 4da5be37de..06c1ca71f1 100644 --- a/tests/api/core/content-manager/content-manager/uid.test.api.js +++ b/tests/api/core/content-manager/content-manager/uid.test.api.js @@ -321,11 +321,12 @@ describe('Content Manager single types', () => { error: { status: 400, name: 'ValidationError', - message: 'value must match the following: "/^[A-Za-z0-9-_.~]*$/"', + message: 'value must match the custom regex or the default one "/^[A-Za-z0-9-_.~]*$/"', details: { errors: [ { - message: 'value must match the following: "/^[A-Za-z0-9-_.~]*$/"', + message: + 'value must match the custom regex or the default one "/^[A-Za-z0-9-_.~]*$/"', name: 'ValidationError', path: ['value'], },