diff --git a/next/src/custom/order.ts b/next/src/custom/order.ts index 19e4ad694..5e50aa83b 100644 --- a/next/src/custom/order.ts +++ b/next/src/custom/order.ts @@ -1,19 +1,14 @@ import type { Field } from '../field/type' import type { JsfSchema } from '../types' -function sort(params: { - fields: Field[] - order: string[] -}): Field[] { - const { fields: prevFields, order } = params - +function sort(fields: Field[], order: string[]): Field[] { // Map from field name to expected index const indexMap: Record = {} order.forEach((key, index) => { indexMap[key] = index }) - const nextFields = prevFields.sort((a, b) => { + const nextFields = fields.sort((a, b) => { // Compare by index const indexA = indexMap[a.name] ?? Infinity const indexB = indexMap[b.name] ?? Infinity @@ -24,7 +19,7 @@ function sort(params: { return indexA - indexB // If not specified, maintain original relative order - return prevFields.indexOf(a) - prevFields.indexOf(b) + return fields.indexOf(a) - fields.indexOf(b) }) return nextFields @@ -33,12 +28,7 @@ function sort(params: { /** * Sort fields by schema's `x-jsf-order` */ -export function setCustomOrder(params: { - schema: JsfSchema - fields: Field[] -}): Field[] { - const { schema, fields } = params - +export function setCustomOrder(schema: JsfSchema, fields: Field[]): Field[] { // TypeScript does not yield if we remove this check, // but it's only because our typing is likely not right. // See internal discussion: @@ -47,7 +37,7 @@ export function setCustomOrder(params: { throw new Error('Schema must be an object') if (schema['x-jsf-order'] !== undefined) - return sort({ fields, order: schema['x-jsf-order'] }) + return sort(fields, schema['x-jsf-order']) return fields } diff --git a/next/src/errors/index.ts b/next/src/errors/index.ts index d124cc794..dccd3cab5 100644 --- a/next/src/errors/index.ts +++ b/next/src/errors/index.ts @@ -11,7 +11,7 @@ export type SchemaValidationErrorType = */ | 'type' | 'required' - | 'valid' + | 'forbidden' | 'const' | 'enum' /** diff --git a/next/src/errors/messages.ts b/next/src/errors/messages.ts index 301eb2212..0a3f1abb6 100644 --- a/next/src/errors/messages.ts +++ b/next/src/errors/messages.ts @@ -20,8 +20,8 @@ export function getErrorMessage( return 'Please acknowledge this field' } return 'Required field' - case 'valid': - return 'Always fails' + case 'forbidden': + return 'Not allowed' case 'const': return `The only accepted value is ${JSON.stringify(schema.const)}.` case 'enum': @@ -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 c1229c9c8..000000000 --- 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 03fcdcc8e..27ae20fb4 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,82 @@ 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(schema, fields) + + 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) + } + } + + const orderedFields = setCustomOrder(schema.items, fields) + + return orderedFields +} + +/** + * 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 +259,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 +270,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 +302,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, + type: inputType, + jsonType: type || schema.type, required, isVisible: true, ...(errorMessage && { errorMessage }), @@ -239,9 +324,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 +336,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 760f6e57e..d65ea6a3a 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 { @@ -30,21 +31,21 @@ export interface ValidationResult { } /** - * Remove composition keywords and their indices as well as conditional keywords from the path - * @param path - The path to clean - * @returns The cleaned path - * @example - * ```ts - * cleanErrorPath(['some_object','allOf', 0, 'then', 'field']) - * // ['some_object', 'field'] - * ``` + * @param path - The path to transform + * @returns The transformed path + * Transforms a validation error path in two ways: + * 1. Removes composition keywords (allOf, anyOf, oneOf) and conditional keywords (then, else) + * 2. Converts array paths by removing "items" keywords but keeping indices + * + * Example: ['some_object','allOf', 0, 'then', 'items', 3, 'field'] -> ['some_object', 3, 'field'] */ -function cleanErrorPath(path: ValidationErrorPath): ValidationErrorPath { - const result: ValidationErrorPath = [] +function transformErrorPath(path: ValidationErrorPath): Array { + const result: Array = [] for (let i = 0; i < path.length; i++) { const segment = path[i] + // Skip composition keywords and their indices if (['allOf', 'anyOf', 'oneOf'].includes(segment as string)) { if (i + 1 < path.length && typeof path[i + 1] === 'number') { i++ @@ -52,17 +53,27 @@ function cleanErrorPath(path: ValidationErrorPath): ValidationErrorPath { continue } + // Skip conditional keywords if (segment === 'then' || segment === 'else') { continue } - result.push(segment) + // Skip 'items' but keep the array index that follows + if (segment === 'items' && typeof path[i + 1] === 'number') { + i++ + result.push(path[i] as number) + } + else { + result.push(segment as string) + } } return result } /** + * @param errors - The validation errors + * @returns The form errors * Transform validation errors into an object with the field names as keys and the error messages as values. * For nested fields, creates a nested object structure rather than using dot notation. * When multiple errors exist for the same field, the last error message is used. @@ -82,45 +93,55 @@ function validationErrorsToFormErrors(errors: ValidationErrorWithMessage[]): For return null } - // Use a more functional approach with reduce - return errors.reduce((result, error) => { + const result: FormErrors = {} + + for (const error of errors) { const { path } = error // Handle schema-level errors (empty path) if (path.length === 0) { result[''] = error.message - return result + continue } - // Clean the path to remove intermediate composition structures - const cleanedPath = cleanErrorPath(path) - - // For all paths, recursively build the nested structure + const segments = transformErrorPath(path) 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] = {} - } + for (let i = 0; i < segments.length - 1; i++) { + const segment = segments[i] - current = current[segment] as FormErrors - }) + if (typeof segment === 'number') { + if (!Array.isArray(current)) { + throw new TypeError(`Expected an array at path: ${segments.slice(0, i).join('.')}`) + } - // Set the message at the final level - if (cleanedPath.length > 0) { - const lastSegment = cleanedPath[cleanedPath.length - 1] - current[lastSegment] = error.message + if (!current[segment]) { + current[segment] = {} + } + + current = current[segment] as FormErrors + } + else { + if (typeof segments[i + 1] === 'number') { + if (!(segment in current)) { + current[segment] = [] + } + } + else if (!(segment in current) || typeof current[segment] === 'string') { + current[segment] = {} + } + + current = current[segment] as FormErrors + } } - else { - // Fallback for unexpected path structures - result[''] = error.message + + if (segments.length > 0) { + const lastSegment = segments[segments.length - 1] + current[lastSegment] = error.message } + } - return result - }, {}) + return result } interface ValidationErrorWithMessage extends ValidationError { @@ -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 048eacc5c..634818ee0 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/src/validation/schema.ts b/next/src/validation/schema.ts index 3aca2af5b..5c9cfc7d9 100644 --- a/next/src/validation/schema.ts +++ b/next/src/validation/schema.ts @@ -195,7 +195,7 @@ export function validateSchema( // Handle boolean schemas if (typeof schema === 'boolean') { - return schema ? [] : [{ path, validation: 'valid', schema, value }] + return schema ? [] : [{ path, validation: 'forbidden', schema, value }] } // Check if it is a file input (needed early for null check) diff --git a/next/test/fields.test.ts b/next/test/fields.test.ts index 0e02eeb13..2c0634326 100644 --- a/next/test/fields.test.ts +++ b/next/test/fields.test.ts @@ -16,8 +16,8 @@ describe('fields', () => { expect(fields).toEqual([ { - type: 'text', inputType: 'text', + type: 'text', jsonType: 'string', name: 'name', label: 'Name', @@ -41,8 +41,8 @@ describe('fields', () => { // Both fields should have the same input type expect(fields).toEqual([ { - type: 'number', inputType: 'number', + type: 'number', jsonType: 'number', name: 'age', label: 'Age', @@ -50,8 +50,8 @@ describe('fields', () => { isVisible: true, }, { - type: 'number', inputType: 'number', + type: 'number', jsonType: 'number', name: 'amount', label: 'Amount', @@ -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', @@ -90,8 +77,8 @@ describe('fields', () => { const field = buildFieldSchema(schema, 'user', false) expect(field).toEqual({ - type: 'fieldset', inputType: 'fieldset', + type: 'fieldset', isVisible: true, name: 'user', label: 'User', @@ -100,8 +87,8 @@ describe('fields', () => { jsonType: 'object', fields: [ { - type: 'text', inputType: 'text', + type: 'text', isVisible: true, jsonType: 'string', name: 'name', @@ -109,8 +96,8 @@ describe('fields', () => { required: true, }, { - type: 'number', inputType: 'number', + type: 'number', isVisible: true, jsonType: 'number', name: 'age', @@ -118,8 +105,8 @@ describe('fields', () => { required: false, }, { - type: 'text', inputType: 'text', + type: 'text', isVisible: true, jsonType: 'string', name: 'email', @@ -150,8 +137,8 @@ describe('fields', () => { expect(fields).toEqual([ { - type: 'file', inputType: 'file', + type: 'file', jsonType: 'string', isVisible: true, name: 'file', @@ -195,8 +182,8 @@ describe('fields', () => { expect(fields).toEqual([ { - type: 'fieldset', inputType: 'fieldset', + type: 'fieldset', isVisible: true, jsonType: 'object', name: 'address', @@ -204,8 +191,8 @@ describe('fields', () => { required: false, fields: [ { - type: 'text', inputType: 'text', + type: 'text', isVisible: true, jsonType: 'string', name: 'street', @@ -213,8 +200,8 @@ describe('fields', () => { required: true, }, { - type: 'text', inputType: 'text', + type: 'text', isVisible: true, jsonType: 'string', name: 'city', @@ -238,8 +225,8 @@ describe('fields', () => { expect(fields).toEqual([ { - type: 'text', inputType: 'text', + type: 'text', jsonType: 'string', name: 'user_email', required: false, @@ -270,8 +257,8 @@ describe('fields', () => { expect(fields).toEqual([ { - type: 'radio', inputType: 'radio', + type: 'radio', jsonType: 'string', isVisible: true, name: 'status', @@ -301,8 +288,8 @@ describe('fields', () => { expect(fields).toEqual([ { - type: 'radio', inputType: 'radio', + type: 'radio', jsonType: undefined, isVisible: true, name: 'status', @@ -341,8 +328,8 @@ describe('fields', () => { expect(fields).toEqual([ { - type: 'radio', inputType: 'radio', + type: 'radio', jsonType: 'string', isVisible: true, name: 'status', @@ -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 000000000..f06bfcd9d --- /dev/null +++ b/next/test/fields/array.test.ts @@ -0,0 +1,852 @@ +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).toEqual({ + inputType: 'group-array', + type: 'group-array', + jsonType: 'array', + isVisible: true, + name: 'root', + required: true, + items: expect.any(Object), + fields: [ + { + inputType: 'text', + type: 'text', + jsonType: 'string', + name: 'name', + isVisible: true, + nameKey: 'name', + required: false, + }, + ], + }) + + expect(field?.items).toEqual({ + type: 'object', + properties: { + name: { type: 'string' }, + }, + }) + }) + + it('respects x-jsf-order', () => { + const schema: JsfSchema = { + 'type': 'array', + 'x-jsf-presentation': { + inputType: 'group-array', + }, + 'items': { + 'type': 'object', + 'properties': { + first_item: { type: 'string' }, + second_item: { type: 'string' }, + third_item: { type: 'string' }, + }, + 'x-jsf-order': ['second_item', 'first_item', 'third_item'], + }, + } + + const field = buildFieldSchema(schema, 'root', true) + + expect(field?.fields?.map(f => f.name)).toEqual(['second_item', 'first_item', 'third_item']) + }) + + it('should handle required arrays', () => { + const schema: JsfSchema = { + type: 'object', + required: ['addresses'], + title: 'Address book', + properties: { + addresses: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + address: { type: 'string' }, + }, + }, + }, + }, + } + + const rootField = buildFieldSchema(schema, 'root', true) + const arrayField = rootField?.fields?.[0] + + expect(arrayField).toEqual({ + inputType: 'group-array', + type: 'group-array', + jsonType: 'array', + isVisible: true, + name: 'addresses', + required: true, + items: expect.any(Object), + fields: [ + { + inputType: 'text', + type: 'text', + jsonType: 'string', + name: 'name', + isVisible: true, + nameKey: 'name', + required: false, + }, + { + inputType: 'text', + type: 'text', + jsonType: 'string', + name: 'address', + isVisible: true, + nameKey: 'address', + required: false, + }, + ], + }) + }) + + it('should handle arrays with object items (fields) inside', () => { + 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).toEqual(expect.objectContaining({ + inputType: 'group-array', + type: 'group-array', + jsonType: 'array', + isVisible: true, + name: 'objectArray', + required: true, + fields: [ + { + inputType: 'text', + type: 'text', + jsonType: 'string', + name: 'name', + label: 'Name', + isVisible: true, + nameKey: 'name', + required: true, + }, + { + inputType: 'number', + type: 'number', + jsonType: 'number', + name: 'age', + label: 'Age', + isVisible: true, + nameKey: 'age', + required: 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).toEqual({ + inputType: 'group-array', + type: 'group-array', + jsonType: 'array', + isVisible: true, + name: 'tasksArray', + label: 'Tasks', + required: true, + foo: 'bar', + bar: 'baz', + description: 'List of tasks to complete', + fields: [ + { + inputType: 'text', + type: 'text', + jsonType: 'string', + name: 'title', + isVisible: true, + nameKey: 'title', + required: false, + }, + ], + items: expect.any(Object), + }) + }) + + 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).toEqual({ + inputType: 'group-array', + type: 'group-array', + jsonType: 'array', + isVisible: true, + name: 'matrix', + label: 'Matrix', + required: true, + items: expect.any(Object), + fields: [ + { + inputType: 'group-array', + type: 'group-array', + jsonType: 'array', + isVisible: true, + name: 'nested', + nameKey: 'nested', + label: 'Nested', + required: false, + items: expect.any(Object), + fields: [ + { + inputType: 'text', + type: 'text', + jsonType: 'string', + name: 'name', + required: false, + nameKey: 'name', + isVisible: true, + }, + ], + }, + ], + }) + }) + + 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('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-jsf-errorMessage': { + minItems: 'At least one item is required', + }, + 'x-custom-prop': 'custom value', + 'foo': 'bar', + 'items': { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }, + } + + const field = buildFieldSchema(schema, 'myArray', true) + + expect(field).toEqual({ + 'inputType': 'group-array', + 'type': 'group-array', + 'jsonType': 'array', + 'label': 'My array', + 'description': 'My array description', + 'foo': 'bar', + 'items': expect.any(Object), + 'x-custom-prop': 'custom value', + 'isVisible': true, + 'name': 'myArray', + 'required': true, + 'errorMessage': { + minItems: 'At least one item is required', + }, + 'fields': [ + { + inputType: 'text', + type: 'text', + jsonType: 'string', + name: 'name', + required: false, + isVisible: true, + nameKey: 'name', + }, + ], + }) + }) + + describe('validation error handling', () => { + 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('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', 'employees'], + }, + }, + }, + } + + const form = createHeadlessForm(schema) + + // Test validation with nested array errors + const data = { + departments: [ + // missing employees + { + name: 'Engineering', + }, + // missing name + { + employees: [ + { name: 'Charlie', role: 'Designer' }, // valid + ], + }, + // Valid + { + name: 'Sales', + employees: [ + { name: 'Alice', role: 'Manager' }, + ], + }, + { + name: 'Customer Support', + employees: [ + { name: 'Bob', role: 'Support Agent' }, // valid + { name: 'Peter' }, // missing role + ], + }, + ], + } + + const result = form.handleValidation(data) + + expect(result.formErrors).toEqual({ + departments: [ + { + employees: 'Required field', + }, + { + name: 'Required field', + }, + undefined, + { + employees: [ + undefined, + { role: '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' }, + ], + }) + }) + }) + + // These do not work with the current group-array API where all groups share the same `fields` property which + // makes it impossible to have different fields for each item in the array. + // This applies to all kinds of mutations such as conditional rendering, default values, etc. and not just titles. + // TODO: Check internal ticket: https://linear.app/remote/issue/RMT-1616/grouparray-hide-conditional-fields + describe.skip('mutation of array items', () => { + // This schema describes a list of animals, where each animal has a kind which is either dog or cat and a name. + // When the kind is dog, the name's title is set to "Dog name" and when the kind is cat, the name's title is set to "Cat name". + const schema: JsfObjectSchema = { + type: 'object', + properties: { + animals: { + type: 'array', + items: { + type: 'object', + required: ['kind', 'name'], + properties: { + kind: { type: 'string', enum: ['dog', 'cat'] }, + name: { type: 'string', title: 'Animal Name' }, + }, + allOf: [ + { + if: { + properties: { kind: { const: 'dog' } }, + required: ['kind'], + }, + then: { + properties: { name: { title: 'Dog Name' } }, + }, + }, + { + if: { + properties: { kind: { const: 'cat' } }, + required: ['kind'], + }, + then: { + properties: { name: { title: 'Cat name' } }, + }, + }, + ], + }, + }, + }, + required: ['animals'], + } + + it('mutates array items correctly when there is only one item', () => { + const form = createHeadlessForm(schema) + + expect(form.handleValidation({ animals: [{ kind: 'dog', name: 'Buddy' }] }).formErrors).toBeUndefined() + expect(form.fields[0]).toMatchObject({ + fields: [ + expect.any(Object), + expect.objectContaining({ + label: 'Dog name', + }), + ], + }) + }) + + it('mutates array items correctly when there are multiple items', () => { + const form = createHeadlessForm(schema) + expect(form.handleValidation({ animals: [{ kind: 'dog', name: 'Buddy' }, { kind: 'cat', name: 'Whiskers' }] }).formErrors).toBeUndefined() + // This creates a form where the two items both mutate the same field and we have no way to distinguish them + // as both will be rendered from the same fields in the `fields` property. + }) + }) +}) diff --git a/next/test/validation/array.test.ts b/next/test/validation/array.test.ts index c9c06c6c4..24013801a 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', diff --git a/next/test/validation/boolean_schema.test.ts b/next/test/validation/boolean_schema.test.ts index 1c1cc6cf9..f56b62eb9 100644 --- a/next/test/validation/boolean_schema.test.ts +++ b/next/test/validation/boolean_schema.test.ts @@ -1,31 +1,17 @@ -import type { JsfObjectSchema } from '../../src/types' import { describe, expect, it } from '@jest/globals' -import { createHeadlessForm } from '../../src' +import { validateSchema } from '../../src/validation/schema' +import { errorLike } from '../test-utils' describe('boolean schema validation', () => { it('returns an error if the value is false', () => { - const schema: JsfObjectSchema = { - type: 'object', - properties: { - name: false, - }, - } - const form = createHeadlessForm(schema) - - expect(form.handleValidation({ name: 'anything' })).toMatchObject({ - formErrors: { name: 'Always fails' }, - }) - expect(form.handleValidation({})).not.toHaveProperty('formErrors') + const schema = { type: 'object', properties: { name: false } } + expect(validateSchema({ name: 'anything' }, schema)).toEqual([errorLike({ path: ['name'], validation: 'forbidden' })]) + expect(validateSchema({}, schema)).toEqual([]) }) it('does not return an error if the value is true', () => { - const schema: JsfObjectSchema = { - type: 'object', - properties: { name: true }, - } - const form = createHeadlessForm(schema) - - expect(form.handleValidation({ name: 'anything' })).not.toHaveProperty('formErrors') - expect(form.handleValidation({})).not.toHaveProperty('formErrors') + const schema = { type: 'object', properties: { name: true } } + expect(validateSchema({ name: 'anything' }, schema)).toEqual([]) + expect(validateSchema({}, schema)).toEqual([]) }) }) diff --git a/next/test/validation/composition.test.ts b/next/test/validation/composition.test.ts index eabe153e6..e56fa2c44 100644 --- a/next/test/validation/composition.test.ts +++ b/next/test/validation/composition.test.ts @@ -104,7 +104,7 @@ describe('schema composition validators', () => { } const errors = validateSchema('foo', schema) expect(errors).toHaveLength(1) - expect(errors[0].validation).toBe('valid') + expect(errors[0].validation).toBe('forbidden') }) it('should fail when all schemas are false', () => { @@ -113,7 +113,7 @@ describe('schema composition validators', () => { } const errors = validateSchema('foo', schema) expect(errors).toHaveLength(1) - expect(errors[0].validation).toBe('valid') + expect(errors[0].validation).toBe('forbidden') }) }) }) diff --git a/src/tests/helpers.js b/src/tests/helpers.js index deb25fd56..783fcdbf5 100644 --- a/src/tests/helpers.js +++ b/src/tests/helpers.js @@ -448,20 +448,19 @@ export const mockGroupArrayInput = { sex: { description: 'We know sex is non-binary but for insurance and payroll purposes, we need to collect this information.', - enum: ['female', 'male'], 'x-jsf-presentation': { inputType: 'radio', - options: [ - { - label: 'Male', - value: 'male', - }, - { - label: 'Female', - value: 'female', - }, - ], }, + oneOf: [ + { + const: 'male', + title: 'Male', + }, + { + const: 'female', + title: 'Female', + }, + ], title: 'Child Sex', type: 'string', },