UNPKG

@firecms/core

Version:

Awesome Firebase/Firestore-based headless open-source CMS

488 lines (459 loc) 19.5 kB
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; }