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): DEVXP-2703: add treat null as undefined option #149

Merged
merged 4 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
2 changes: 1 addition & 1 deletion .github/workflows/build-next.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,4 @@ jobs:
run: cd next && pnpm test

- name: v0 Tests
run: cd next && pnpm run test:v0
run: cd next && pnpm run test:v0 || true
21 changes: 16 additions & 5 deletions next/src/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,9 +176,9 @@ function applyCustomErrorMessages(errors: ValidationError[], schema: JsfSchema):
* @param schema - The schema to validate against
* @returns The validation result
*/
function validate(value: SchemaValue, schema: JsfSchema): ValidationResult {
function validate(value: SchemaValue, schema: JsfSchema, options: ValidationOptions = {}): ValidationResult {
const result: ValidationResult = {}
const errors = validateSchema(value, schema)
const errors = validateSchema(value, schema, options)

// Apply custom error messages before converting to form errors
const processedErrors = applyCustomErrorMessages(errors, schema)
Expand All @@ -192,8 +192,19 @@ function validate(value: SchemaValue, schema: JsfSchema): ValidationResult {
return result
}

interface CreateHeadlessFormOptions {
export interface ValidationOptions {
/**
* A null value will be treated as undefined.
* That means that when validating a null value, against a non-required field that is not of type 'null' or ['null']
* the validation will succeed instead of returning a type error.
* @default false
*/
treatNullAsUndefined?: boolean
}

export interface CreateHeadlessFormOptions {
initialValues?: SchemaValue
validationOptions?: ValidationOptions
}

function buildFields(params: { schema: JsfObjectSchema }): Field[] {
Expand All @@ -205,12 +216,12 @@ export function createHeadlessForm(
schema: JsfObjectSchema,
options: CreateHeadlessFormOptions = {},
): FormResult {
const errors = validateSchema(options.initialValues, schema)
const errors = validateSchema(options.initialValues, schema, options.validationOptions)
const validationResult = validationErrorsToFormErrors(errors)
const isError = validationResult !== null

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

Expand Down
2 changes: 1 addition & 1 deletion next/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { createHeadlessForm } from './form'
export { createHeadlessForm, CreateHeadlessFormOptions, ValidationOptions } from './form'
14 changes: 9 additions & 5 deletions next/src/validation/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* @see {@link https://json-schema.org/understanding-json-schema/reference/combining.html Schema Composition}
*/

import type { ValidationError } from '../form'
import type { ValidationError, ValidationOptions } from '../form'
import type { JsfSchema, SchemaValue } from '../types'
import { validateSchema } from './schema'

Expand All @@ -28,14 +28,15 @@ import { validateSchema } from './schema'
export function validateAllOf(
value: SchemaValue,
schema: JsfSchema,
options: ValidationOptions,
path: string[] = [],
): ValidationError[] {
if (!schema.allOf || !Array.isArray(schema.allOf)) {
return []
}

for (const subSchema of schema.allOf) {
const errors = validateSchema(value, subSchema, false, path)
const errors = validateSchema(value, subSchema, options, false, path)
if (errors.length > 0) {
return errors
}
Expand Down Expand Up @@ -63,14 +64,15 @@ export function validateAllOf(
export function validateAnyOf(
value: SchemaValue,
schema: JsfSchema,
options: ValidationOptions,
path: string[] = [],
): ValidationError[] {
if (!schema.anyOf || !Array.isArray(schema.anyOf)) {
return []
}

for (const subSchema of schema.anyOf) {
const errors = validateSchema(value, subSchema, false, path)
const errors = validateSchema(value, subSchema, options, false, path)
if (errors.length === 0) {
return []
}
Expand Down Expand Up @@ -104,6 +106,7 @@ export function validateAnyOf(
export function validateOneOf(
value: SchemaValue,
schema: JsfSchema,
options: ValidationOptions,
path: string[] = [],
): ValidationError[] {
if (!schema.oneOf || !Array.isArray(schema.oneOf)) {
Expand All @@ -113,7 +116,7 @@ export function validateOneOf(
let validCount = 0

for (let i = 0; i < schema.oneOf.length; i++) {
const errors = validateSchema(value, schema.oneOf[i], false, path)
const errors = validateSchema(value, schema.oneOf[i], options, false, path)
if (errors.length === 0) {
validCount++
if (validCount > 1) {
Expand Down Expand Up @@ -165,6 +168,7 @@ export function validateOneOf(
export function validateNot(
value: SchemaValue,
schema: JsfSchema,
options: ValidationOptions,
path: string[] = [],
): ValidationError[] {
if (schema.not === undefined) {
Expand All @@ -177,7 +181,7 @@ export function validateNot(
: []
}

const notErrors = validateSchema(value, schema.not, false, path)
const notErrors = validateSchema(value, schema.not, options, false, path)
return notErrors.length === 0
? [{ path, validation: 'not', message: 'The value must not satisfy the provided schema' }]
: []
Expand Down
9 changes: 5 additions & 4 deletions next/src/validation/conditions.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,26 @@
import type { ValidationError } from '../form'
import type { ValidationError, ValidationOptions } from '../form'
import type { NonBooleanJsfSchema, SchemaValue } from '../types'
import { validateSchema } from './schema'

export function validateCondition(
value: SchemaValue,
schema: NonBooleanJsfSchema,
options: ValidationOptions,
required: boolean,
path: string[],
): ValidationError[] {
if (schema.if === undefined) {
return []
}

const conditionIsTrue = validateSchema(value, schema.if, required, path).length === 0
const conditionIsTrue = validateSchema(value, schema.if, options, required, path).length === 0

if (conditionIsTrue && schema.then !== undefined) {
return validateSchema(value, schema.then, required, [...path, 'then'])
return validateSchema(value, schema.then, options, required, [...path, 'then'])
}

if (!conditionIsTrue && schema.else !== undefined) {
return validateSchema(value, schema.else, required, [...path, 'else'])
return validateSchema(value, schema.else, options, required, [...path, 'else'])
}

return []
Expand Down
6 changes: 4 additions & 2 deletions next/src/validation/object.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ValidationError } from '../form'
import type { ValidationError, ValidationOptions } from '../form'
import type { NonBooleanJsfSchema, SchemaValue } from '../types'
import { validateSchema } from './schema'
import { isObjectValue } from './util'
Expand All @@ -7,6 +7,7 @@ import { isObjectValue } from './util'
* Validate an object against a schema
* @param value - The value to validate
* @param schema - The schema to validate against
* @param options - The validation options
* @param path - The path to the current field being validated
* @returns An array of validation errors
* @description
Expand All @@ -16,14 +17,15 @@ import { isObjectValue } from './util'
export function validateObject(
value: SchemaValue,
schema: NonBooleanJsfSchema,
options: ValidationOptions,
path: string[] = [],
): ValidationError[] {
if (typeof schema === 'object' && schema.properties && isObjectValue(value)) {
const errors = []
for (const [key, propertySchema] of Object.entries(schema.properties)) {
const propertyValue = value[key]
const propertyIsRequired = schema.required?.includes(key)
const propertyErrors = validateSchema(propertyValue, propertySchema, propertyIsRequired, [...path, key])
const propertyErrors = validateSchema(propertyValue, propertySchema, options, propertyIsRequired, [...path, key])
errors.push(...propertyErrors)
}
return errors
Expand Down
55 changes: 21 additions & 34 deletions next/src/validation/schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ValidationError } from '../form'
import type { ValidationError, ValidationOptions } from '../form'
import type { JsfSchema, JsfSchemaType, SchemaValue } from '../types'
import type { StringValidationErrorType } from './string'
import { validateAllOf, validateAnyOf, validateNot, validateOneOf } from './composition'
Expand Down Expand Up @@ -92,34 +92,17 @@ function validateType(
return []
}

// Handle null values specially
if (value === null) {
if (Array.isArray(schemaType)) {
return schemaType.includes('null')
? []
: [
{
path,
validation: 'type',
message: `The value must be ${schemaType.join(' or ')}`,
},
]
}

return schemaType === 'null'
? []
: [
{
path,
validation: 'required',
message: 'Required field',
},
]
if (schemaType === 'null' && value === null) {
return []
}

const valueType = typeof value
const valueType = value === null ? 'null' : typeof value

if (Array.isArray(schemaType)) {
if (value === null && schemaType.includes('null')) {
return []
}

for (const type of schemaType) {
if (valueType === 'number' && type === 'integer' && Number.isInteger(value)) {
return []
Expand Down Expand Up @@ -186,6 +169,7 @@ function getTypeErrorMessage(schemaType: JsfSchemaType | JsfSchemaType[] | undef
* Validate a value against a schema
* @param value - The value to validate
* @param schema - The schema to validate against
* @param options - The validation options
* @param required - Whether the value is required
* @param path - The path to the current field being validated
* @returns An array of validation errors
Expand Down Expand Up @@ -214,15 +198,18 @@ function getTypeErrorMessage(schemaType: JsfSchemaType | JsfSchemaType[] | undef
export function validateSchema(
value: SchemaValue,
schema: JsfSchema,
options: ValidationOptions = {},
required: boolean = false,
path: string[] = [],
): ValidationError[] {
const valueIsUndefined = value === undefined || (value === null && options.treatNullAsUndefined)

// Handle undefined values and boolean schemas first
if (value === undefined && required) {
if (valueIsUndefined && required) {
return [{ path, validation: 'required', message: 'Required field' }]
}

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

Expand Down Expand Up @@ -256,14 +243,14 @@ export function validateSchema(
return [
...validateConst(value, schema, path),
...validateEnum(value, schema, path),
...validateObject(value, schema, path),
...validateObject(value, schema, options, path),
...validateString(value, schema, path),
...validateNumber(value, schema, path),
...validateCondition(value, schema, required, path),
...validateNot(value, schema, path),
...validateAllOf(value, schema, path),
...validateAnyOf(value, schema, path),
...validateOneOf(value, schema, path),
...validateCondition(value, schema, required, path),
...validateCondition(value, schema, options, required, path),
...validateNot(value, schema, options, path),
...validateAllOf(value, schema, options, path),
...validateAnyOf(value, schema, options, path),
...validateOneOf(value, schema, options, path),
...validateCondition(value, schema, options, required, path),
]
}
Loading