feat(uid): add regex attribute (#23141)

This commit is contained in:
Aurélien MANCA 2025-06-10 16:34:28 +02:00 committed by GitHub
parent ebcac74d51
commit 3561ab6db9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 98 additions and 10 deletions

View File

@ -42,11 +42,12 @@ import type { Schema } from '@strapi/types';
const UID_REGEX = /^[A-Za-z0-9-_.~]*$/;
interface UIDInputProps extends Omit<InputProps, 'type'> {
attribute?: Pick<Schema.Attribute.UIDProperties, 'regex'>;
type: Schema.Attribute.TypeOf<Schema.Attribute.UID>;
}
const UIDInput = React.forwardRef<any, UIDInputProps>(
({ 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<CheckUIDAvailability.Response>();
@ -61,6 +62,9 @@ const UIDInput = React.forwardRef<any, UIDInputProps>(
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<any, UIDInputProps>(
{
// 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())
),
}
);

View File

@ -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.

View File

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

View File

@ -403,6 +403,7 @@ export const advancedForm = {
attributeOptions.maxLength,
attributeOptions.minLength,
attributeOptions.private,
attributeOptions.regex,
],
},
],

View File

@ -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: {

View File

@ -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({

View File

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

View File

@ -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-_.~]*$/);
};

View File

@ -15,6 +15,7 @@ export interface UIDProperties<
> {
targetField?: TTargetAttribute;
options?: UIDOptions & TOptions;
regex?: RegExp['source'];
}
/**

View File

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