at path:
ROOT
/
wp-content
/
plugins
/
give
/
src
/
Admin
/
ajv.ts
run:
R
W
Run
common
DIR
2026-04-08 21:07:16
R
W
Run
components
DIR
2026-04-08 21:07:16
R
W
Run
fields
DIR
2026-04-08 21:07:16
R
W
Run
hooks
DIR
2026-04-08 21:07:16
R
W
Run
providers
DIR
2026-04-08 21:07:16
R
W
Run
ajv.ts
25.27 KB
2025-11-05 19:00:32
R
W
Run
Delete
Rename
types.ts
664 By
2025-10-16 18:10:48
R
W
Run
Delete
Rename
utils.ts
26 By
2025-11-05 19:00:32
R
W
Run
Delete
Rename
error_log
up
📄
ajv.ts
Save
import {__, sprintf} from '@wordpress/i18n'; import {JSONSchemaType} from 'ajv'; import addErrors from 'ajv-errors'; import addFormats from 'ajv-formats'; /** * Create an AJV resolver for react-hook-form with WordPress REST API schema * * This function creates a custom resolver that works with WordPress REST API schemas (Draft 03/04) * and provides enhanced frontend validation using AJV (Draft 7/2019-09). It handles: * - Transforming WordPress Draft 03/04 schema to Draft 7/2019-09 for AJV compatibility * - Data transformation before validation (string numbers, enum handling, etc.) * - Error handling and fallback behavior * * Key advantage: WordPress REST API supports most JSON Schema Draft 4 features but lacks * some advanced features (if/then/else, allOf, not) that AJV can provide for enhanced frontend validation. * * @since 4.10.0 Refactor transformWordPressSchemaToDraft7 to handle readonly/readOnly fields and conditionally remove enum from nullable fields when value is null to prevent AJV conflicts * @since 4.9.0 * * @param schema - The JSON Schema from WordPress REST API * @returns A resolver function compatible with react-hook-form */ export function ajvResolver(schema: JSONSchemaType<any>) { return (data: any) => { try { // Ensure we have valid data to validate if (!data || typeof data !== 'object') { return {values: data || {}, errors: {}}; } const transformedData = transformFormDataForValidation(data, schema); const ajv = configureAjvForWordPress(); const transformedSchema = transformWordPressSchemaToDraft7(schema, data); const validate = ajv.compile(transformedSchema); const valid = validate(transformedData); if (valid) { // Use original form data to avoid mutating field values on each validation cycle // (e.g., prevent repeated timezone normalization of date-time fields) return {values: data, errors: {}}; } else { console.error('🔴 Validation failed, errors:', validate.errors); const errors: any = {}; if (validate.errors) { validate.errors.forEach((error) => { const path = error.instancePath || error.schemaPath; if (path) { const fieldName = path.replace('/', ''); // Use the error message from ajv-errors // ajv-errors should provide the custom message in error.message const errorMessage = error.message || sprintf(__('%s is invalid.', 'give'), fieldName); errors[fieldName] = { type: 'validation', message: errorMessage, }; } }); } return {values: {}, errors}; } } catch (error) { console.error('AJV validation error:', error); return {values: data, errors: {}}; } }; } /** * Configure standard AJV (Draft 7/2019-09) for WordPress REST API compatibility * * WordPress REST API Schema Characteristics (Draft 03/04): * - Uses 'required: true' on individual properties (Draft 03 syntax) * - Uses 'readonly' property (lowercase, WordPress REST API standard) * - Supports most JSON Schema Draft 4 features including: * * anyOf, oneOf (since WordPress 5.6.0) * * Basic validation: type, format, enum, pattern, constraints * * Object/array validation: properties, items, additionalProperties * - Does NOT support: allOf, not, if/then/else (conditional validation) * * This function configures AJV (Draft 7/2019-09) to work with WordPress schemas: * - Transforms WordPress Draft 03/04 schemas to Draft 7/2019-09 syntax * - Converts 'required: true' on individual properties to 'required' array at object level * - Adds all standard JSON Schema formats using ajv-formats package * - Adds custom error messages using ajv-errors package * - Adds WordPress-specific custom formats (text-field, textarea-field) * - Disables schema validation to avoid conflicts with WordPress schema extensions * - Enables advanced validation features that WordPress ignores * * Validation Features Available: * * Backend (WordPress REST API) + Frontend (AJV): * - Type validation (string, integer, boolean, number, array, object, null) * - Required field validation * - Format validation (email, date-time, uri, ip, hex-color, uuid, text-field, textarea-field, and all standard formats via ajv-formats) * - Enum validation * - Constraint validation (minLength, maxLength, minimum, maximum, etc.) * - Pattern validation (regex) * - Array validation (items, minItems, maxItems, uniqueItems) * - Object validation (properties, additionalProperties, patternProperties) * - Schema composition: anyOf, oneOf (WordPress 5.6.0+) * * Frontend Only (AJV Draft 7/2019-09): * - Conditional validation (if/then/else) * - Advanced schema composition (allOf, not) * - Advanced references ($ref with complex paths) * - Dependent required fields * - Dynamic validation based on other field values * - More comprehensive format validation * * References: * - WordPress REST API Schema Documentation: https://developer.wordpress.org/rest-api/extending-the-rest-api/schema/ * - It only supports a subset of the draft-04 and draft-03 meta-schemas: https://developer.wordpress.org/news/2024/07/json-schema-in-wordpress/#wordpress-rest-api * - WordPress 5.6.0 anyOf/oneOf Support: https://core.trac.wordpress.org/ticket/51025 * - Implementation Changeset: https://core.trac.wordpress.org/changeset/49246 * - WordPress uses 'readonly' (lowercase) vs JSON Schema 'readOnly': https://core.trac.wordpress.org/ticket/56152 * - WordPress 5.5.0 UUID format support: https://core.trac.wordpress.org/ticket/50053 * - WordPress 5.9.0 text-field/textarea-field formats: https://core.trac.wordpress.org/changeset/49246 * - rest_validate_value_from_schema(): https://developer.wordpress.org/reference/functions/rest_validate_value_from_schema/ * - rest_get_allowed_schema_keywords(): https://developer.wordpress.org/reference/functions/rest_get_allowed_schema_keywords/ * * @returns Configured AJV instance (Draft 7/2019-09) for WordPress compatibility */ function configureAjvForWordPress() { // Use standard AJV (Draft 7/2019-09) and transform WordPress schemas to be compatible const AjvClass = require('ajv').default || require('ajv'); const ajv = new AjvClass({ // Disable schema validation to avoid conflicts with WordPress schemas validateSchema: false, // Disable strict mode to allow WordPress extensions like 'readonly' strict: false, // Enable all errors for better debugging allErrors: true, verbose: true, }); // Add all standard JSON Schema formats using ajv-formats addFormats(ajv); // Add custom error messages support using ajv-errors addErrors(ajv); // Add WordPress-specific custom formats that are not in the standard ajv.addFormat('text-field', true); // WordPress custom format - no validation, only sanitization ajv.addFormat('textarea-field', true); // WordPress custom format - no validation, only sanitization ajv.addFormat('integer', true); // WordPress custom format - no validation, only sanitization ajv.addFormat('boolean', true); // WordPress custom format - no validation, only sanitization // Transform WordPress schemas to be compatible with Draft 7/2019-09 // This converts Draft 03/04 syntax (required: true on properties) to Draft 7 syntax const originalCompile = ajv.compile.bind(ajv); ajv.compile = function (schema: JSONSchemaType<any>) { try { const transformedSchema = transformWordPressSchemaToDraft7(schema); return originalCompile(transformedSchema); } catch (error) { console.error('Schema transformation error:', error); return originalCompile(schema); } }; return ajv; } /** * Transform WordPress schema from Draft 03/04 syntax to Draft 7/2019-09 syntax * * This function converts WordPress REST API schemas (Draft 03/04) to be compatible * with AJV (Draft 7/2019-09). The transformation includes: * - Converts 'required: true' on individual properties to 'required' array at object level * - Updates $schema reference from Draft 04 to Draft 7/2019-09 * - Removes readonly/readOnly fields from validation (they shouldn't be validated by frontend) * - Conditionally removes enum from nullable fields when value is null to prevent AJV conflicts * - Preserves all advanced features (if/then/else, allOf, etc.) that WordPress ignores * but AJV can use for enhanced frontend validation * * Key benefit: WordPress schemas can include advanced validation rules (if/then/else, allOf, not) * that are ignored by the backend but utilized by the frontend for better UX. */ function transformWordPressSchemaToDraft7(schema: JSONSchemaType<any>, data?: any): JSONSchemaType<any> { if (!schema || typeof schema !== 'object') { return schema; } const transformed = JSON.parse(JSON.stringify(schema)); // Update $schema reference to Draft 7/2019-09 if (transformed.$schema) { transformed.$schema = 'https://json-schema.org/draft/2019-09/schema'; } if (transformed.properties && typeof transformed.properties === 'object') { const requiredFields: string[] = []; const errorMessages: any = {}; Object.keys(transformed.properties).forEach((key) => { const prop = transformed.properties[key]; // Early return if prop is not a valid object if (!prop || typeof prop !== 'object') { return; } // Converts 'required: true' on individual properties to 'required' array at object level if (prop.required === true) { requiredFields.push(key); delete prop.required; } // Remove readonly/readOnly fields from validation (they shouldn't be validated by frontend) if (prop.readonly === true || prop.readOnly === true) { delete transformed.properties[key]; return; } // remove readonly/readOnly fields from nested properties if (prop.properties) { Object.keys(prop.properties).forEach((subKey) => { const subProp = prop.properties[subKey]; if (subProp.readonly === true || subProp.readOnly === true) { delete prop.properties[subKey]; if (Array.isArray(prop.required) && prop.required.includes(subKey)) { prop.required.splice(prop.required.indexOf(subKey), 1); } return; } }); } // For WordPress Array type + enum (like honorific), conditionally remove enum based on current value // This prevents AJV conflicts when nullable fields have null values if (Array.isArray(prop.type) && prop.enum) { const currentValue = data && data[key]; const allowsNull = prop.type.includes('null'); if (currentValue === null && allowsNull) { delete prop.enum; } } // Add custom error messages for each property errorMessages[key] = getCustomErrorMessage(prop, key); }); if (requiredFields.length > 0) { transformed.required = requiredFields; } // Add error messages to the schema if (Object.keys(errorMessages).length > 0) { transformed.errorMessage = { properties: errorMessages, required: __('Required fields are missing.', 'give'), _: __('Please check the form for errors.', 'give'), }; } } return transformed; } /** * Generate custom error messages for schema properties using ajv-errors * * This function creates specific error messages for different validation types * based on the property schema, providing better user experience. * * @param prop - The property schema object * @param fieldName - The name of the field * @returns Custom error message string for the field */ function getCustomErrorMessage(prop: any, fieldName: string): string { // Priority order: format > type > constraints > generic // Format validation messages (highest priority) if (prop.format) { switch (prop.format) { case 'email': return sprintf(__('%s must be a valid email address.', 'give'), fieldName); case 'uri': return sprintf(__('%s must be a valid URL.', 'give'), fieldName); case 'date-time': return sprintf(__('%s must be a valid date and time.', 'give'), fieldName); case 'uuid': return sprintf(__('%s must be a valid UUID.', 'give'), fieldName); case 'hex-color': return sprintf(__('%s must be a valid color code.', 'give'), fieldName); default: return sprintf(__('%s format is invalid.', 'give'), fieldName); } } // Type validation messages if (prop.type) { if (prop.type === 'string') { return sprintf(__('%s must be text.', 'give'), fieldName); } else if (prop.type === 'number') { return sprintf(__('%s must be a number.', 'give'), fieldName); } else if (prop.type === 'integer') { return sprintf(__('%s must be a whole number.', 'give'), fieldName); } else if (prop.type === 'boolean') { return sprintf(__('%s must be true or false.', 'give'), fieldName); } else if (prop.type === 'array') { return sprintf(__('%s must be a list.', 'give'), fieldName); } else if (prop.type === 'object') { return sprintf(__('%s must be an object.', 'give'), fieldName); } } // Enum validation messages if (prop.enum && Array.isArray(prop.enum)) { return sprintf(__('%s must be one of: %s', 'give'), fieldName, prop.enum.join(', ')); } // Constraint validation messages if (prop.minLength !== undefined) { return sprintf(__('%s must be at least %d characters long.', 'give'), fieldName, prop.minLength); } if (prop.maxLength !== undefined) { return sprintf(__('%s must be no more than %d characters long.', 'give'), fieldName, prop.maxLength); } if (prop.minimum !== undefined) { return sprintf(__('%s must be at least %s.', 'give'), fieldName, prop.minimum); } if (prop.maximum !== undefined) { return sprintf(__('%s must be no more than %s.', 'give'), fieldName, prop.maximum); } if (prop.pattern) { return sprintf(__('%s format is invalid.', 'give'), fieldName); } // Generic fallback return sprintf(__('%s is invalid.', 'give'), fieldName); } /** * Transform form data to be compatible with JSON Schema validation * * This function handles common form data issues that occur when HTML forms send * data that doesn't match the expected schema types: * - Converts string numbers to actual numbers based on schema type definitions * - Handles enum fields with null/empty string values (converts to null if schema allows) * - Removes non-required fields that are not present in form data * - Recursively processes nested objects and arrays * * This ensures that form data matches the schema expectations for validation, * working with both WordPress backend validation and AJV frontend validation. */ function transformFormDataForValidation(data: any, schema: JSONSchemaType<any>): any { if (!data || !schema || typeof data !== 'object') { return data || {}; } const transformed = JSON.parse(JSON.stringify(data)); // Deep clone // Recursively transform nested objects function transformObject(obj: any, schemaObj: any): any { if (!obj || !schemaObj || typeof obj !== 'object') { return obj; } const result = {...obj}; if (schemaObj.properties) { Object.keys(schemaObj.properties).forEach((key) => { const propSchema = schemaObj.properties[key]; const value = result[key]; // Skip validation for fields that are not present in form data and not required const isRequired = Array.isArray(schemaObj.required) && schemaObj.required.includes(key); const isFieldPresent = value !== undefined && value !== null; // Remove fields that are not present and not required from the result // Note: We allow empty strings ('') to be present so they can be saved to clear fields if (!isFieldPresent && !isRequired) { delete result[key]; return; // Skip processing this field } // Only process fields that are present in the form OR are required if (propSchema && (isFieldPresent || isRequired)) { // Coerce empty string to null for nullable fields to avoid format/type conflicts (e.g., uri, email) if (Array.isArray(propSchema.type) && propSchema.type.includes('null')) { if (['', null, undefined].includes(result[key])) { result[key] = null; return; // Skip further processing for this field } } // Handle number types if (propSchema.type === 'number' && typeof value === 'string') { // Convert string to number const numValue = parseFloat(value); if (!isNaN(numValue)) { result[key] = numValue; } } else if (propSchema.type === 'integer' && typeof value === 'string') { // Convert string to integer const intValue = parseInt(value, 10); if (!isNaN(intValue)) { result[key] = intValue; } } // Handle boolean types else if (propSchema.type === 'boolean') { if (typeof value === 'string') { result[key] = value === 'true' || value === '1' || value === 'yes'; } else if (typeof value === 'number') { result[key] = value === 1; } } // Handle enum types - ensure value is valid else if (propSchema.enum && Array.isArray(propSchema.enum)) { // Check if null is allowed (when type includes 'null') const allowsNull = Array.isArray(propSchema.type) && propSchema.type.includes('null'); // If value is null, undefined, or empty string and null is allowed, set to null if ((value === null || value === undefined || value === '') && allowsNull) { result[key] = null; } // If value is not in enum and not null/empty, try to find a close match else if (!propSchema.enum.includes(value)) { // If value is not in enum, try to find a close match or use first valid value const stringValue = String(value).toLowerCase(); const validValue = propSchema.enum.find( (enumValue) => String(enumValue).toLowerCase() === stringValue ); if (validValue !== undefined) { result[key] = validValue; } else if (propSchema.enum.length > 0) { // Use first enum value as fallback result[key] = propSchema.enum[0]; } } } // Handle oneOf schemas (like createdAt/updatedAt for donations) else if (propSchema.oneOf && Array.isArray(propSchema.oneOf)) { result[key] = transformOneOfValue(value, propSchema.oneOf); } // Handle array types with string/null (like createdAt/renewsAt for subscriptions) else if ( Array.isArray(propSchema.type) && propSchema.type.includes('string') && propSchema.format === 'date-time' ) { // For subscription date fields that expect string or null if (value === null || value === undefined) { result[key] = null; } else if (typeof value === 'string') { // Handle ISO 8601 date strings (with or without timezone) // Examples: '2025-07-15T16:34:57', '2025-07-15T16:34:57Z', '2025-07-15T16:34:57.000Z' const date = new Date(value); if (!isNaN(date.getTime())) { // Ensure the string is in proper ISO format const isoString = date.toISOString(); result[key] = isoString; } else { result[key] = null; } } else if (value instanceof Date) { result[key] = value.toISOString(); } else { result[key] = null; } } // Handle nested objects else if ( (propSchema.type === 'object' || (Array.isArray(propSchema.type) && propSchema.type.includes('object'))) && propSchema.properties ) { // Recursively transform nested objects result[key] = transformObject(value, propSchema); } } }); } return result; } // Call transformObject to process the data return transformObject(transformed, schema); } /** * Transform values for oneOf schemas (like createdAt/updatedAt) */ function transformOneOfValue(value: any, oneOfSchemas: any[]): any { // If value is already null, return null if (value === null || value === undefined) { return null; } // Check each oneOf schema to see which one matches for (const schema of oneOfSchemas) { // If schema expects a string if (schema.type === 'string') { if (typeof value === 'string') { // Check if it's a valid ISO date-time string const date = new Date(value); if (!isNaN(date.getTime())) { return value; // Return as string if valid } } } // If schema expects an object with date property (like createdAt/updatedAt) else if (schema.type === 'object' && schema.properties && schema.properties.date) { // If value is already an object with date property, validate and return if (typeof value === 'object' && value.date) { const date = new Date(value.date); if (!isNaN(date.getTime())) { // Convert to ISO 8601 format if needed const isoDate = date.toISOString(); return { date: isoDate, timezone: value.timezone || 'UTC', timezone_type: value.timezone_type || 3, }; } } // If value is a string, convert to object format else if (typeof value === 'string') { const date = new Date(value); if (!isNaN(date.getTime())) { return { date: date.toISOString(), timezone: 'UTC', timezone_type: 3, }; } } } // If schema expects null else if (schema.type === 'null') { if (value === null) { return null; } } } // If value is a Date object, convert to object format if (value instanceof Date) { return { date: value.toISOString(), timezone: 'UTC', timezone_type: 3, }; } // If value is a string that looks like a date, convert to object format if (typeof value === 'string') { const date = new Date(value); if (!isNaN(date.getTime())) { return { date: value, timezone: 'UTC', timezone_type: 3, }; } } // Fallback: return null return null; }