Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(next): DEVXP-2682: centralize error messages #148

Merged
merged 7 commits into from
Mar 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions next/src/errors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* The type of validation error
* @description
* This type is used to determine the type of validation error that occurred.
*/
export type SchemaValidationErrorType =
/**
* Core validation keywords
*/
| 'type' | 'required' | 'valid' | 'const' | 'enum'
/**
* Schema composition keywords (allOf, anyOf, oneOf, not)
* These keywords apply subschemas in a logical manner according to JSON Schema spec
*/
| 'anyOf' | 'oneOf' | 'not'
/**
* String validation keywords
*/
| 'format' | 'minLength' | 'maxLength' | 'pattern'
/**
* Number validation keywords
*/
| 'multipleOf' | 'maximum' | 'exclusiveMaximum' | 'minimum' | 'exclusiveMinimum'

export type ValidationErrorPath = Array<string | number>

/**
* Validation error for schema
*/
export interface ValidationError {
/**
* The path to the field that has the error
* - For field-level errors: array of field names (e.g., ['address', 'street'])
* - For schema-level errors: empty array []
* - For nested validations: full path to the field (e.g., ['address', 'street', 'number'])
* - For schema composition: includes array indices (e.g., ['value', 'allOf', 0])
* @example
* [] // schema-level error
* ['username'] // field-level error
* ['address', 'street'] // nested field error
* ['value', 'allOf', 0] // schema composition error
*/
path: ValidationErrorPath
/**
* The type of validation error
* @example
* 'required'
*/
validation: SchemaValidationErrorType
}
94 changes: 94 additions & 0 deletions next/src/errors/messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { SchemaValidationErrorType } from '.'
import type { JsfSchemaType, NonBooleanJsfSchema, SchemaValue } from '../types'
import { randexp } from 'randexp'

export function getErrorMessage(
schema: NonBooleanJsfSchema,
value: SchemaValue,
validation: SchemaValidationErrorType,
): string {
switch (validation) {
// Core validation
case 'type':
return getTypeErrorMessage(schema.type)
case 'required':
return 'Required field'
case 'valid':
return 'Always fails'
case 'const':
return `The only accepted value is ${JSON.stringify(schema.const)}`
case 'enum':
return `The option "${valueToString(value)}" is not valid.`
// Schema composition
case 'anyOf':
return `The option "${valueToString(value)}" is not valid.`
case 'oneOf':
return `The option "${valueToString(value)}" is not valid.`
case 'not':
return 'The value must not satisfy the provided schema'
// String validation
case 'minLength':
return `Please insert at least ${schema.minLength} characters`
case 'maxLength':
return `Please insert up to ${schema.maxLength} characters`
case 'pattern':
return `Must have a valid format. E.g. ${randexp(schema.pattern || '')}`
case 'format':
if (schema.format === 'email') {
return 'Please enter a valid email address'
}
return `Must be a valid ${schema.format} format`
// Number validation
case 'multipleOf':
return `Must be a multiple of ${schema.multipleOf}`
case 'maximum':
return `Must be smaller or equal to ${schema.maximum}`
case 'exclusiveMaximum':
return `Must be smaller than ${schema.exclusiveMaximum}`
case 'minimum':
return `Must be greater or equal to ${schema.minimum}`
case 'exclusiveMinimum':
return `Must be greater than ${schema.exclusiveMinimum}`
}
}

/**
* Get the appropriate type error message based on the schema type
*/
function getTypeErrorMessage(schemaType: JsfSchemaType | JsfSchemaType[] | undefined): string {
if (Array.isArray(schemaType)) {
// Map 'integer' to 'number' in error messages
const formattedTypes = schemaType.map((type) => {
if (type === 'integer')
return 'number'
return type
})

return `The value must be a ${formattedTypes.join(' or ')}`
}

switch (schemaType) {
case 'number':
case 'integer':
return 'The value must be a number'
case 'boolean':
return 'The value must be a boolean'
case 'null':
return 'The value must be null'
case 'string':
return 'The value must be a string'
case 'object':
return 'The value must be an object'
case 'array':
return 'The value must be an array'
default:
return schemaType ? `The value must be ${schemaType}` : 'Invalid value'
}
}

function valueToString(value: SchemaValue): string {
if (typeof value === 'string') {
return value
}
return JSON.stringify(value)
}
144 changes: 107 additions & 37 deletions next/src/form.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { ValidationError } from './errors'
import type { Field } from './field/type'
import type { JsfObjectSchema, JsfSchema, NonBooleanJsfSchema, SchemaValue } from './types'
import type { SchemaValidationErrorType } from './validation/schema'
import { getErrorMessage } from './errors/messages'
import { buildFieldObject } from './field/object'
import { validateSchema } from './validation/schema'
import { isObjectValue } from './validation/util'

