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

DEVXP-XXX: handle paths #129

Merged
merged 7 commits into from
Feb 13, 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
1 change: 1 addition & 0 deletions next/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
/node_modules
/dist
/json-schema-test-suite
13 changes: 1 addition & 12 deletions next/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,3 @@
import antfu from '@antfu/eslint-config'

export default antfu({
ignores: [
// Build output
'dist',

// Dependencies
'node_modules',

// External test suite
'json-schema-test-suite',
],
})
export default antfu({})
1 change: 1 addition & 0 deletions next/jest.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const config = {
moduleNameMapper,
testPathIgnorePatterns,
reporters: ['default', '<rootDir>/test/json-schema-test-suite/json-schema-test-suite-tracker.js'],
transformIgnorePatterns: ['<rootDir>/node_modules/json-schema-typed/'],
}

export default config
1 change: 1 addition & 0 deletions next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"@babel/preset-typescript": "^7.26.0",
"@jest/globals": "^29.7.0",
"@jest/reporters": "^29.7.0",
"@types/validator": "^13.12.2",
"babel-jest": "^29.7.0",
"eslint": "^9.18.0",
"generate-changelog": "^1.8.0",
Expand Down
8 changes: 8 additions & 0 deletions next/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

101 changes: 76 additions & 25 deletions next/src/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,13 @@ interface FormResult {
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
* ['address', 'street']
* [] // schema-level error
* ['username'] // field-level error
* ['address', 'street'] // nested field error
*/
path: string[]
/**
Expand All @@ -40,47 +45,93 @@ export interface ValidationResult {
}

/**
* Validate a value against a schema
* @param value - The value to validate
* @param schema - The schema to validate against
* @returns The validation result
* JSON Schema keywords that require special path handling.
* These keywords always use dot notation for their error paths.
* For example: { ".fieldName": "should match at least one schema" }
*/
function validate(value: SchemaValue, schema: JsfSchema): ValidationResult {
const result: ValidationResult = {}
const errors = validateSchema(value, schema)
const formErrors = validationErrorsToFormErrors(errors)
const SCHEMA_KEYWORDS = ['anyOf', 'oneOf', 'allOf', 'not'] as const

if (formErrors) {
result.formErrors = formErrors
/**
* Transform a validation error path array into a form error path string.
* Follows these rules:
* 1. Schema-level errors (empty path) -> empty string ('')
* 2. Keyword validations (anyOf, etc.) -> always use dot notation ('.fieldName')
* 3. Single field errors -> field name only ('fieldName')
* 4. Nested field errors -> dot notation ('.parent.field')
*
* @example
* Schema-level error
* pathToFormErrorPath([], 'required') // ''
*
* Keyword validation - always dot notation
* pathToFormErrorPath(['value'], 'anyOf') // '.value'
*
* Single field error - no dot
* pathToFormErrorPath(['username'], 'type') // 'username'
*
* Nested field error - dot notation
* pathToFormErrorPath(['address', 'street'], 'type') // '.address.street'
*/
function pathToFormErrorPath(path: string[], validation: SchemaValidationErrorType): string {
// Schema-level errors have no path
if (path.length === 0)
return ''

// Special handling for JSON Schema keywords
if (SCHEMA_KEYWORDS.includes(validation as any)) {
return `.${path.join('.')}`
}

return result
// Regular fields: dot notation only for nested paths
return path.length === 1 ? path[0] : `.${path.join('.')}`
}

/**
* Transform validation errors into an object with the field names as keys and the error messages as values
* @param errors - The validation errors to transform
* @returns The transformed validation errors
* @description
* When multiple errors are present for a single field, the last error message is used.
* Transform validation errors into an object with the field names as keys and the error messages as values.
* The path format follows the rules defined in pathToFormErrorPath.
* When multiple errors exist for the same field, the last error message is used.
*
* @example
* validationErrorsToFormErrors([
* { path: ['address', 'street'], validation: 'required', message: 'is required' },
* { path: ['address', 'street'], validation: 'type', message: 'must be a string' },
* ])
* // { '.address.street': 'must be a string' }
* Single field error
* { username: 'Required field' }
*
* Nested field error
* { '.address.street': 'should be string' }
*
* Keyword validation error
* { '.fieldName': 'should match at least one schema' }
*
* Schema-level error
* { '': 'should match at least one schema' }
*/
function validationErrorsToFormErrors(errors: ValidationError[]): Record<string, string> | null {
if (errors.length === 0) {
if (errors.length === 0)
return null
}

return errors.reduce((acc: Record<string, string>, error) => {
acc[error.path.join('')] = error.message
acc[pathToFormErrorPath(error.path, error.validation)] = error.message
return acc
}, {})
}

/**
* Validate a value against a schema
* @param value - The value to validate
* @param schema - The schema to validate against
* @returns The validation result
*/
function validate(value: SchemaValue, schema: JsfSchema): ValidationResult {
const result: ValidationResult = {}
const errors = validateSchema(value, schema)
const formErrors = validationErrorsToFormErrors(errors)

if (formErrors) {
result.formErrors = formErrors
}

return result
}

interface CreateHeadlessFormOptions {
initialValues?: SchemaValue
}
Expand Down
8 changes: 4 additions & 4 deletions next/src/validation/anyOf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ function mergeSubSchema({ parent, subSchema }: { parent: NonBooleanJsfSchema, su
* Validate a value against the `anyOf` keyword in a schema.
* @param value - The value to validate.
* @param schema - The schema that contains the `anyOf` keyword.
* @param path - The path to the current field being validated.
* @returns An array of validation errors.
* @description
* This function checks the provided value against each subschema in the `anyOf` array.
Expand All @@ -30,7 +31,7 @@ function mergeSubSchema({ parent, subSchema }: { parent: NonBooleanJsfSchema, su
* all conditions), the function returns an error indicating that the value should match at
* least one schema.
*/
export function validateAnyOf(value: SchemaValue, schema: JsfSchema): ValidationError[] {
export function validateAnyOf(value: SchemaValue, schema: JsfSchema, path: string[] = []): ValidationError[] {
if (!schema.anyOf || !Array.isArray(schema.anyOf)) {
return []
}
Expand All @@ -42,15 +43,14 @@ export function validateAnyOf(value: SchemaValue, schema: JsfSchema): Validation
if (typeof subSchema !== 'boolean' && typeof schema !== 'boolean') {
effectiveSubSchema = mergeSubSchema({ parent: schema, subSchema })
}
const errors = validateSchema(value, effectiveSubSchema)
const errors = validateSchema(value, effectiveSubSchema, false, path)
if (errors.length === 0) {
return []
}
}

// TODO: decide on a path for the `anyOf` errors and also for the other keyword errors that we'll have
return [{
path: [],
path,
validation: 'anyOf',
message: 'should match at least one schema',
}]
Expand Down
Loading
Loading