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

feat(next): string validation #118

Merged
merged 15 commits into from
Feb 4, 2025
103 changes: 100 additions & 3 deletions next/src/form.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,102 @@
import type { JsfSchema } from './types'
import type { JsfSchema, SchemaValue } from './types'
import type { SchemaValidationErrorType } from './validation/schema'
import { validateSchema } from './validation/schema'

export function createHeadlessForm(_schema: JsfSchema): never {
throw new Error('Not implemented')
interface FormResult {
fields: never[]
thien-remote marked this conversation as resolved.
Show resolved Hide resolved
isError: boolean
error: string | null
handleValidation: (value: SchemaValue) => ValidationResult
}

/**
* Validation error for schema
*/
export interface ValidationError {
/**
* The path to the field that has the error
* @example
* ['address', 'street']
*/
path: string[]
/**
* The type of validation error
* @example
* 'required'
*/
validation: SchemaValidationErrorType
/**
* The message of the validation error
* @example
* 'is required'
*/
message: string
}

export interface ValidationResult {
formErrors?: Record<string, string>
}

/**
* 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
}

/**
* 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.
* @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' }
*/
function validationErrorsToFormErrors(errors: ValidationError[]): Record<string, string> | null {
if (errors.length === 0) {
return null
}

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

interface CreateHeadlessFormOptions {
initialValues?: SchemaValue
}

export function createHeadlessForm(schema: JsfSchema, options: CreateHeadlessFormOptions = {}): FormResult {
const errors = validateSchema(options.initialValues, schema)
const validationResult = validationErrorsToFormErrors(errors)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: Thanks for doing this, following the official spec of json schema errors, and turn them into "formErrors" ✨

const isError = validationResult !== undefined

const handleValidation = (value: SchemaValue) => {
const result = validate(value, schema)
return result
}

return {
fields: [],
isError,
thien-remote marked this conversation as resolved.
Show resolved Hide resolved
error: null,
thien-remote marked this conversation as resolved.
Show resolved Hide resolved
handleValidation,
}
}
31 changes: 29 additions & 2 deletions next/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,37 @@
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
thien-remote marked this conversation as resolved.
Show resolved Hide resolved

export type JsfSchema = Exclude<JSONSchema, boolean> & {
'properties'?: Record<string, JsfSchema> | boolean
/**
* Defines the type of a `Field` in the form.
*/
export type JsfSchemaType = Exclude<JSONSchema, boolean>['type']
thien-remote marked this conversation as resolved.
Show resolved Hide resolved

/**
* Defines the type of a value in the form that will be validated against the schema.
*/
export type SchemaValue = string | number | ObjectValue | undefined
thien-remote marked this conversation as resolved.
Show resolved Hide resolved

/**
* A nested object value.
*/
export interface ObjectValue {
[key: string]: SchemaValue
}

/**
* JSON Schema Form extending JSON Schema with additional JSON Schema Form properties.
*/
export type JsfSchema = JSONSchema & {
'properties'?: Record<string, JsfSchema>
'x-jsf-logic'?: {
validations: Record<string, object>
computedValues: Record<string, object>
}
'x-jsf-order'?: string[]
}

/**
* JSON Schema Form type without booleans.
* This type is used for convenience in places where a boolean is not allowed.
* @see `JsfSchema` for the full schema type which allows booleans and is used for sub schemas.
*/
export type NonBooleanJsfSchema = Exclude<JsfSchema, boolean>
34 changes: 34 additions & 0 deletions next/src/validation/object.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { ValidationError } from '../form'
import type { NonBooleanJsfSchema, SchemaValue } from '../types'
import type { StringValidationErrorType } from './string'
import { validateSchema } from './schema'

export type ObjectValidationErrorType = StringValidationErrorType

/**
* Validate an object against a schema
* @param value - The value to validate
* @param schema - The schema to validate against
* @returns An array of validation errors
* @description
* Validates each property of object against the schema while keeping track of the path to the property.
* Each property is validated with `validateSchema`.
*/
export function validateObject(value: SchemaValue, schema: NonBooleanJsfSchema): ValidationError[] {
if (typeof schema === 'object' && schema.properties && typeof value === 'object') {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lukad / @dragidavid can we adopt the "early return" pattern anywhere we can? In this case it would be:

if (typeof schema !== 'object' || !schema.properties || typeof value !== 'object') {
  return []
}

// the actual code...

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love early return! It makes the code much easier to read.

Also we have concluded in internal discussion that early return would use a safe fallback (e.g., empty array for errors) instead of fail-fast errors (e.g., throw) (Slack).

const errors = []
for (const [key, propertySchema] of Object.entries(schema.properties)) {
thien-remote marked this conversation as resolved.
Show resolved Hide resolved
const propertyValue = value[key]
const propertyIsRequired = schema.required?.includes(key)
const propertyErrors = validateSchema(propertyValue, propertySchema, propertyIsRequired)
thien-remote marked this conversation as resolved.
Show resolved Hide resolved
const errorsWithPath = propertyErrors.map(error => ({
...error,
path: [`.${key}`, ...error.path],
}))
errors.push(...errorsWithPath)
}
return errors
}

return []
}
106 changes: 106 additions & 0 deletions next/src/validation/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { ValidationError } from '../form'
import type { JsfSchema, JsfSchemaType, SchemaValue } from '../types'
import type { ObjectValidationErrorType } from './object'
import { validateObject } from './object'
import { validateString } from './string'

export type SchemaValidationErrorType =
/**
* The value is not of the correct type
*/
| 'type'
/**
* The value is required
*/
| 'required'
/**
* The value fails validation due to boolean schema
*/
| 'valid'
/**
* The value fails validation due to object schema
*/
| ObjectValidationErrorType

/**
* Get the type of a schema
* @param schema - The schema to get the type of
* @returns The type of the schema, or an array of types if the schema is an array. Will fallback to 'object' if no type is defined.
* @example
* getType(false) // 'boolean'
* getType(true) // 'boolean'
* getType({ type: 'string' }) // 'string'
* getType({ type: ['string', 'number'] }) // ['string', 'number']
thien-remote marked this conversation as resolved.
Show resolved Hide resolved
* getType({}) // 'object'
*/
export function getSchemaType(schema: JsfSchema): JsfSchemaType | JsfSchemaType[] {
if (typeof schema === 'boolean') {
return 'boolean'
}

if (schema.type) {
return schema.type
}

return 'object'
}

/**
* Validate the type of a value against a schema
* @param value - The value to validate
* @param schema - The schema to validate against
* @returns An array of validation errors
* @description
* - If the schema type is an array, the value must be an instance of one of the types in the array.
* - If the schema type is a string, the value must be of the same type.
*/
function validateType(value: SchemaValue, schema: JsfSchema): ValidationError[] {
const schemaType = getSchemaType(schema)
const valueType = value === undefined ? 'undefined' : typeof value

if (Array.isArray(schemaType) ? !schemaType.includes(valueType) : valueType !== schemaType) {
lukad marked this conversation as resolved.
Show resolved Hide resolved
return [{ path: [], validation: 'type', message: `should be ${schemaType}` }]
}

return []
}

/**
* Validate a value against a schema
* @param value - The value to validate
* @param schema - The schema to validate against
* @param required - Whether the value is required
* @returns An array of validation errors
* @description
* This function is the main validation function to validate a value against a schema.
* - It validates boolean schemas
* - It validates the `required` constraint
* - It validates the type of the value
* - It delegates to type specific validation functions such as `validateObject` and `validateString`
* @see `validateType` for type validation
*/
export function validateSchema(value: SchemaValue, schema: JsfSchema, required: boolean = false): ValidationError[] {
if (value === undefined && required) {
return [{ path: [], validation: 'required', message: 'is required' }]
}

if (value === undefined) {
return []
}

if (typeof schema === 'boolean') {
return schema ? [] : [{ path: [], validation: 'valid', message: 'always fails' }]
}

const typeValidationErrors = validateType(value, schema)
if (typeValidationErrors.length > 0) {
return typeValidationErrors
}

const errors = [
...validateObject(value, schema),
...validateString(value, schema),
]
thien-remote marked this conversation as resolved.
Show resolved Hide resolved

return errors
}
53 changes: 53 additions & 0 deletions next/src/validation/string.ts
thien-remote marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { ValidationError } from '../form'
thien-remote marked this conversation as resolved.
Show resolved Hide resolved
import type { NonBooleanJsfSchema, SchemaValue } from '../types'
import { getSchemaType } from './schema'

export type StringValidationErrorType =
/**
* The value is too short
*/
| 'minLength'
/**
* The value is too long
*/
| 'maxLength'
/**
* The value does not match the pattern
*/
| 'pattern'

/**
* Validate a string against a schema
* @param value - The value to validate
* @param schema - The schema to validate against
* @returns An array of validation errors
* @description
* - Validates the string length against the `minLength` and `maxLength` properties.
* - Validates the string pattern against a regular expression defined in the `pattern` property.
*/
export function validateString(value: SchemaValue, schema: NonBooleanJsfSchema): ValidationError[] {
const errors: ValidationError[] = []

if (getSchemaType(schema) === 'string' && typeof value === 'string') {
if (schema.minLength && value.length < schema.minLength) {
thien-remote marked this conversation as resolved.
Show resolved Hide resolved
errors.push({ path: [], validation: 'minLength', message: 'must be at least 3 characters' })
thien-remote marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐛 Question: Why is "3 characters hardcoded"? 🤔

}

if (schema.maxLength && value.length > schema.maxLength) {
errors.push({ path: [], validation: 'maxLength', message: 'must be at most 10 characters' })
thien-remote marked this conversation as resolved.
Show resolved Hide resolved
}

if (schema.pattern) {
const pattern = new RegExp(schema.pattern)
if (!pattern.test(value)) {
errors.push({
path: [],
validation: 'pattern',
message: `must match the pattern '${schema.pattern}'`,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For UX reasons, we improve this message even more v0 source. Must have a valid format. E.g. ${randomPlaceholder}

})
}
}
}

return errors
}
13 changes: 10 additions & 3 deletions next/test/form.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { describe, expect, it } from '@jest/globals'
import { createHeadlessForm } from '../src'

/**
* Example test suite for V2
*/
describe('createHeadlessForm', () => {
it('should be a function', () => {
expect(createHeadlessForm).toBeInstanceOf(Function)
})

it('returns empty result given an empty schema', () => {
thien-remote marked this conversation as resolved.
Show resolved Hide resolved
const result = createHeadlessForm({}, { initialValues: {} })

expect(result).toMatchObject({
fields: [],
})
expect(result.isError).toBe(false)
expect(result.error).toBeFalsy()
})
})
18 changes: 18 additions & 0 deletions next/test/validation/boolean_schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, expect, it } from '@jest/globals'
import { createHeadlessForm } from '../../src'

describe('boolean schema validation', () => {
it('returns an error if the value is false', () => {
const form = createHeadlessForm({ properties: { name: false } })

expect(form.handleValidation({ name: 'anything' })).toMatchObject({ formErrors: { '.name': 'always fails' } })
thien-remote marked this conversation as resolved.
Show resolved Hide resolved
expect(form.handleValidation({})).toMatchObject({ formErrors: undefined })
})

it('does not return an error if the value is true', () => {
const form = createHeadlessForm({ properties: { name: true } })

expect(form.handleValidation({ name: 'anything' })).toMatchObject({ formErrors: undefined })
expect(form.handleValidation({})).toMatchObject({ formErrors: undefined })
})
})
Loading