interface FormResult {
fields: Field[]
Expand All @@ -11,35 +13,6 @@ interface FormResult {
handleValidation: (value: SchemaValue) => ValidationResult
}

/**
* Validation error for schema
*/
export interface ValidationError {
/**
* The path to the field that has the error
* - For field-level errors: array of field names (e.g., ['address', 'street'])
* - For schema-level errors: empty array []
* - For nested validations: full path to the field (e.g., ['address', 'street', 'number'])
* @example
* [] // schema-level error
* ['username'] // field-level error
* ['address', 'street'] // nested field error
*/
path: string[]
/**
* The type of validation error
* @example
* 'required'
*/
validation: SchemaValidationErrorType
/**
* The message of the validation error
* @example
* 'is required'
*/
message: string
}

/**
* Recursive type for form error messages
* - String for leaf error messages
Expand Down Expand Up @@ -68,18 +41,51 @@ export interface ValidationResult {
* Schema-level error
* { '': 'The value must match at least one schema' }
*/
function validationErrorsToFormErrors(errors: ValidationError[]): FormErrors | null {
function validationErrorsToFormErrors(errors: ValidationErrorWithMessage[]): FormErrors | null {
if (errors.length === 0) {
return null
}

// Use a more functional approach with reduce
return errors.reduce<FormErrors>((result, error) => {
const { path, message } = error
const { path } = error

// Handle schema-level errors (empty path)
if (path.length === 0) {
result[''] = message
result[''] = error.message
return result
}

// For allOf/anyOf/oneOf validation errors, show the error at the field level
const compositionKeywords = ['allOf', 'anyOf', 'oneOf']
const compositionIndex = compositionKeywords.reduce((index, keyword) => {
const keywordIndex = path.indexOf(keyword)
return keywordIndex !== -1 ? keywordIndex : index
}, -1)

if (compositionIndex !== -1) {
// Get the field path (everything before the composition keyword)
const fieldPath = path.slice(0, compositionIndex)
let current = result

// Process all segments except the last one (which will hold the message)
fieldPath.slice(0, -1).forEach((segment) => {
// If this segment doesn't exist yet or is currently a string (from a previous error),
// initialize it as an object
if (!(segment in current) || typeof current[segment] === 'string') {
current[segment] = {}
}

// Cast is safe because we just ensured it's an object
current = current[segment] as FormErrors
})

// Set the message at the field level
if (fieldPath.length > 0) {
const lastSegment = fieldPath[fieldPath.length - 1]
current[lastSegment] = error.message
}

return result
}

Expand All @@ -100,19 +106,81 @@ function validationErrorsToFormErrors(errors: ValidationError[]): FormErrors | n

// Set the message at the final level
const lastSegment = path[path.length - 1]
current[lastSegment] = message
current[lastSegment] = error.message

return result
}, {})
}

interface ValidationErrorWithMessage extends ValidationError {
message: string
}

function getSchemaAndValueAtPath(rootSchema: JsfSchema, rootValue: SchemaValue, path: (string | number)[]): { schema: JsfSchema, value: SchemaValue } {
let currentSchema = rootSchema
let currentValue = rootValue

for (const segment of path) {
if (typeof currentSchema === 'object' && currentSchema !== null) {
if (currentSchema.properties && currentSchema.properties[segment]) {
currentSchema = currentSchema.properties[segment]
if (isObjectValue(currentValue)) {
currentValue = currentValue[segment]
}
}
else if (currentSchema.items && typeof currentSchema.items !== 'boolean') {
currentSchema = currentSchema.items
if (Array.isArray(currentValue)) {
currentValue = currentValue[Number(segment)]
}
}
// Skip the 'allOf', 'anyOf', and 'oneOf' segments, the next segment will be the index
else if (segment === 'allOf' && currentSchema.allOf) {
continue
}
else if (segment === 'anyOf' && currentSchema.anyOf) {
continue
}
else if (segment === 'oneOf' && currentSchema.oneOf) {
continue
}
// If we have we are in a composition context, get the subschema
else if (currentSchema.allOf || currentSchema.anyOf || currentSchema.oneOf) {
const index = Number(segment)
if (currentSchema.allOf && index >= 0 && index < currentSchema.allOf.length) {
currentSchema = currentSchema.allOf[index]
}
else if (currentSchema.anyOf && index >= 0 && index < currentSchema.anyOf.length) {
currentSchema = currentSchema.anyOf[index]
}
else if (currentSchema.oneOf && index >= 0 && index < currentSchema.oneOf.length) {
currentSchema = currentSchema.oneOf[index]
}
}
}
}

return { schema: currentSchema, value: currentValue }
}

function addErrorMessages(rootValue: SchemaValue, rootSchema: JsfSchema, errors: ValidationError[]): ValidationErrorWithMessage[] {
return errors.map((error) => {
const { schema: errorSchema, value: errorValue } = getSchemaAndValueAtPath(rootSchema, rootValue, error.path)

return {
...error,
message: getErrorMessage(errorSchema, errorValue, error.validation),
}
})
}

/**
* Apply custom error messages from the schema to validation errors
* @param errors - The validation errors
* @param schema - The schema that contains custom error messages
* @returns The validation errors with custom error messages applied
*/
function applyCustomErrorMessages(errors: ValidationError[], schema: JsfSchema): ValidationError[] {
function applyCustomErrorMessages(errors: ValidationErrorWithMessage[], schema: JsfSchema): ValidationErrorWithMessage[] {
if (typeof schema !== 'object' || !schema || !errors.length) {
return errors
}
Expand Down Expand Up @@ -181,7 +249,8 @@ function validate(value: SchemaValue, schema: JsfSchema, options: ValidationOpti
const errors = validateSchema(value, schema, options)

// Apply custom error messages before converting to form errors
const processedErrors = applyCustomErrorMessages(errors, schema)
const errorsWithMessages = addErrorMessages(value, schema, errors)
const processedErrors = applyCustomErrorMessages(errorsWithMessages, schema)

const formErrors = validationErrorsToFormErrors(processedErrors)

Expand Down Expand Up @@ -217,7 +286,8 @@ export function createHeadlessForm(
options: CreateHeadlessFormOptions = {},
): FormResult {
const errors = validateSchema(options.initialValues, schema, options.validationOptions)
const validationResult = validationErrorsToFormErrors(errors)
const errorsWithMessages = addErrorMessages(options.initialValues, schema, errors)
const validationResult = validationErrorsToFormErrors(errorsWithMessages)
const isError = validationResult !== null

const handleValidation = (value: SchemaValue) => {
Expand Down
Loading