diff --git a/next/src/errors/messages.ts b/next/src/errors/messages.ts index 301eb221..841d4739 100644 --- a/next/src/errors/messages.ts +++ b/next/src/errors/messages.ts @@ -81,17 +81,17 @@ export function getErrorMessage( } // Arrays case 'minItems': - throw new Error('Array support is not implemented yet') + return `Must have at least ${schema.minItems} items` case 'maxItems': - throw new Error('Array support is not implemented yet') + return `Must have at most ${schema.maxItems} items` case 'uniqueItems': - throw new Error('Array support is not implemented yet') + return 'Items must be unique' case 'contains': - throw new Error('Array support is not implemented yet') + throw new Error('"contains" is not implemented yet') case 'minContains': - throw new Error('Array support is not implemented yet') + throw new Error('"minContains" is not implemented yet') case 'maxContains': - throw new Error('Array support is not implemented yet') + throw new Error('"maxContains" is not implemented yet') case 'json-logic': return customErrorMessage || 'The value is not valid' } diff --git a/next/src/field/object.ts b/next/src/field/object.ts deleted file mode 100644 index c1229c9c..00000000 --- a/next/src/field/object.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { JsfObjectSchema } from '../types' -import type { Field } from './type' -import { setCustomOrder } from '../custom/order' -import { buildFieldSchema } from './schema' - -/** - * Build a field from an object schema - * @param schema - The schema of the field - * @param name - The name of the field, used if the schema has no title - * @param required - Whether the field is required - * @returns The field - */ -export function buildFieldObject(schema: JsfObjectSchema, name: string, required: boolean, strictInputType?: boolean) { - const fields: Field[] = [] - - for (const key in schema.properties) { - const isRequired = schema.required?.includes(key) || false - const field = buildFieldSchema(schema.properties[key], key, isRequired, strictInputType) - if (field) { - fields.push(field) - } - } - - const orderedFields = setCustomOrder({ fields, schema }) - - const field: Field = { - ...schema['x-jsf-presentation'], - type: schema['x-jsf-presentation']?.inputType || 'fieldset', - inputType: schema['x-jsf-presentation']?.inputType || 'fieldset', - jsonType: 'object', - name, - required, - fields: orderedFields, - isVisible: true, - } - - if (schema.title !== undefined) { - field.label = schema.title - } - - if (schema.description !== undefined) { - field.description = schema.description - } - - if (schema['x-jsf-presentation']?.accept) { - field.accept = schema['x-jsf-presentation']?.accept - } - - return field -} diff --git a/next/src/field/schema.ts b/next/src/field/schema.ts index 03fcdcc8..ea6ab929 100644 --- a/next/src/field/schema.ts +++ b/next/src/field/schema.ts @@ -1,6 +1,6 @@ import type { JsfObjectSchema, JsfSchema, JsfSchemaType, NonBooleanJsfSchema } from '../types' import type { Field, FieldOption, FieldType } from './type' -import { buildFieldObject } from './object' +import { setCustomOrder } from '../custom/order' /** * Add checkbox attributes to a field @@ -64,16 +64,20 @@ function getInputTypeFromSchema(type: JsfSchemaType, schema: NonBooleanJsfSchema /** * Get the input type for a field + * @param type - The schema type + * @param name - The name of the field * @param schema - The non boolean schema of the field + * @param strictInputType - Whether to strictly enforce the input type * @returns The input type for the field, based schema type. Default to 'text' + * @throws If the input type is missing and strictInputType is true with the exception of the root field */ -function getInputType(schema: NonBooleanJsfSchema, strictInputType?: boolean): FieldType { +export function getInputType(type: JsfSchemaType, name: string, schema: NonBooleanJsfSchema, strictInputType?: boolean): FieldType { const presentation = schema['x-jsf-presentation'] if (presentation?.inputType) { return presentation.inputType as FieldType } - if (strictInputType) { + if (strictInputType && name !== 'root') { throw new Error(`Strict error: Missing inputType to field "${schema.title}". You can fix the json schema or skip this error by calling createHeadlessForm(schema, { strictInputType: false })`) } @@ -94,7 +98,7 @@ You can fix the json schema or skip this error by calling createHeadlessForm(sch } // Get input type from schema (fallback type is "string") - return getInputTypeFromSchema(schema.type || 'string', schema) + return getInputTypeFromSchema(type || schema.type || 'string', schema) } /** @@ -169,6 +173,80 @@ function getFieldOptions(schema: NonBooleanJsfSchema) { return null } +/** + * Get the fields for an object schema + * @param schema - The schema of the field + * @param strictInputType - Whether to strictly enforce the input type + * @returns The fields for the schema or an empty array if the schema does not define any properties + */ +function getObjectFields(schema: NonBooleanJsfSchema, strictInputType?: boolean): Field[] | null { + const fields: Field[] = [] + + for (const key in schema.properties) { + const isRequired = schema.required?.includes(key) || false + const field = buildFieldSchema(schema.properties[key], key, isRequired, strictInputType) + if (field) { + fields.push(field) + } + } + + const orderedFields = setCustomOrder({ fields, schema }) + + return orderedFields +} + +/** + * Get the fields for an array schema + * @param schema - The schema of the field + * @param strictInputType - Whether to strictly enforce the input type + * @returns The fields for the schema or an empty array if the schema does not define any items + */ +function getArrayFields(schema: NonBooleanJsfSchema, strictInputType?: boolean): Field[] { + const fields: Field[] = [] + + if (typeof schema.items !== 'object' || schema.items === null) { + return [] + } + + if (schema.items?.type === 'object') { + const objectSchema = schema.items as JsfObjectSchema + + for (const key in objectSchema.properties) { + const isFieldRequired = objectSchema.required?.includes(key) || false + const field = buildFieldSchema(objectSchema.properties[key], key, isFieldRequired, strictInputType) + if (field) { + field.nameKey = key + fields.push(field) + } + } + } + else { + const field = buildFieldSchema(schema.items, 'item', false, strictInputType) + if (field) { + fields.push(field) + } + } + + return fields +} + +/** + * Get the fields for a schema from either `items` or `properties` + * @param schema - The schema of the field + * @param strictInputType - Whether to strictly enforce the input type + * @returns The fields for the schema + */ +function getFields(schema: NonBooleanJsfSchema, strictInputType?: boolean): Field[] | null { + if (typeof schema.properties === 'object' && schema.properties !== null) { + return getObjectFields(schema, strictInputType) + } + else if (typeof schema.items === 'object' && schema.items !== null) { + return getArrayFields(schema, strictInputType) + } + + return null +} + /** * List of schema properties that should be excluded from the final field or handled specially */ @@ -179,7 +257,7 @@ const excludedSchemaProps = [ 'x-jsf-presentation', // Handled separately 'oneOf', // Transformed to 'options' 'anyOf', // Transformed to 'options' - 'items', // Handled specially for arrays + 'properties', // Handled separately ] /** @@ -190,25 +268,31 @@ export function buildFieldSchema( name: string, required: boolean = false, strictInputType: boolean = false, + type: JsfSchemaType = undefined, ): Field | null { - if (typeof schema === 'boolean') { - return null - } - - if (schema.type === 'object') { - const objectSchema: JsfObjectSchema = { ...schema, type: 'object' } - return buildFieldObject(objectSchema, name, required) + // If schema is boolean false, return a field with isVisible=false + if (schema === false) { + const inputType = getInputType(type, name, schema, strictInputType) + return { + type: inputType, + name, + inputType, + jsonType: 'boolean', + required, + isVisible: false, + } } - if (schema.type === 'array') { - throw new TypeError('Array type is not yet supported') + // If schema is any other boolean (true), just return null + if (typeof schema === 'boolean') { + return null } const presentation = schema['x-jsf-presentation'] || {} const errorMessage = schema['x-jsf-errorMessage'] // Get input type from presentation or fallback to schema type - const inputType = getInputType(schema, strictInputType) + const inputType = getInputType(type, name, schema, strictInputType) // Build field with all schema properties by default, excluding ones that need special handling const field: Field = { @@ -216,12 +300,11 @@ export function buildFieldSchema( ...Object.entries(schema) .filter(([key]) => !excludedSchemaProps.includes(key)) .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}), - // Add required field properties type: inputType, name, inputType, - jsonType: schema.type, + jsonType: type || schema.type, required, isVisible: true, ...(errorMessage && { errorMessage }), @@ -239,9 +322,11 @@ export function buildFieldSchema( if (Object.keys(presentation).length > 0) { Object.entries(presentation).forEach(([key, value]) => { // inputType is already handled above - if (key !== 'inputType') { - field[key] = value + if (key === 'inputType') { + return } + + field[key] = value }) } @@ -249,6 +334,16 @@ export function buildFieldSchema( const options = getFieldOptions(schema) if (options) { field.options = options + if (schema.type === 'array') { + field.multiple = true + } + } + else { + // We did not find options, so we might have an array to generate fields from + const fields = getFields(schema, strictInputType) + if (fields) { + field.fields = fields + } } return field diff --git a/next/src/form.ts b/next/src/form.ts index 760f6e57..56d38260 100644 --- a/next/src/form.ts +++ b/next/src/form.ts @@ -3,7 +3,7 @@ import type { Field } from './field/type' import type { JsfObjectSchema, JsfSchema, SchemaValue } from './types' import type { ValidationOptions } from './validation/schema' import { getErrorMessage } from './errors/messages' -import { buildFieldObject } from './field/object' +import { buildFieldSchema } from './field/schema' import { mutateFields } from './mutations' import { validateSchema } from './validation/schema' @@ -20,9 +20,10 @@ interface FormResult { * Recursive type for form error messages * - String for leaf error messages * - Nested object for nested fields + * - Arrays for group-array fields */ export interface FormErrors { - [key: string]: string | FormErrors + [key: string]: string | FormErrors | Array } export interface ValidationResult { @@ -77,13 +78,13 @@ function cleanErrorPath(path: ValidationErrorPath): ValidationErrorPath { * Schema-level error * { '': 'The value must match at least one schema' } */ -function validationErrorsToFormErrors(errors: ValidationErrorWithMessage[]): FormErrors | null { +function validationErrorsToFormErrors(errors: ValidationErrorWithMessage[], _value: SchemaValue = {}): FormErrors | null { if (errors.length === 0) { return null } // Use a more functional approach with reduce - return errors.reduce((result, error) => { + const result = errors.reduce((result, error) => { const { path } = error // Handle schema-level errors (empty path) @@ -94,19 +95,37 @@ function validationErrorsToFormErrors(errors: ValidationErrorWithMessage[]): For // Clean the path to remove intermediate composition structures const cleanedPath = cleanErrorPath(path) - - // For all paths, recursively build the nested structure let current = result // Process all segments except the last one (which will hold the message) cleanedPath.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] = {} - } + // Special case for group-arrays where the next segment is `items` followed by the index of the item + if (cleanedPath[1] === 'items' && typeof cleanedPath[2] === 'number') { + const [arrayName, _, arrayIndex] = cleanedPath + + if (!(arrayName in result) || typeof result[arrayName] === 'string') { + result[arrayName] = [] as Array + } + + const array = result[arrayName] as Array + + // Create a new object for this index if it doesn't exist + if (array[arrayIndex] === null || array[arrayIndex] === undefined) { + array[arrayIndex] = {} + } - current = current[segment] as FormErrors + // Update current to point to the array item object + current = array[arrayIndex] as FormErrors + } + else { + // 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] = {} + } + + current = current[segment] as FormErrors + } }) // Set the message at the final level @@ -121,6 +140,8 @@ function validationErrorsToFormErrors(errors: ValidationErrorWithMessage[]): For return result }, {}) + + return result } interface ValidationErrorWithMessage extends ValidationError { @@ -184,7 +205,7 @@ function validate(value: SchemaValue, schema: JsfSchema, options: ValidationOpti const errorsWithMessages = addErrorMessages(errors) const processedErrors = applyCustomErrorMessages(errorsWithMessages, schema) - const formErrors = validationErrorsToFormErrors(processedErrors) + const formErrors = validationErrorsToFormErrors(processedErrors, value) if (formErrors) { result.formErrors = formErrors @@ -211,7 +232,7 @@ export interface CreateHeadlessFormOptions { function buildFields(params: { schema: JsfObjectSchema, strictInputType?: boolean }): Field[] { const { schema, strictInputType } = params - const fields = buildFieldObject(schema, 'root', true, strictInputType).fields || [] + const fields = buildFieldSchema(schema, 'root', true, strictInputType, 'object')?.fields || [] return fields } @@ -259,7 +280,7 @@ function buildFieldsInPlace(fields: Field[], schema: JsfObjectSchema): void { fields.length = 0 // Get new fields from schema - const newFields = buildFieldObject(schema, 'root', true).fields || [] + const newFields = buildFieldSchema(schema, 'root', true, false, 'object')?.fields || [] // Push all new fields into existing array fields.push(...newFields) diff --git a/next/src/mutations.ts b/next/src/mutations.ts index 048eacc5..634818ee 100644 --- a/next/src/mutations.ts +++ b/next/src/mutations.ts @@ -143,7 +143,7 @@ function processBranch(fields: Field[], values: SchemaValue, branch: JsfSchema, } // If the field has properties being declared on this branch, we need to update the field // with the new properties - const newField = buildFieldSchema(fieldSchema as JsfObjectSchema, fieldName, true) + const newField = buildFieldSchema(fieldSchema as JsfObjectSchema, fieldName, false) for (const key in newField) { // We don't want to override the type property if (!['type'].includes(key)) { diff --git a/next/test/fields.test.ts b/next/test/fields.test.ts index 0e02eeb1..7c86b7b6 100644 --- a/next/test/fields.test.ts +++ b/next/test/fields.test.ts @@ -61,19 +61,6 @@ describe('fields', () => { ]) }) - it('should throw an error if the type equals "array" (group-array)', () => { - const schema = { - type: 'object', - properties: { - name: { type: 'array' }, - }, - } - - expect(() => buildFieldSchema(schema, 'root', true)).toThrow( - 'Array type is not yet supported', - ) - }) - it('should build an object field with multiple properties', () => { const schema = { type: 'object', @@ -376,8 +363,7 @@ describe('fields', () => { .toThrow(/Strict error: Missing inputType to field "Test"/) }) - // Skipping this test until we have group-array support - it.skip('defaults to group-array for schema with no type but items.properties', () => { + it('defaults to group-array for schema with no type but items.properties', () => { const schema = { items: { properties: { diff --git a/next/test/fields/array.test.ts b/next/test/fields/array.test.ts new file mode 100644 index 00000000..2e693ac6 --- /dev/null +++ b/next/test/fields/array.test.ts @@ -0,0 +1,720 @@ +import type { JsfObjectSchema, JsfSchema } from '../../src/types' +import { describe, expect, it } from '@jest/globals' +import { createHeadlessForm } from '../../src' +import { buildFieldSchema } from '../../src/field/schema' + +describe('buildFieldArray', () => { + it('should build a field from an array schema', () => { + const schema: JsfSchema = { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }, + } + + const field = buildFieldSchema(schema, 'root', true) + + expect(field).toBeDefined() + expect(field?.type).toBe('group-array') + }) + + it('should handle required arrays', () => { + const schema: JsfSchema = { + type: 'object', + required: ['items'], + properties: { + items: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }, + }, + }, + } + + const rootField = buildFieldSchema(schema, 'root', true) + const arrayField = rootField?.fields?.[0] + + expect(arrayField).toBeDefined() + expect(arrayField?.type).toBe('group-array') + expect(arrayField?.required).toBe(true) + }) + + it('should handle arrays with complex object items', () => { + const schema: JsfSchema = { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string', title: 'Name' }, + age: { type: 'number', title: 'Age' }, + }, + required: ['name'], + }, + } + + const field = buildFieldSchema(schema, 'objectArray', true) + + expect(field).toBeDefined() + expect(field?.type).toBe('group-array') + + const fields = field!.fields! + expect(fields).toHaveLength(2) + expect(fields[0].name).toBe('name') + expect(fields[0].type).toBe('text') + expect(fields[0].required).toBe(true) + expect(fields[1].name).toBe('age') + expect(fields[1].type).toBe('number') + expect(fields[1].required).toBe(false) + }) + + it('should handle arrays with custom presentation', () => { + const schema: JsfSchema = { + 'type': 'array', + 'title': 'Tasks', + 'description': 'List of tasks to complete', + 'x-jsf-presentation': { + foo: 'bar', + bar: 'baz', + }, + 'items': { + type: 'object', + properties: { + title: { type: 'string' }, + }, + }, + } + + const field = buildFieldSchema(schema, 'tasksArray', true) + + expect(field).toBeDefined() + expect(field?.type).toBe('group-array') + expect(field?.foo).toBe('bar') + expect(field?.bar).toBe('baz') + expect(field?.description).toBe('List of tasks to complete') + expect(field?.label).toBe('Tasks') + }) + + it('should handle nested group-arrays', () => { + const schema: JsfSchema = { + type: 'array', + title: 'Matrix', + items: { + type: 'object', + properties: { + nested: { + type: 'array', + title: 'Nested', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }, + }, + }, + }, + } + + const field = buildFieldSchema(schema, 'matrix', true) + + expect(field).toBeDefined() + expect(field?.type).toBe('group-array') + expect(field?.label).toBe('Matrix') + + const fields = field?.fields + expect(fields).toBeDefined() + expect(fields).toHaveLength(1) + expect(fields?.[0]?.type).toBe('group-array') + expect(fields?.[0]?.label).toBe('Nested') + + const nestedFields = fields?.[0]?.fields + expect(nestedFields).toBeDefined() + expect(nestedFields).toHaveLength(1) + expect(nestedFields?.[0]?.type).toBe('text') + expect(nestedFields?.[0]?.name).toBe('name') + }) + + it('allows non-object items', () => { + const groupArray = () => expect.objectContaining({ + inputType: 'group-array', + fields: [expect.anything()], + }) + + expect(buildFieldSchema({ 'type': 'array', 'x-jsf-presentation': { inputType: 'group-array' }, 'items': { type: 'string' } }, 'root', true)).toEqual(groupArray()) + expect(buildFieldSchema({ 'type': 'array', 'x-jsf-presentation': { inputType: 'group-array' }, 'items': { type: 'number' } }, 'root', true)).toEqual(groupArray()) + expect(buildFieldSchema({ 'type': 'array', 'x-jsf-presentation': { inputType: 'group-array' }, 'items': { type: 'array' } }, 'root', true)).toEqual(groupArray()) + expect(buildFieldSchema({ 'type': 'array', 'x-jsf-presentation': { inputType: 'group-array' }, 'items': { type: 'enum' } }, 'root', true)).toEqual(groupArray()) + expect(buildFieldSchema({ 'type': 'array', 'x-jsf-presentation': { inputType: 'group-array' }, 'items': { type: 'boolean' } }, 'root', true)).toEqual(groupArray()) + }) + + it('creates correct form errors validation errors in array items', () => { + const schema: JsfObjectSchema = { + type: 'object', + properties: { + list: { + type: 'array', + items: { + type: 'object', + properties: { + a: { + type: 'string', + }, + }, + required: ['a'], + }, + }, + }, + required: ['list'], + } + + const form = createHeadlessForm(schema) + + expect(form.handleValidation({}).formErrors).toEqual({ list: 'Required field' }) + expect(form.handleValidation({ list: [] }).formErrors).toEqual(undefined) + expect(form.handleValidation({ list: [{ a: 'test' }] }).formErrors).toEqual(undefined) + expect(form.handleValidation({ list: [{}] }).formErrors).toEqual({ list: [{ a: 'Required field' }] }) + expect(form.handleValidation({ list: [{ a: 'a' }, {}, { a: 'c' }] }).formErrors).toEqual({ list: [undefined, { a: 'Required field' }, undefined] }) + }) + + it('handles validation of arrays with multiple required fields', () => { + const schema: JsfObjectSchema = { + type: 'object', + properties: { + people: { + type: 'array', + items: { + type: 'object', + properties: { + firstName: { type: 'string' }, + lastName: { type: 'string' }, + age: { type: 'number' }, + }, + required: ['firstName', 'lastName'], + }, + }, + }, + } + + const form = createHeadlessForm(schema) + + // Test array with multiple validation errors in one item + expect(form.handleValidation({ people: [{}] }).formErrors).toEqual({ + people: [{ firstName: 'Required field', lastName: 'Required field' }], + }) + + // Test array with validation errors in different items + expect(form.handleValidation({ + people: [ + { firstName: 'John' }, + { lastName: 'Smith' }, + { firstName: 'Jane', lastName: 'Doe' }, + ], + }).formErrors).toEqual({ + people: [ + { lastName: 'Required field' }, + { firstName: 'Required field' }, + undefined, + ], + }) + }) + + it.skip('handles validation of nested group-arrays', () => { + const schema: JsfObjectSchema = { + type: 'object', + properties: { + departments: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + employees: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + role: { type: 'string' }, + }, + required: ['name', 'role'], + }, + }, + }, + required: ['name'], + }, + }, + }, + } + + const form = createHeadlessForm(schema) + + // Test validation with nested array errors + const data = { + departments: [ + { + name: 'Engineering', + employees: [ + { name: 'Alice' }, // missing role + { name: 'Bob', role: 'Developer' }, // valid + { role: 'Manager' }, // missing name + ], + }, + { + // missing name + employees: [ + { name: 'Charlie', role: 'Designer' }, // valid + ], + }, + ], + } + + const result = form.handleValidation(data) + + expect(result.formErrors).toEqual({ + departments: [ + { + employees: [ + { role: 'Required field' }, + undefined, + { name: 'Required field' }, + ], + }, + { + name: 'Required field', + }, + ], + }) + }) + + it('handles string format validation in array items', () => { + const schema: JsfObjectSchema = { + type: 'object', + properties: { + contacts: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + email: { + 'type': 'string', + 'format': 'email', + 'x-jsf-errorMessage': { + format: 'Please enter a valid email address', + }, + }, + }, + required: ['email'], + }, + }, + }, + } + + const form = createHeadlessForm(schema) + + // Test email format validation in array items + expect(form.handleValidation({ + contacts: [ + { email: 'invalid-email' }, + { email: 'valid@example.com' }, + { email: 'another-invalid' }, + ], + }).formErrors).toEqual({ + contacts: [ + { email: 'Please enter a valid email address' }, + undefined, + { email: 'Please enter a valid email address' }, + ], + }) + }) + + it('handles validation of sparse arrays', () => { + const schema: JsfObjectSchema = { + type: 'object', + properties: { + items: { + type: 'array', + items: { + type: 'object', + properties: { + value: { type: 'string' }, + }, + required: ['value'], + }, + }, + }, + } + + const form = createHeadlessForm(schema) + + // Test array with empty slots (sparse array) + const data = { + items: [], + } + // @ts-expect-error - Creating a sparse array + data.items[0] = { value: 'first' } + // @ts-expect-error - Creating a sparse array + data.items[3] = {} + // @ts-expect-error - Creating a sparse array + data.items[5] = { value: 'last' } + + expect(form.handleValidation(data).formErrors).toEqual({ + items: [ + undefined, + undefined, + undefined, + { value: 'Required field' }, + undefined, + undefined, + ], + }) + }) + + it('handles minItems and maxItems validation for arrays', () => { + const schema: JsfObjectSchema = { + type: 'object', + properties: { + tags: { + 'type': 'array', + 'minItems': 2, + 'maxItems': 4, + 'x-jsf-errorMessage': { + minItems: 'Please add at least 2 tags', + maxItems: 'You cannot add more than 4 tags', + }, + 'items': { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }, + }, + }, + required: ['tags'], + } + + const form = createHeadlessForm(schema) + + // Test minItems validation + expect(form.handleValidation({ tags: [{ name: 'tag1' }] }).formErrors).toEqual({ + tags: 'Please add at least 2 tags', + }) + + // Test maxItems validation + expect(form.handleValidation({ + tags: [ + { name: 'tag1' }, + { name: 'tag2' }, + { name: 'tag3' }, + { name: 'tag4' }, + { name: 'tag5' }, + ], + }).formErrors).toEqual({ + tags: 'You cannot add more than 4 tags', + }) + + // Test valid number of items + expect(form.handleValidation({ + tags: [{ name: 'tag1' }, { name: 'tag2' }], + }).formErrors).toEqual(undefined) + }) + + it('handles validation of arrays with complex conditional validation', () => { + const schema: JsfObjectSchema = { + type: 'object', + properties: { + products: { + type: 'array', + items: { + type: 'object', + properties: { + type: { + type: 'string', + enum: ['physical', 'digital'], + }, + weight: { type: 'number' }, + fileSize: { type: 'number' }, + }, + required: ['type'], + allOf: [ + { + if: { + properties: { type: { const: 'physical' } }, + required: ['type'], + }, + then: { + required: ['weight'], + properties: { + weight: { 'x-jsf-errorMessage': { required: 'Weight is required for physical products' } }, + }, + }, + }, + { + if: { + properties: { type: { const: 'digital' } }, + required: ['type'], + }, + then: { + required: ['fileSize'], + properties: { + fileSize: { 'x-jsf-errorMessage': { required: 'File size is required for digital products' } }, + }, + }, + }, + ], + }, + }, + }, + } + + const form = createHeadlessForm(schema) + + // Test conditional validation in array items + expect(form.handleValidation({ + products: [ + { type: 'physical' }, // missing weight + { type: 'digital', weight: 2 }, // missing fileSize + { type: 'physical', weight: 5 }, // valid + { type: 'digital', fileSize: 100 }, // valid + ], + }).formErrors).toEqual({ + products: [ + { weight: 'Weight is required for physical products' }, + { fileSize: 'File size is required for digital products' }, + undefined, + undefined, + ], + }) + }) + + it('handles uniqueItems validation for arrays', () => { + const schema: JsfObjectSchema = { + type: 'object', + properties: { + colors: { + 'type': 'array', + 'uniqueItems': true, + 'x-jsf-errorMessage': { + uniqueItems: 'All colors must be unique', + }, + 'items': { + type: 'object', + properties: { + code: { type: 'string' }, + }, + required: ['code'], + }, + }, + }, + } + + const form = createHeadlessForm(schema) + + // Test uniqueItems validation - array has duplicate objects (based on value equality) + expect(form.handleValidation({ + colors: [ + { code: 'red' }, + { code: 'blue' }, + { code: 'red' }, // duplicate + ], + }).formErrors).toEqual({ + colors: 'All colors must be unique', + }) + + // Valid case - all items unique + expect(form.handleValidation({ + colors: [ + { code: 'red' }, + { code: 'blue' }, + { code: 'green' }, + ], + }).formErrors).toEqual(undefined) + }) + + it('handles validation of arrays with pattern property errors', () => { + const schema: JsfObjectSchema = { + type: 'object', + properties: { + contacts: { + type: 'array', + items: { + type: 'object', + properties: { + phone: { + 'type': 'string', + 'pattern': '^\\d{3}-\\d{3}-\\d{4}$', + 'x-jsf-errorMessage': { + pattern: 'Phone must be in format: 123-456-7890', + }, + }, + zipCode: { + 'type': 'string', + 'pattern': '^\\d{5}(-\\d{4})?$', + 'x-jsf-errorMessage': { + pattern: 'Invalid zip code format', + }, + }, + }, + }, + }, + }, + } + + const form = createHeadlessForm(schema) + + // Test pattern validation in array items + expect(form.handleValidation({ + contacts: [ + { phone: '123-456-7890', zipCode: '12345' }, // valid + { phone: '1234567890', zipCode: '12345-6789' }, // invalid phone, valid zip + { phone: '123-456-7890', zipCode: '1234' }, // valid phone, invalid zip + ], + }).formErrors).toEqual({ + contacts: [ + undefined, + { phone: 'Phone must be in format: 123-456-7890' }, + { zipCode: 'Invalid zip code format' }, + ], + }) + }) + + it('handles mixed arrays with different types of validation errors', () => { + const schema: JsfObjectSchema = { + type: 'object', + properties: { + mixedData: { + 'type': 'array', + 'minItems': 1, + 'x-jsf-errorMessage': { + minItems: 'At least one item is required', + }, + 'items': { + type: 'object', + properties: { + type: { + 'type': 'string', + 'enum': ['text', 'number', 'boolean'], + 'x-jsf-errorMessage': { + enum: 'Type must be one of: text, number, boolean', + }, + }, + value: { type: 'string' }, + minimum: { type: 'number' }, + maximum: { type: 'number' }, + }, + required: ['type', 'value'], + allOf: [ + { + if: { + properties: { type: { const: 'number' } }, + }, + then: { + properties: { + value: { + 'type': 'number', + 'x-jsf-errorMessage': { + type: 'Value must be a number for number type', + }, + }, + }, + }, + }, + ], + }, + }, + }, + } + + const form = createHeadlessForm(schema) + + // Test empty array validation + expect(form.handleValidation({ mixedData: [] }).formErrors).toEqual({ + mixedData: 'At least one item is required', + }) + + // Test array with mixed validation errors + expect(form.handleValidation({ + mixedData: [ + { type: 'text', value: 'Hello' }, // valid + { type: 'number', value: 'not-a-number' }, // invalid value type for number + { type: 'unknown', value: 'test' }, // invalid enum + { type: 'boolean' }, // missing value + ], + }).formErrors).toEqual({ + mixedData: [ + undefined, + { value: 'Value must be a number for number type' }, + { type: 'Type must be one of: text, number, boolean' }, + { value: 'Required field' }, + ], + }) + }) + + it('propagates schema properties to the field', () => { + const schema: JsfSchema & Record = { + 'type': 'array', + 'label': 'My array', + 'description': 'My array description', + 'x-jsf-presentation': { + inputType: 'group-array', + }, + 'x-custom-prop': 'custom value', + 'foo': 'bar', + 'items': { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }, + } + + const field = buildFieldSchema(schema, 'myArray', true) + + expect(field).toBeDefined() + expect(field?.label).toBe('My array') + expect(field?.description).toBe('My array description') + }) + + it('does not propagate excluded schema properties to the field', () => { + const schema: JsfSchema & Record = { + 'type': 'array', + 'title': 'My array', + 'description': 'My array description', + 'x-jsf-presentation': { + inputType: 'group-array', + }, + 'x-jsf-errorMessage': { + minItems: 'At least one item is required', + }, + 'label': 'My label', + 'minItems': 1, + 'items': { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }, + } + + const field = buildFieldSchema(schema, 'myArray', true) + + expect(field).toBeDefined() + expect(field?.label).toBe('My array') + expect(field?.items).toEqual({ + type: 'object', + properties: { + name: { type: 'string' }, + }, + }) + expect(field?.minItems).toBe(1) + expect(field?.['x-jsf-errorMessage']).toBeUndefined() + expect(field?.['x-jsf-presentation']).toBeUndefined() + }) +}) diff --git a/next/test/validation/array.test.ts b/next/test/validation/array.test.ts index c9c06c6c..24013801 100644 --- a/next/test/validation/array.test.ts +++ b/next/test/validation/array.test.ts @@ -122,6 +122,23 @@ describe('array validation', () => { }) }) + describe('object items', () => { + it('returns no errors for empty arrays', () => { + const schema = { type: 'array', items: { type: 'object' } } + expect(validateSchema([], schema)).toEqual([]) + }) + + it('returns no errors for arrays with objects that match the schema', () => { + const schema = { type: 'array', items: { type: 'object' } } + expect(validateSchema([{ a: 1 }, { b: 2 }], schema)).toEqual([]) + }) + + it('respects the object\'s required properties', () => { + const schema = { type: 'array', items: { type: 'object', required: ['a'] } } + expect(validateSchema([{ a: 1 }, { b: 2 }], schema)).toEqual([errorLike({ path: ['items', 1, 'a'], validation: 'required' })]) + }) + }) + describe('prefixItems', () => { const schema = { type: 'array',