@firecms/core
Version:
Awesome Firebase/Firestore-based headless open-source CMS
488 lines (459 loc) • 19.5 kB
text/typescript
import {
CMSType,
EntityReference,
GeoPoint,
ResolvedArrayProperty,
ResolvedMapProperty,
ResolvedProperties,
ResolvedProperty
} from "../types";
import * as yup from "yup";
import { AnySchema, ArraySchema, BooleanSchema, DateSchema, NumberSchema, ObjectSchema, StringSchema } from "yup";
import { enumToObjectEntries, getValueInPath, hydrateRegExp, isPropertyBuilder } from "../util";
// Add custom unique function for array values
declare module "yup" {
// tslint:disable-next-line
interface ArraySchema<TIn extends any[] | null | undefined, TContext, TDefault = undefined, TFlags extends yup.Flags = ""> {
uniqueInArray(mapper: (a: any) => any, message: string): ArraySchema<TIn, TContext, TDefault, TFlags>;
}
}
yup.addMethod(yup.array, "uniqueInArray", function (
mapper = (a: any) => a,
message: string
) {
return this.test("uniqueInArray", message, values => {
return !values || values.length === new Set(values.map(mapper)).size;
});
});
export type CustomFieldValidator = (props: {
name: string,
value: any,
property: ResolvedProperty,
entityId?: string,
parentProperty?: ResolvedMapProperty | ResolvedArrayProperty,
}) => Promise<boolean>;
interface PropertyContext<T extends CMSType> {
property: ResolvedProperty<T>,
parentProperty?: ResolvedMapProperty | ResolvedArrayProperty,
entityId: string,
customFieldValidator?: CustomFieldValidator,
name?: any
}
export function getYupEntitySchema<M extends Record<string, any>>(
entityId: string,
properties: ResolvedProperties<M>,
customFieldValidator?: CustomFieldValidator): ObjectSchema<any> {
const objectSchema: any = {};
Object.entries(properties as Record<string, ResolvedProperty>)
.forEach(([name, property]) => {
try {
objectSchema[name] = mapPropertyToYup({
property: property as ResolvedProperty<any>,
customFieldValidator,
name,
entityId
});
} catch (e: any) {
console.error(`Error creating validation schema for property ${name}:`, e);
objectSchema[name] = yup.mixed().test(
"validation-error",
`Validation error: ${e?.message ?? "Unknown error"}`,
() => false
);
}
});
return yup.object().shape(objectSchema);
}
export function mapPropertyToYup<T extends CMSType>(propertyContext: PropertyContext<T>): AnySchema<unknown> {
const property = propertyContext.property;
if (isPropertyBuilder(property)) {
console.error("Error in property", propertyContext);
// Return a permissive schema with an error message instead of crashing
return yup.mixed().test(
"property-builder-error",
"Invalid property configuration: property builder should be resolved",
() => false
);
}
if (property.dataType === "string") {
return getYupStringSchema(propertyContext as PropertyContext<string>);
} else if (property.dataType === "number") {
return getYupNumberSchema(propertyContext as PropertyContext<number>);
} else if (property.dataType === "boolean") {
return getYupBooleanSchema(propertyContext as PropertyContext<boolean>);
} else if (property.dataType === "map") {
return getYupMapObjectSchema(propertyContext as PropertyContext<object>);
} else if (property.dataType === "array") {
return getYupArraySchema(propertyContext as PropertyContext<any[]>);
} else if (property.dataType === "date") {
return getYupDateSchema(propertyContext as PropertyContext<Date>);
} else if (property.dataType === "geopoint") {
return getYupGeoPointSchema(propertyContext as PropertyContext<GeoPoint>);
} else if (property.dataType === "reference") {
return getYupReferenceSchema(propertyContext as PropertyContext<EntityReference>);
}
// Log the error but don't crash the form - return a permissive schema with an error message
console.error("Unsupported data type in yup mapping", property);
const dataType = (property as any).dataType ?? "unknown";
return yup.mixed().test(
"unsupported-data-type",
`Unsupported data type: ${dataType}`,
() => false
);
}
export function getYupMapObjectSchema({
property,
entityId,
customFieldValidator,
name
}: PropertyContext<Record<string, any>>): ObjectSchema<any> {
const objectSchema: any = {};
const validation = property.validation;
if (property.properties)
Object.entries(property.properties).forEach(([childName, childProperty]: [string, ResolvedProperty]) => {
try {
objectSchema[childName] = mapPropertyToYup<any>({
property: childProperty,
parentProperty: property as ResolvedMapProperty,
customFieldValidator,
name: `${name}[${childName}]`,
entityId
});
} catch (e: any) {
console.error(`Error creating validation schema for property ${childName}:`, e);
objectSchema[childName] = yup.mixed().test(
"validation-error",
`Validation error: ${e?.message ?? "Unknown error"}`,
() => false
);
}
});
const shape = yup.object().shape(objectSchema);
if (validation?.required) {
// In yup v0.x, .required().nullable(true) allowed null values
// To match this behavior: reject undefined but allow null
return shape.nullable().test(
"required",
validation?.requiredMessage ? validation.requiredMessage : "Required",
(value) => value !== undefined
) as any;
}
return yup.object().shape(shape.fields).default(undefined).nullable().optional() as any;
}
function getYupStringSchema({
property,
parentProperty,
customFieldValidator,
name,
entityId
}: PropertyContext<string>): StringSchema {
let schema: StringSchema<any> = yup.string().nullable();
const validation = property.validation;
if (property.enumValues) {
if (validation?.required) {
schema = schema.test(
"required",
validation?.requiredMessage ? validation.requiredMessage : "Required",
(value) => value !== undefined && value !== null && value !== ""
);
}
const entries = enumToObjectEntries(property.enumValues);
schema = schema.oneOf(
(validation?.required ? entries : [...entries, null])
.map((enumValueConfig) => enumValueConfig?.id ?? null)
);
}
if (validation) {
if (validation.required) {
schema = schema.test(
"required",
validation?.requiredMessage ? validation.requiredMessage : "Required",
(value) => value !== undefined && value !== null && value !== ""
);
}
if (validation.unique && customFieldValidator && name)
schema = schema.test("unique", "This value already exists and should be unique",
(value, context) =>
customFieldValidator({
name,
property,
parentProperty,
value,
entityId
}));
if (validation.min || validation.min === 0) schema = schema.min(validation.min, `${property.name} must be min ${validation.min} characters long`);
if (validation.max || validation.max === 0) schema = schema.max(validation.max, `${property.name} must be max ${validation.max} characters long`);
if (validation.matches) {
const regExp = typeof validation.matches === "string" ? hydrateRegExp(validation.matches) : validation.matches;
if (regExp) {
schema = schema.matches(regExp, validation.matchesMessage ? { message: validation.matchesMessage } : undefined)
}
}
if (validation.trim) schema = schema.trim();
if (validation.lowercase) schema = schema.lowercase();
if (validation.uppercase) schema = schema.uppercase();
if (property.email) schema = schema.email(`${property.name} must be an email`);
if (property.url) {
if (!property.storage || property.storage?.storeUrl) {
schema = schema.url(`${property.name} must be a url`);
} else {
console.warn(`Property ${property.name} has a url validation but its storage configuration is not set to store urls`);
}
}
}
return schema;
}
function getYupNumberSchema({
property,
parentProperty,
customFieldValidator,
name,
entityId
}: PropertyContext<number>): NumberSchema {
const validation = property.validation;
let schema: NumberSchema<any> = yup.number().nullable().typeError("Must be a number");
if (validation) {
if (validation.required) {
schema = schema.test(
"required",
validation.requiredMessage ? validation.requiredMessage : "Required",
(value) => value !== undefined && value !== null
);
}
if (validation.unique && customFieldValidator && name)
schema = schema.test("unique",
"This value already exists and should be unique",
(value) => customFieldValidator({
name,
property,
parentProperty,
value,
entityId
}));
if (validation.min || validation.min === 0) schema = schema.min(validation.min, `${property.name} must be higher or equal to ${validation.min}`);
if (validation.max || validation.max === 0) schema = schema.max(validation.max, `${property.name} must be lower or equal to ${validation.max}`);
if (validation.lessThan || validation.lessThan === 0) schema = schema.lessThan(validation.lessThan, `${property.name} must be higher than ${validation.lessThan}`);
if (validation.moreThan || validation.moreThan === 0) schema = schema.moreThan(validation.moreThan, `${property.name} must be lower than ${validation.moreThan}`);
if (validation.positive) schema = schema.positive(`${property.name} must be positive`);
if (validation.negative) schema = schema.negative(`${property.name} must be negative`);
if (validation.integer) schema = schema.integer(`${property.name} must be an integer`);
}
return schema;
}
function getYupGeoPointSchema({
property,
parentProperty,
customFieldValidator,
name,
entityId
}: PropertyContext<GeoPoint>): AnySchema {
let schema: ObjectSchema<any> = yup.object().nullable() as ObjectSchema<any>;
const validation = property.validation;
if (validation?.unique && customFieldValidator && name)
schema = schema.test("unique",
"This value already exists and should be unique",
(value) => customFieldValidator({
name,
property,
parentProperty,
value,
entityId
}));
if (validation?.required) {
schema = schema.test(
"required",
validation.requiredMessage ? validation.requiredMessage : "Required",
(value) => value !== undefined && value !== null
);
}
return schema;
}
function getYupDateSchema({
property,
parentProperty,
customFieldValidator,
name,
entityId
}: PropertyContext<Date>): AnySchema | DateSchema {
if (property.autoValue) {
return yup.date().nullable();
}
let schema: DateSchema<any> = yup.date().nullable();
const validation = property.validation;
if (validation) {
if (validation.required) {
schema = schema.test(
"required",
validation?.requiredMessage ? validation.requiredMessage : "Required",
(value) => value !== undefined && value !== null
);
}
if (validation.unique && customFieldValidator && name)
schema = schema.test("unique",
"This value already exists and should be unique",
(value) => customFieldValidator({
name,
property,
parentProperty,
value,
entityId
}));
if (validation.min) schema = schema.min(validation.min, `${property.name} must be after ${validation.min}`);
if (validation.max) schema = schema.max(validation.max, `${property.name} must be before ${validation.min}`);
}
return schema.transform((v: any) => (v instanceof Date ? v : null));
}
function getYupReferenceSchema({
property,
parentProperty,
customFieldValidator,
name,
entityId
}: PropertyContext<EntityReference>): AnySchema {
let schema: ObjectSchema<any> = yup.object().nullable() as ObjectSchema<any>;
const validation = property.validation;
if (validation) {
if (validation.required) {
schema = schema.test(
"required",
validation?.requiredMessage ? validation.requiredMessage : "Required",
(value) => value !== undefined && value !== null
);
}
if (validation.unique && customFieldValidator && name)
schema = schema.test("unique",
"This value already exists and should be unique",
(value) => customFieldValidator({
name,
property,
parentProperty,
value,
entityId
}));
}
return schema;
}
function getYupBooleanSchema({
property,
parentProperty,
customFieldValidator,
name,
entityId
}: PropertyContext<boolean>): BooleanSchema {
let schema: BooleanSchema<any> = yup.boolean().nullable();
const validation = property.validation;
if (validation) {
if (validation.required) {
schema = schema.test(
"required",
validation?.requiredMessage ? validation.requiredMessage : "Required",
(value) => value !== undefined && value !== null
);
}
if (validation.unique && customFieldValidator && name)
schema = schema.test("unique",
"This value already exists and should be unique",
(value) => customFieldValidator({
name,
property,
parentProperty,
value,
entityId
}));
}
return schema;
}
function hasUniqueInArrayModifier(property: ResolvedProperty): boolean | [string, ResolvedProperty][] {
if (property.validation?.uniqueInArray) {
return true;
} else if (property.dataType === "map" && property.properties) {
return Object.entries(property.properties)
.filter(([key, childProperty]) => childProperty.validation?.uniqueInArray);
}
return false;
}
function getYupArraySchema({
property,
parentProperty,
customFieldValidator,
name,
entityId
}: PropertyContext<any[]>): AnySchema<any> {
let arraySchema: any = yup.array().nullable();
if (property.of) {
if (Array.isArray(property.of)) {
const yupProperties = (property.of as ResolvedProperty[]).map((p, index) => {
try {
return {
[`${name}[${index}]`]: mapPropertyToYup({
property: p as ResolvedProperty<any>,
parentProperty: property,
entityId
})
};
} catch (e: any) {
console.error(`Error creating validation schema for array item ${index}:`, e);
return {
[`${name}[${index}]`]: yup.mixed().test(
"validation-error",
`Validation error: ${e?.message ?? "Unknown error"}`,
() => false
)
};
}
}).reduce((a, b) => ({ ...a, ...b }), {});
return yup.array().nullable().of(
yup.mixed().test(
"Dynamic object validation",
"Dynamic object validation error",
(object, context) => {
const yupProperty = getValueInPath(yupProperties, context.path);
return yupProperty.validate(object);
}
)
);
} else {
try {
arraySchema = arraySchema.of(mapPropertyToYup({
property: property.of,
parentProperty: property,
entityId
}));
} catch (e: any) {
console.error(`Error creating validation schema for array of property:`, e);
arraySchema = arraySchema.of(yup.mixed().test(
"validation-error",
`Validation error: ${e?.message ?? "Unknown error"}`,
() => false
));
}
const arrayUniqueFields = hasUniqueInArrayModifier(property.of);
if (arrayUniqueFields) {
if (typeof arrayUniqueFields === "boolean") {
arraySchema = arraySchema.uniqueInArray((v: any) => v, `${property.name} should have unique values within the array`);
} else if (Array.isArray(arrayUniqueFields)) {
arrayUniqueFields.forEach(([name, childProperty]) => {
arraySchema = arraySchema.uniqueInArray((v: any) => v && v[name], `${property.name} → ${childProperty.name ?? name}: should have unique values within the array`);
});
}
}
}
}
const validation = property.validation;
if (validation) {
if (validation.required) {
arraySchema = arraySchema.test(
"required",
validation?.requiredMessage ? validation.requiredMessage : "Required",
(value: any) => value !== undefined && value !== null && value.length > 0
);
}
if (validation.min || validation.min === 0) arraySchema = arraySchema.min(validation.min, `${property.name} should be min ${validation.min} entries long`);
if (validation.max) arraySchema = arraySchema.max(validation.max, `${property.name} should be max ${validation.max} entries long`);
// Handle uniqueInArray at the array level (in addition to the of.validation check above)
if (validation.uniqueInArray) {
arraySchema = arraySchema.uniqueInArray((v: any) => v, `${property.name} should have unique values within the array`);
}
}
return arraySchema;
}