Skip to content
Open
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
69 changes: 57 additions & 12 deletions src/form.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ValidationError, ValidationErrorPath } from './errors'
import type { SchemaValidationErrorType, ValidationError, ValidationErrorPath } from './errors'
import type { Field } from './field/type'
import type { JsfObjectSchema, JsfSchema, SchemaValue } from './types'
import type { LegacyOptions } from './validation/schema'
Expand Down Expand Up @@ -166,26 +166,42 @@ function addErrorMessages(errors: ValidationError[]): ValidationErrorWithMessage
}

/**
* Apply custom error messages from the schema to validation errors
* Apply custom error messages to validation errors.
* @param errors - The validation errors
* @param schema - The schema that contains custom error messages
* @param globalErrorMessages - Form-level default messages per validation type, from `options.errorMessages`
* @returns The validation errors with custom error messages applied
* @description
* Two sources of custom messages can override the built-in default, in order of precedence:
* 1. The field's own `x-jsf-errorMessage` (schema-level, one field at a time) — always wins.
* 2. `globalErrorMessages` (form-level, applies to every field of that validation type) — used
* only when the field doesn't define its own override. This exists so consumers can set
* messages once (e.g. for i18n) instead of repeating `x-jsf-errorMessage` on every property.
*/
function applyCustomErrorMessages(errors: ValidationErrorWithMessage[], schema: JsfSchema): ValidationErrorWithMessage[] {
function applyCustomErrorMessages(
errors: ValidationErrorWithMessage[],
schema: JsfSchema,
globalErrorMessages?: Partial<Record<SchemaValidationErrorType, string>>,
): ValidationErrorWithMessage[] {
if (typeof schema !== 'object' || !schema || !errors.length) {
return errors
}

return errors.map((error) => {
const fieldSchema = error.schema
const customErrorMessage = fieldSchema['x-jsf-errorMessage']?.[error.validation]
if (
fieldSchema
&& customErrorMessage
) {
const fieldErrorMessage = fieldSchema['x-jsf-errorMessage']?.[error.validation]
if (fieldSchema && fieldErrorMessage) {
return {
...error,
message: customErrorMessage,
message: fieldErrorMessage,
}
}

const globalErrorMessage = globalErrorMessages?.[error.validation]
if (globalErrorMessage) {
return {
...error,
message: globalErrorMessage,
}
}

Expand All @@ -197,14 +213,21 @@ function applyCustomErrorMessages(errors: ValidationErrorWithMessage[], schema:
* Validate a value against a schema
* @param value - The value to validate
* @param schema - The schema to validate against
* @param options - Legacy (v0 back-compat) validation options
* @param errorMessages - Form-level default error messages per validation type (see `CreateHeadlessFormOptions.errorMessages`)
* @returns The validation result
*/
function validate(value: SchemaValue, schema: JsfSchema, options: LegacyOptions = {}): ValidationResult {
function validate(
value: SchemaValue,
schema: JsfSchema,
options: LegacyOptions = {},
errorMessages?: Partial<Record<SchemaValidationErrorType, string>>,
): ValidationResult {
const result: ValidationResult = {}
const errors = validateSchema(value, schema, options)

const errorsWithMessages = addErrorMessages(errors)
const processedErrors = applyCustomErrorMessages(errorsWithMessages, schema)
const processedErrors = applyCustomErrorMessages(errorsWithMessages, schema, errorMessages)

const formErrors = validationErrorsToFormErrors(processedErrors)

Expand Down Expand Up @@ -234,6 +257,28 @@ export interface CreateHeadlessFormOptions {
* Custom user defined functions. A dictionary of name and function
*/
customJsonLogicOps?: Record<string, (...args: any[]) => any>

/**
* Default error messages to use per validation type (e.g. `required`, `type`, `minLength`),
* applied to every field that doesn't already define its own `x-jsf-errorMessage` for that
* validation type.
*
* Useful for i18n and for apps with many fields: define each message once here instead of
* repeating `x-jsf-errorMessage` on every property of the schema.
*
* Precedence (most specific wins): a field's own `x-jsf-errorMessage` > `errorMessages` (this
* option) > the library's built-in default message.
* @example
* ```ts
* createHeadlessForm(schema, {
* errorMessages: {
* required: 'This field is required.',
* minLength: 'This value is too short.',
* },
* })
* ```
*/
errorMessages?: Partial<Record<SchemaValidationErrorType, string>>
}

function buildFields(params: { schema: JsfObjectSchema, originalSchema: JsfObjectSchema, strictInputType?: boolean }): Field[] {
Expand Down Expand Up @@ -314,7 +359,7 @@ export function createHeadlessForm(
options,
})

const result = validate(value, updatedSchema, options.legacyOptions)
const result = validate(value, updatedSchema, options.legacyOptions, options.errorMessages)

updateFieldProperties(fields, updatedSchema, schema)

Expand Down
261 changes: 261 additions & 0 deletions test/errors/messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,267 @@ describe('validation error messages', () => {
})
})

describe('global error messages (options.errorMessages)', () => {
it('applies a form-level default message to every field of a given validation type', () => {
// Two fields, two different input types, same validation type ('required').
// A single `errorMessages.required` should cover both, without repeating
// `x-jsf-errorMessage` on each property — this is the i18n use case from
// https://github.com/remoteoss/json-schema-form/issues/69
const schema: JsfObjectSchema = {
type: 'object',
properties: {
pet_name: { title: 'Pet name', type: 'string' },
browsers: {
'title': 'Browsers',
'type': 'string',
'oneOf': [
{ const: 'chr', title: 'Chrome' },
{ const: 'ff', title: 'Firefox' },
],
'x-jsf-presentation': { inputType: 'select' },
},
},
required: ['pet_name', 'browsers'],
}
const form = createHeadlessForm(schema, {
errorMessages: {
required: 'This cannot be empty.',
},
})

const result = form.handleValidation({})

expect(result.formErrors).toMatchObject({
pet_name: 'This cannot be empty.',
browsers: 'This cannot be empty.',
})
})

it('lets a field\'s own x-jsf-errorMessage override the form-level default', () => {
const schema: JsfObjectSchema = {
type: 'object',
properties: {
pet_name: { type: 'string' },
email: {
'type': 'string',
'x-jsf-errorMessage': {
required: 'Please provide your email address',
},
},
},
required: ['pet_name', 'email'],
}
const form = createHeadlessForm(schema, {
errorMessages: {
required: 'This cannot be empty.',
},
})

const result = form.handleValidation({})

expect(result.formErrors).toMatchObject({
pet_name: 'This cannot be empty.', // falls back to the form-level default
email: 'Please provide your email address', // field-level override wins
})
})

it('falls back to the built-in default message when no override matches the validation type', () => {
const schema: JsfObjectSchema = {
type: 'object',
properties: {
age: { type: 'number', minimum: 18 },
},
}
// Only `required` is overridden — `minimum` errors should keep using
// the library's built-in message.
const form = createHeadlessForm(schema, {
errorMessages: {
required: 'This cannot be empty.',
},
})

const result = form.handleValidation({ age: 10 })

expect(result.formErrors).toMatchObject({
age: 'Must be greater or equal to 18',
})
})

it('applies a global "type" message across different data types', () => {
const schema: JsfObjectSchema = {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
},
}
const form = createHeadlessForm(schema, {
errorMessages: { type: 'Invalid value.' },
})

const result = form.handleValidation({ name: 123, age: 'not a number' })

expect(result.formErrors).toMatchObject({
name: 'Invalid value.',
age: 'Invalid value.',
})
})

it('applies a global "enum" message', () => {
const schema: JsfObjectSchema = {
type: 'object',
properties: {
status: { type: 'string', enum: ['active', 'inactive'] },
},
}
const form = createHeadlessForm(schema, {
errorMessages: { enum: 'Pick one of the allowed options.' },
})

const result = form.handleValidation({ status: 'unknown' })

expect(result.formErrors).toMatchObject({
status: 'Pick one of the allowed options.',
})
})

it('applies a global "minLength" message', () => {
const schema: JsfObjectSchema = {
type: 'object',
properties: {
username: { type: 'string', minLength: 5 },
},
}
const form = createHeadlessForm(schema, {
errorMessages: { minLength: 'Too short.' },
})

const result = form.handleValidation({ username: 'ab' })

expect(result.formErrors).toMatchObject({
username: 'Too short.',
})
})

it('supports multiple validation types overridden at once', () => {
const schema: JsfObjectSchema = {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number', minimum: 18 },
},
required: ['name'],
}
const form = createHeadlessForm(schema, {
errorMessages: {
required: 'This cannot be empty.',
minimum: 'Value too low.',
},
})

const result = form.handleValidation({ age: 10 })

expect(result.formErrors).toMatchObject({
name: 'This cannot be empty.',
age: 'Value too low.',
})
})

it('also overrides the checkbox-specific "required" message, since it is the same validation type', () => {
// Checkboxes get a special built-in default ("Please acknowledge this field")
// instead of the generic "Required field" — applyCustomErrorMessages() doesn't
// special-case checkboxes, so a global `required` override applies here too.
// Documenting this as intended behavior: the option is about the validation
// type, not about how the default happens to be computed for a given input.
const schema: JsfObjectSchema = {
type: 'object',
properties: {
consent: {
'type': 'string',
'const': 'yes',
'x-jsf-presentation': { inputType: 'checkbox' },
},
},
required: ['consent'],
}
const form = createHeadlessForm(schema, {
errorMessages: { required: 'This cannot be empty.' },
})

const result = form.handleValidation({})

expect(result.formErrors).toMatchObject({
consent: 'This cannot be empty.',
})
})

it('does not produce any error message when the submitted data is valid', () => {
const schema: JsfObjectSchema = {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number', minimum: 18 },
},
required: ['name'],
}
const form = createHeadlessForm(schema, {
errorMessages: {
required: 'This cannot be empty.',
minimum: 'Value too low.',
type: 'Invalid value.',
},
})

const result = form.handleValidation({ name: 'Rex', age: 20 })

expect(result.formErrors).toBeUndefined()
})

it('reaches fields nested inside a sub-object', () => {
const schema: JsfObjectSchema = {
type: 'object',
properties: {
address: {
type: 'object',
properties: {
street: { type: 'string' },
},
required: ['street'],
},
},
required: ['address'],
}
const form = createHeadlessForm(schema, {
errorMessages: { required: 'This cannot be empty.' },
})

const result = form.handleValidation({ address: {} })

expect(result.formErrors).toMatchObject({
address: {
street: 'This cannot be empty.',
},
})
})

it('an empty errorMessages object behaves the same as not passing the option at all', () => {
const schema: JsfObjectSchema = {
type: 'object',
properties: {
name: { type: 'string' },
},
required: ['name'],
}
const form = createHeadlessForm(schema, { errorMessages: {} })

const result = form.handleValidation({})

expect(result.formErrors).toMatchObject({
name: 'Required field',
})
})
})

describe('schema composition errors', () => {
it('shows anyOf validation error messages', () => {
const schema: JsfObjectSchema = {
Expand Down