From 75d4a079d7f10f53b9cdfe20bbbba16b770e27af Mon Sep 17 00:00:00 2001 From: Capelo Date: Wed, 28 May 2025 10:42:35 +0100 Subject: [PATCH 01/41] fix: small fixes for backward compatibility --- next/src/errors/messages.ts | 22 ++++++++++++++++++++-- next/src/field/schema.ts | 2 +- next/src/field/type.ts | 3 ++- src/tests/helpers.custom.js | 2 +- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/next/src/errors/messages.ts b/next/src/errors/messages.ts index 0856c459b..dfe83e11a 100644 --- a/next/src/errors/messages.ts +++ b/next/src/errors/messages.ts @@ -4,6 +4,20 @@ import { randexp } from 'randexp' import { convertKBToMB } from '../utils' import { DATE_FORMAT } from '../validation/custom/date' +/** + * Check if the schema is a checkbox + * @param schema - The schema to check + * @returns True if the schema is a checkbox, false otherwise + */ +function isCheckbox(schema: NonBooleanJsfSchema): boolean { + return schema['x-jsf-presentation']?.inputType === 'checkbox' +} + +const CHECKBOX_ERROR_MESSAGES = { + required: 'Please acknowledge this field', + const: 'Please acknowledge this field', +} + export function getErrorMessage( schema: NonBooleanJsfSchema, value: SchemaValue, @@ -16,13 +30,17 @@ export function getErrorMessage( case 'type': return getTypeErrorMessage(schema.type) case 'required': - if (schema['x-jsf-presentation']?.inputType === 'checkbox') { - return 'Please acknowledge this field' + if (isCheckbox(schema)) { + return CHECKBOX_ERROR_MESSAGES.required } return 'Required field' case 'forbidden': return 'Not allowed' case 'const': + // Boolean checkboxes that are required will come as a "const" validation error as the "empty" value is false + if (isCheckbox(schema) && value === false) { + return CHECKBOX_ERROR_MESSAGES.const + } return `The only accepted value is ${JSON.stringify(schema.const)}.` case 'enum': return `The option "${valueToString(value)}" is not valid.` diff --git a/next/src/field/schema.ts b/next/src/field/schema.ts index af0307397..561591ea5 100644 --- a/next/src/field/schema.ts +++ b/next/src/field/schema.ts @@ -344,7 +344,7 @@ export function buildFieldSchema( type: inputType, jsonType: type || schema.type, required, - isVisible: true, + isVisible: inputType !== 'hidden', ...(errorMessage && { errorMessage }), } diff --git a/next/src/field/type.ts b/next/src/field/type.ts index 7e73c9d28..f62605f01 100644 --- a/next/src/field/type.ts +++ b/next/src/field/type.ts @@ -27,6 +27,7 @@ export interface Field { options?: unknown[] const?: unknown checkboxValue?: unknown + default?: unknown // Allow additional properties from x-jsf-presentation (e.g. meta from oneOf/anyOf) [key: string]: unknown @@ -44,4 +45,4 @@ export interface FieldOption { [key: string]: unknown } -export type FieldType = 'text' | 'number' | 'select' | 'file' | 'radio' | 'group-array' | 'email' | 'date' | 'checkbox' | 'fieldset' | 'money' | 'country' | 'textarea' +export type FieldType = 'text' | 'number' | 'select' | 'file' | 'radio' | 'group-array' | 'email' | 'date' | 'checkbox' | 'fieldset' | 'money' | 'country' | 'textarea' | 'hidden' diff --git a/src/tests/helpers.custom.js b/src/tests/helpers.custom.js index 634b935ca..f6a5b6cf5 100644 --- a/src/tests/helpers.custom.js +++ b/src/tests/helpers.custom.js @@ -205,7 +205,7 @@ export const schemaInputTypeHidden = { a_hidden_select_multiple: { ...schemaInputTypeCountriesMultiple.properties.nationality, title: 'Select multi hidden', - default: ['Albania, Algeria'], + default: ['Albania', 'Algeria'], 'x-jsf-presentation': { inputType: 'hidden' }, type: 'array', }, From d9583a28b3413d6c0e05ecf4ffb583f1228be1ab Mon Sep 17 00:00:00 2001 From: Capelo Date: Wed, 28 May 2025 11:57:40 +0100 Subject: [PATCH 02/41] chore: maybe this refactor makes sense --- next/src/form.ts | 3 +- next/src/validation/json-logic.ts | 124 ++++++++++++++++++++++-- next/test/validation/json-logic.test.ts | 2 +- 3 files changed, 121 insertions(+), 8 deletions(-) diff --git a/next/src/form.ts b/next/src/form.ts index da7bed8cf..51e27b039 100644 --- a/next/src/form.ts +++ b/next/src/form.ts @@ -255,13 +255,14 @@ export function createHeadlessForm( const handleValidation = (value: SchemaValue) => { const updatedSchema = applyComputedAttrsToSchema(schema, schema['x-jsf-logic']?.computedValues, value) + console.log('updatedSchema', updatedSchema) const result = validate(value, updatedSchema, options.validationOptions) // Fields properties might have changed, so we need to reset the fields by updating them in place buildFieldsInPlace(fields, updatedSchema) // Updating field properties based on the new form value - mutateFields(fields, value, updatedSchema, options.validationOptions) + // mutateFields(fields, value, updatedSchema, options.validationOptions) return result } diff --git a/next/src/validation/json-logic.ts b/next/src/validation/json-logic.ts index 5577df530..ffdbc812e 100644 --- a/next/src/validation/json-logic.ts +++ b/next/src/validation/json-logic.ts @@ -1,8 +1,10 @@ import type { RulesLogic } from 'json-logic-js' import type { ValidationError, ValidationErrorPath } from '../errors' import type { JsfObjectSchema, JsfSchema, JsonLogicContext, JsonLogicRules, JsonLogicSchema, NonBooleanJsfSchema, ObjectValue, SchemaValue } from '../types' +import type { ValidationOptions } from './schema' import jsonLogic from 'json-logic-js' -import { safeDeepClone } from './util' +import { validateSchema } from './schema' +import { isObjectValue, safeDeepClone } from './util' /** * Builds a json-logic context based on a schema and the current value @@ -137,6 +139,10 @@ export function applyComputedAttrsToSchema(schema: JsfObjectSchema, computedValu // - calculate all the computed values // - apply the computed values to the cloned schema // Otherwise, we return the original schema + const schemaCopy = safeDeepClone(schema) + + magic(schemaCopy, values, {}, undefined) + if (computedValuesDefinition) { const computedValues: Record = {} @@ -145,15 +151,121 @@ export function applyComputedAttrsToSchema(schema: JsfObjectSchema, computedValu computedValues[name] = computedValue }) - const schemaCopy = safeDeepClone(schema) - cycleThroughPropertiesAndApplyValues(schemaCopy, computedValues) + } + + return schemaCopy +} - return schemaCopy +function magic(schema: JsfObjectSchema, values: SchemaValue, options: ValidationOptions = {}, jsonLogicContext: JsonLogicContext | undefined) { + applySchemaRules(schema, values, options, jsonLogicContext) +} + +function deepMerge>(obj1: T, obj2: T): void { + // Handle null/undefined + if (!obj1 || !obj2) + return + + // Handle arrays + if (Array.isArray(obj1) && Array.isArray(obj2)) { + obj1.push(...obj2) + return } - else { - return schema + + // Handle non-objects + if (typeof obj1 !== 'object' || typeof obj2 !== 'object') + return + + // Merge all properties from obj2 into obj1 + for (const [key, value] of Object.entries(obj2)) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + // If both objects have this key and it's an object, merge recursively + if (obj1[key] && typeof obj1[key] === 'object' && !Array.isArray(obj1[key])) { + deepMerge(obj1[key], value) + } + else { + // Otherwise just assign + obj1[key as keyof T] = value + } + } + else { + // For non-objects (including arrays), just assign + obj1[key as keyof T] = value + } + } +} + +function evaluateConditional( + values: ObjectValue, + schema: JsfObjectSchema, + rule: NonBooleanJsfSchema, + options: ValidationOptions = {}, +) { + const ifErrors = validateSchema(values, rule.if!, options) + const matches = ifErrors.length === 0 + + // Prevent fields from being shown when required fields have type errors + let hasTypeErrors = false + if (matches && rule.if?.required) { + const requiredFields = rule.if.required + hasTypeErrors = requiredFields.some((fieldName) => { + if (!schema.properties || !schema.properties[fieldName]) { + return false + } + const fieldSchema = schema.properties[fieldName] + const fieldValue = values[fieldName] + const fieldErrors = validateSchema(fieldValue, fieldSchema, options) + return fieldErrors.some(error => error.validation === 'type') + }) } + + return { rule, matches: matches && !hasTypeErrors } +} + +function applySchemaRules( + schema: JsfObjectSchema, + values: SchemaValue, + options: ValidationOptions = {}, + jsonLogicContext: JsonLogicContext | undefined, +) { + if (!isObjectValue(values)) { + return + } + + const conditionalRules: { rule: NonBooleanJsfSchema, matches: boolean }[] = [] + + // If the schema has an if property, evaluate it and add it to the conditional rules array + if (schema.if) { + conditionalRules.push(evaluateConditional(values, schema, schema, options)) + } + + // If the schema has an allOf property, evaluate each rule and add it to the conditional rules array + (schema.allOf ?? []) + .filter((rule: JsfSchema) => typeof rule.if !== 'undefined') + .forEach((rule) => { + const result = evaluateConditional(values, schema, rule as NonBooleanJsfSchema, options) + conditionalRules.push(result) + }) + + // Process the conditional rules + for (const { rule, matches } of conditionalRules) { + // If the rule matches, process the then branch + if (matches && rule.then) { + processBranch(schema, values, rule.then, options, jsonLogicContext) + } + // If the rule doesn't match, process the else branch + else if (!matches && rule.else) { + processBranch(schema, values, rule.else, options, jsonLogicContext) + } + } +} + +function processBranch(schema: JsfObjectSchema, values: SchemaValue, branch: JsfSchema, options: ValidationOptions = {}, jsonLogicContext: JsonLogicContext | undefined) { + applySchemaRules(branch as JsfObjectSchema, values, options, jsonLogicContext) + deepMerge(schema, branch as JsfObjectSchema) + + // Apply rules to the branch + // applySchemaRules(schema, values, options, jsonLogicContext) } /** diff --git a/next/test/validation/json-logic.test.ts b/next/test/validation/json-logic.test.ts index c655fb61d..69614e3f1 100644 --- a/next/test/validation/json-logic.test.ts +++ b/next/test/validation/json-logic.test.ts @@ -365,7 +365,7 @@ describe('applyComputedAttrsToSchema', () => { } const result = JsonLogicValidation.applyComputedAttrsToSchema(schema, schema['x-jsf-logic']?.computedValues, {}) - expect(result).toBe(schema) + expect(result).toEqual(schema) }) it('applies computed values to schema properties', () => { From 422217b6e73dbff40a1345fbe91fa7aafef8846a Mon Sep 17 00:00:00 2001 From: Capelo Date: Wed, 28 May 2025 18:16:10 +0100 Subject: [PATCH 03/41] wip --- next/src/form.ts | 34 +++++++++++++-------------- next/src/utils.ts | 39 +++++++++++++++++++++++++++++++ next/src/validation/json-logic.ts | 35 +-------------------------- 3 files changed, 56 insertions(+), 52 deletions(-) diff --git a/next/src/form.ts b/next/src/form.ts index 51e27b039..836636905 100644 --- a/next/src/form.ts +++ b/next/src/form.ts @@ -5,6 +5,7 @@ import type { ValidationOptions } from './validation/schema' import { getErrorMessage } from './errors/messages' import { buildFieldSchema } from './field/schema' import { mutateFields } from './mutations' +import { deepMerge } from './utils' import { applyComputedAttrsToSchema } from './validation/json-logic' import { validateSchema } from './validation/schema' @@ -247,22 +248,15 @@ export function createHeadlessForm( const updatedSchema = applyComputedAttrsToSchema(schema, schema['x-jsf-logic']?.computedValues, initialValues) const fields = buildFields({ schema: updatedSchema, strictInputType }) - // Making sure field properties are correct for the initial values - mutateFields(fields, initialValues, updatedSchema, options.validationOptions) - // TODO: check if we need this isError variable exposed const isError = false const handleValidation = (value: SchemaValue) => { const updatedSchema = applyComputedAttrsToSchema(schema, schema['x-jsf-logic']?.computedValues, value) - console.log('updatedSchema', updatedSchema) const result = validate(value, updatedSchema, options.validationOptions) // Fields properties might have changed, so we need to reset the fields by updating them in place - buildFieldsInPlace(fields, updatedSchema) - - // Updating field properties based on the new form value - // mutateFields(fields, value, updatedSchema, options.validationOptions) + updateFieldProperties(fields, updatedSchema) return result } @@ -280,22 +274,26 @@ export function createHeadlessForm( * @param fields - The fields array to mutate * @param schema - The schema to use for updating fields */ -function buildFieldsInPlace(fields: Field[], schema: JsfObjectSchema): void { +function updateFieldProperties(fields: Field[], schema: JsfObjectSchema): void { // Clear existing fields array - fields.length = 0 + // fields.length = 0 // Get new fields from schema const newFields = buildFieldSchema(schema, 'root', true, false, 'object')?.fields || [] - // Push all new fields into existing array - fields.push(...newFields) - - // Recursively update any nested fields + // cycle through the original fields and merge the new fields with the original fields for (const field of fields) { - // eslint-disable-next-line ts/ban-ts-comment - // @ts-expect-error - if (field.fields && schema.properties?.[field.name]?.type === 'object') { - buildFieldsInPlace(field.fields, schema.properties[field.name] as JsfObjectSchema) + const newField = newFields.find(f => f.name === field.name) + if (newField) { + deepMerge(field, newField) + + const fieldSchema = schema.properties?.[field.name] + + if (fieldSchema && typeof fieldSchema === 'object') { + if (field.fields && fieldSchema.type === 'object') { + updateFieldProperties(field.fields, fieldSchema as JsfObjectSchema) + } + } } } } diff --git a/next/src/utils.ts b/next/src/utils.ts index 2d78d6e5e..5e7932566 100644 --- a/next/src/utils.ts +++ b/next/src/utils.ts @@ -51,3 +51,42 @@ export function convertKBToMB(kb: number): number { const mb = kb / 1024 // KB to MB return Number.parseFloat(mb.toFixed(2)) // Keep 2 decimal places } + +/** + * Merges two objects recursively, regardless of the depth of the objects + * @param obj1 - The first object to merge + * @param obj2 - The second object to merge + */ +export function deepMerge>(obj1: T, obj2: T): void { + // Handle null/undefined + if (!obj1 || !obj2) + return + + // Handle arrays + if (Array.isArray(obj1) && Array.isArray(obj2)) { + obj1.push(...obj2) + return + } + + // Handle non-objects + if (typeof obj1 !== 'object' || typeof obj2 !== 'object') + return + + // Merge all properties from obj2 into obj1 + for (const [key, value] of Object.entries(obj2)) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + // If both objects have this key and it's an object, merge recursively + if (obj1[key] && typeof obj1[key] === 'object' && !Array.isArray(obj1[key])) { + deepMerge(obj1[key], value) + } + else { + // Otherwise just assign + obj1[key as keyof T] = value + } + } + else { + // For non-objects (including arrays), just assign + obj1[key as keyof T] = value + } + } +} diff --git a/next/src/validation/json-logic.ts b/next/src/validation/json-logic.ts index ffdbc812e..0c1615680 100644 --- a/next/src/validation/json-logic.ts +++ b/next/src/validation/json-logic.ts @@ -3,6 +3,7 @@ import type { ValidationError, ValidationErrorPath } from '../errors' import type { JsfObjectSchema, JsfSchema, JsonLogicContext, JsonLogicRules, JsonLogicSchema, NonBooleanJsfSchema, ObjectValue, SchemaValue } from '../types' import type { ValidationOptions } from './schema' import jsonLogic from 'json-logic-js' +import { deepMerge } from '../utils' import { validateSchema } from './schema' import { isObjectValue, safeDeepClone } from './util' @@ -161,40 +162,6 @@ function magic(schema: JsfObjectSchema, values: SchemaValue, options: Validation applySchemaRules(schema, values, options, jsonLogicContext) } -function deepMerge>(obj1: T, obj2: T): void { - // Handle null/undefined - if (!obj1 || !obj2) - return - - // Handle arrays - if (Array.isArray(obj1) && Array.isArray(obj2)) { - obj1.push(...obj2) - return - } - - // Handle non-objects - if (typeof obj1 !== 'object' || typeof obj2 !== 'object') - return - - // Merge all properties from obj2 into obj1 - for (const [key, value] of Object.entries(obj2)) { - if (value && typeof value === 'object' && !Array.isArray(value)) { - // If both objects have this key and it's an object, merge recursively - if (obj1[key] && typeof obj1[key] === 'object' && !Array.isArray(obj1[key])) { - deepMerge(obj1[key], value) - } - else { - // Otherwise just assign - obj1[key as keyof T] = value - } - } - else { - // For non-objects (including arrays), just assign - obj1[key as keyof T] = value - } - } -} - function evaluateConditional( values: ObjectValue, schema: JsfObjectSchema, From e47acce5e95f06ab9337810b08d934bffe7f668d Mon Sep 17 00:00:00 2001 From: Capelo Date: Wed, 28 May 2025 18:22:48 +0100 Subject: [PATCH 04/41] chore: wip 2 --- next/src/form.ts | 31 +------- next/src/mutations.ts | 116 +++++++++++------------------- next/src/validation/json-logic.ts | 94 +----------------------- 3 files changed, 45 insertions(+), 196 deletions(-) diff --git a/next/src/form.ts b/next/src/form.ts index 836636905..2bd863ba7 100644 --- a/next/src/form.ts +++ b/next/src/form.ts @@ -4,7 +4,7 @@ import type { JsfObjectSchema, JsfSchema, SchemaValue } from './types' import type { ValidationOptions } from './validation/schema' import { getErrorMessage } from './errors/messages' import { buildFieldSchema } from './field/schema' -import { mutateFields } from './mutations' +import { mutateFields, updateFieldProperties } from './mutations' import { deepMerge } from './utils' import { applyComputedAttrsToSchema } from './validation/json-logic' import { validateSchema } from './validation/schema' @@ -268,32 +268,3 @@ export function createHeadlessForm( handleValidation, } } - -/** - * Updates fields in place based on a schema, recursively if needed - * @param fields - The fields array to mutate - * @param schema - The schema to use for updating fields - */ -function updateFieldProperties(fields: Field[], schema: JsfObjectSchema): void { - // Clear existing fields array - // fields.length = 0 - - // Get new fields from schema - const newFields = buildFieldSchema(schema, 'root', true, false, 'object')?.fields || [] - - // cycle through the original fields and merge the new fields with the original fields - for (const field of fields) { - const newField = newFields.find(f => f.name === field.name) - if (newField) { - deepMerge(field, newField) - - const fieldSchema = schema.properties?.[field.name] - - if (fieldSchema && typeof fieldSchema === 'object') { - if (field.fields && fieldSchema.type === 'object') { - updateFieldProperties(field.fields, fieldSchema as JsfObjectSchema) - } - } - } - } -} diff --git a/next/src/mutations.ts b/next/src/mutations.ts index 637df31f3..f82dfdaa6 100644 --- a/next/src/mutations.ts +++ b/next/src/mutations.ts @@ -1,44 +1,27 @@ import type { Field } from './field/type' -import type { JsfObjectSchema, JsfSchema, JsonLogicContext, NonBooleanJsfSchema, ObjectValue, SchemaValue } from './types' +import type { JsfObjectSchema, JsfSchema, JsonLogicContext, JsonLogicRules, NonBooleanJsfSchema, ObjectValue, SchemaValue } from './types' import type { ValidationOptions } from './validation/schema' import { buildFieldSchema } from './field/schema' +import { deepMerge } from './utils' import { applyComputedAttrsToSchema, getJsonLogicContextFromSchema } from './validation/json-logic' import { validateSchema } from './validation/schema' -import { isObjectValue } from './validation/util' +import { isObjectValue, safeDeepClone } from './validation/util' /** - * Updates field properties based on JSON schema conditional rules - * @param fields - The fields to update + * Mutates a schema to take into account the computed values and the conditional rules + * @param schema + * @param computedValuesDefinition - The computed values definition * @param values - The current form values - * @param schema - The JSON schema definition - * @param options - Validation options + * @returns The mutated schema */ -export function mutateFields( - fields: Field[], - values: SchemaValue, - schema: JsfObjectSchema, - options: ValidationOptions = {}, -) { - if (!isObjectValue(values)) { - return - } - - // We should get the json-logic context from the schema in case we need to mutate fields using computed values - const jsonLogicSchema = schema['x-jsf-logic'] - const jsonLogicContext = jsonLogicSchema ? getJsonLogicContextFromSchema(jsonLogicSchema, values) : undefined +export function mutateSchema(schema: JsfObjectSchema, computedValuesDefinition: JsonLogicRules['computedValues'], values: SchemaValue, options: ValidationOptions = {}, jsonLogicContext: JsonLogicContext | undefined): JsfObjectSchema { + const schemaCopy = safeDeepClone(schema) - // Apply schema rules to current level of fields - applySchemaRules(fields, values, schema, options, jsonLogicContext) + applySchemaRules(schema, values, options, jsonLogicContext) - // Process nested object fields that have conditional logic - for (const fieldName in schema.properties) { - const fieldSchema = schema.properties[fieldName] - const field = fields.find(field => field.name === fieldName) + applyComputedAttrsToSchema(schema, computedValuesDefinition, values) - if (field?.fields) { - applySchemaRules(field.fields, values[fieldName], fieldSchema as JsfObjectSchema, options, jsonLogicContext) - } - } + return schemaCopy } /** @@ -85,9 +68,8 @@ function evaluateConditional( * @param jsonLogicContext - JSON Logic context */ function applySchemaRules( - fields: Field[], - values: SchemaValue, schema: JsfObjectSchema, + values: SchemaValue, options: ValidationOptions = {}, jsonLogicContext: JsonLogicContext | undefined, ) { @@ -114,11 +96,11 @@ function applySchemaRules( for (const { rule, matches } of conditionalRules) { // If the rule matches, process the then branch if (matches && rule.then) { - processBranch(fields, values, rule.then, options, jsonLogicContext) + processBranch(schema, values, rule.then, options, jsonLogicContext) } // If the rule doesn't match, process the else branch else if (!matches && rule.else) { - processBranch(fields, values, rule.else, options, jsonLogicContext) + processBranch(schema, values, rule.else, options, jsonLogicContext) } } } @@ -131,50 +113,36 @@ function applySchemaRules( * @param options - Validation options * @param jsonLogicContext - JSON Logic context */ -function processBranch(fields: Field[], values: SchemaValue, branch: JsfSchema, options: ValidationOptions = {}, jsonLogicContext: JsonLogicContext | undefined) { - if (branch.properties) { - // Cycle through each property in the schema and search for any property that needs - // to be updated in the fields collection. - for (const fieldName in branch.properties) { - let fieldSchema = branch.properties[fieldName] - - // If the field schema has computed attributes, we need to apply them - if (fieldSchema['x-jsf-logic-computedAttrs']) { - fieldSchema = applyComputedAttrsToSchema(fieldSchema as JsfObjectSchema, jsonLogicContext?.schema.computedValues, values) - } +function processBranch(schema: JsfObjectSchema, values: SchemaValue, branch: JsfSchema, options: ValidationOptions = {}, jsonLogicContext: JsonLogicContext | undefined) { + applySchemaRules(branch as JsfObjectSchema, values, options, jsonLogicContext) + deepMerge(schema, branch as JsfObjectSchema) +} - const field = fields.find(e => e.name === fieldName) - if (field) { - // If the field has a false schema, it should be removed from the form (hidden) - if (fieldSchema === false) { - field.isVisible = false - } - // If the field has inner fields, we need to process them - else if (field?.fields) { - processBranch(field.fields, values, fieldSchema, options, jsonLogicContext) - } - // 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, false) - for (const key in newField) { - // We don't want to override the type property - if (!['type'].includes(key)) { - field[key] = newField[key] - } +/** + * Updates fields in place based on a schema, recursively if needed + * @param fields - The fields array to mutate + * @param schema - The schema to use for updating fields + */ +export function updateFieldProperties(fields: Field[], schema: JsfObjectSchema): void { + // Clear existing fields array + // fields.length = 0 + + // Get new fields from schema + const newFields = buildFieldSchema(schema, 'root', true, false, 'object')?.fields || [] + + // cycle through the original fields and merge the new fields with the original fields + for (const field of fields) { + const newField = newFields.find(f => f.name === field.name) + if (newField) { + deepMerge(field, newField) + + const fieldSchema = schema.properties?.[field.name] + + if (fieldSchema && typeof fieldSchema === 'object') { + if (field.fields && fieldSchema.type === 'object') { + updateFieldProperties(field.fields, fieldSchema as JsfObjectSchema) } } } } - - // Go through the `required` array and mark all fields included in the array as required - if (Array.isArray(branch.required)) { - fields.forEach((field) => { - if (branch.required!.includes(field.name)) { - field.required = true - } - }) - } - - // Apply rules to the branch - applySchemaRules(fields, values, branch as JsfObjectSchema, options, jsonLogicContext) } diff --git a/next/src/validation/json-logic.ts b/next/src/validation/json-logic.ts index 0c1615680..79668f528 100644 --- a/next/src/validation/json-logic.ts +++ b/next/src/validation/json-logic.ts @@ -1,11 +1,7 @@ import type { RulesLogic } from 'json-logic-js' import type { ValidationError, ValidationErrorPath } from '../errors' import type { JsfObjectSchema, JsfSchema, JsonLogicContext, JsonLogicRules, JsonLogicSchema, NonBooleanJsfSchema, ObjectValue, SchemaValue } from '../types' -import type { ValidationOptions } from './schema' import jsonLogic from 'json-logic-js' -import { deepMerge } from '../utils' -import { validateSchema } from './schema' -import { isObjectValue, safeDeepClone } from './util' /** * Builds a json-logic context based on a schema and the current value @@ -135,15 +131,6 @@ export function computePropertyValues( * @returns The schema with computed attributes applied */ export function applyComputedAttrsToSchema(schema: JsfObjectSchema, computedValuesDefinition: JsonLogicRules['computedValues'], values: SchemaValue): JsfObjectSchema { - // If the schema has any computed attributes, we need to: - // - clone the original schema - // - calculate all the computed values - // - apply the computed values to the cloned schema - // Otherwise, we return the original schema - const schemaCopy = safeDeepClone(schema) - - magic(schemaCopy, values, {}, undefined) - if (computedValuesDefinition) { const computedValues: Record = {} @@ -152,87 +139,10 @@ export function applyComputedAttrsToSchema(schema: JsfObjectSchema, computedValu computedValues[name] = computedValue }) - cycleThroughPropertiesAndApplyValues(schemaCopy, computedValues) - } - - return schemaCopy -} - -function magic(schema: JsfObjectSchema, values: SchemaValue, options: ValidationOptions = {}, jsonLogicContext: JsonLogicContext | undefined) { - applySchemaRules(schema, values, options, jsonLogicContext) -} - -function evaluateConditional( - values: ObjectValue, - schema: JsfObjectSchema, - rule: NonBooleanJsfSchema, - options: ValidationOptions = {}, -) { - const ifErrors = validateSchema(values, rule.if!, options) - const matches = ifErrors.length === 0 - - // Prevent fields from being shown when required fields have type errors - let hasTypeErrors = false - if (matches && rule.if?.required) { - const requiredFields = rule.if.required - hasTypeErrors = requiredFields.some((fieldName) => { - if (!schema.properties || !schema.properties[fieldName]) { - return false - } - const fieldSchema = schema.properties[fieldName] - const fieldValue = values[fieldName] - const fieldErrors = validateSchema(fieldValue, fieldSchema, options) - return fieldErrors.some(error => error.validation === 'type') - }) - } - - return { rule, matches: matches && !hasTypeErrors } -} - -function applySchemaRules( - schema: JsfObjectSchema, - values: SchemaValue, - options: ValidationOptions = {}, - jsonLogicContext: JsonLogicContext | undefined, -) { - if (!isObjectValue(values)) { - return - } - - const conditionalRules: { rule: NonBooleanJsfSchema, matches: boolean }[] = [] - - // If the schema has an if property, evaluate it and add it to the conditional rules array - if (schema.if) { - conditionalRules.push(evaluateConditional(values, schema, schema, options)) - } - - // If the schema has an allOf property, evaluate each rule and add it to the conditional rules array - (schema.allOf ?? []) - .filter((rule: JsfSchema) => typeof rule.if !== 'undefined') - .forEach((rule) => { - const result = evaluateConditional(values, schema, rule as NonBooleanJsfSchema, options) - conditionalRules.push(result) - }) - - // Process the conditional rules - for (const { rule, matches } of conditionalRules) { - // If the rule matches, process the then branch - if (matches && rule.then) { - processBranch(schema, values, rule.then, options, jsonLogicContext) - } - // If the rule doesn't match, process the else branch - else if (!matches && rule.else) { - processBranch(schema, values, rule.else, options, jsonLogicContext) - } + cycleThroughPropertiesAndApplyValues(schema, computedValues) } -} - -function processBranch(schema: JsfObjectSchema, values: SchemaValue, branch: JsfSchema, options: ValidationOptions = {}, jsonLogicContext: JsonLogicContext | undefined) { - applySchemaRules(branch as JsfObjectSchema, values, options, jsonLogicContext) - deepMerge(schema, branch as JsfObjectSchema) - // Apply rules to the branch - // applySchemaRules(schema, values, options, jsonLogicContext) + return schema } /** From 789774e36b8815073ca920058ebfabac42690c9c Mon Sep 17 00:00:00 2001 From: Capelo Date: Thu, 29 May 2025 10:34:01 +0100 Subject: [PATCH 05/41] fix: fix wrong variable in the mutateSchema fn and fix 1 test --- next/src/form.ts | 7 +++---- next/src/mutations.ts | 14 +++++++------- next/src/validation/json-logic.ts | 2 +- next/test/validation/json-logic.test.ts | 4 +++- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/next/src/form.ts b/next/src/form.ts index 2bd863ba7..1363583eb 100644 --- a/next/src/form.ts +++ b/next/src/form.ts @@ -4,8 +4,7 @@ import type { JsfObjectSchema, JsfSchema, SchemaValue } from './types' import type { ValidationOptions } from './validation/schema' import { getErrorMessage } from './errors/messages' import { buildFieldSchema } from './field/schema' -import { mutateFields, updateFieldProperties } from './mutations' -import { deepMerge } from './utils' +import { mutateSchema, updateFieldProperties } from './mutations' import { applyComputedAttrsToSchema } from './validation/json-logic' import { validateSchema } from './validation/schema' @@ -245,14 +244,14 @@ export function createHeadlessForm( const initialValues = options.initialValues || {} const strictInputType = options.strictInputType || false // Make a (new) version with all the computed attrs computed and applied - const updatedSchema = applyComputedAttrsToSchema(schema, schema['x-jsf-logic']?.computedValues, initialValues) + const updatedSchema = mutateSchema(schema, schema['x-jsf-logic']?.computedValues, initialValues, options.validationOptions, undefined) const fields = buildFields({ schema: updatedSchema, strictInputType }) // TODO: check if we need this isError variable exposed const isError = false const handleValidation = (value: SchemaValue) => { - const updatedSchema = applyComputedAttrsToSchema(schema, schema['x-jsf-logic']?.computedValues, value) + const updatedSchema = mutateSchema(schema, schema['x-jsf-logic']?.computedValues, value, options.validationOptions, undefined) const result = validate(value, updatedSchema, options.validationOptions) // Fields properties might have changed, so we need to reset the fields by updating them in place diff --git a/next/src/mutations.ts b/next/src/mutations.ts index f82dfdaa6..1ddebe204 100644 --- a/next/src/mutations.ts +++ b/next/src/mutations.ts @@ -17,9 +17,9 @@ import { isObjectValue, safeDeepClone } from './validation/util' export function mutateSchema(schema: JsfObjectSchema, computedValuesDefinition: JsonLogicRules['computedValues'], values: SchemaValue, options: ValidationOptions = {}, jsonLogicContext: JsonLogicContext | undefined): JsfObjectSchema { const schemaCopy = safeDeepClone(schema) - applySchemaRules(schema, values, options, jsonLogicContext) + applySchemaRules(schemaCopy, values, options, jsonLogicContext) - applyComputedAttrsToSchema(schema, computedValuesDefinition, values) + applyComputedAttrsToSchema(schemaCopy, computedValuesDefinition, values) return schemaCopy } @@ -114,8 +114,10 @@ function applySchemaRules( * @param jsonLogicContext - JSON Logic context */ function processBranch(schema: JsfObjectSchema, values: SchemaValue, branch: JsfSchema, options: ValidationOptions = {}, jsonLogicContext: JsonLogicContext | undefined) { - applySchemaRules(branch as JsfObjectSchema, values, options, jsonLogicContext) - deepMerge(schema, branch as JsfObjectSchema) + const branchSchema = branch as JsfObjectSchema + + applySchemaRules(branchSchema, values, options, jsonLogicContext) + deepMerge(schema, branchSchema) } /** @@ -124,15 +126,13 @@ function processBranch(schema: JsfObjectSchema, values: SchemaValue, branch: Jsf * @param schema - The schema to use for updating fields */ export function updateFieldProperties(fields: Field[], schema: JsfObjectSchema): void { - // Clear existing fields array - // fields.length = 0 - // Get new fields from schema const newFields = buildFieldSchema(schema, 'root', true, false, 'object')?.fields || [] // cycle through the original fields and merge the new fields with the original fields for (const field of fields) { const newField = newFields.find(f => f.name === field.name) + if (newField) { deepMerge(field, newField) diff --git a/next/src/validation/json-logic.ts b/next/src/validation/json-logic.ts index 79668f528..3c5c5d303 100644 --- a/next/src/validation/json-logic.ts +++ b/next/src/validation/json-logic.ts @@ -123,7 +123,7 @@ export function computePropertyValues( /** * Applies any computed attributes to a schema, based on the provided values. When there are values to apply, - * it creates a deep clone of the schema and applies the computed values to the clone,otherwise it returns the original schema. + * Note: this function mutates the schema in place. * * @param schema - The schema to apply computed attributes to * @param computedValuesDefinition - The computed values to apply diff --git a/next/test/validation/json-logic.test.ts b/next/test/validation/json-logic.test.ts index 69614e3f1..69b883e45 100644 --- a/next/test/validation/json-logic.test.ts +++ b/next/test/validation/json-logic.test.ts @@ -395,9 +395,11 @@ describe('applyComputedAttrsToSchema', () => { (jsonLogic.apply as jest.Mock).mockReturnValue(21) + const initialSchema = JSON.parse(JSON.stringify(schema)) + const result: JsfObjectSchema = JsonLogicValidation.applyComputedAttrsToSchema(schema, schema['x-jsf-logic']?.computedValues, { person: { age: 21 } }) - expect(result).not.toBe(schema) + expect(result).not.toEqual(initialSchema) const ageProperties = result.properties?.person?.properties?.age as JsfObjectSchema expect(ageProperties?.minimum).toBe(21) expect(ageProperties?.['x-jsf-logic-computedAttrs']).toBeUndefined() From 55b551fc9300114c3956579ec8c8c18a6ad99dac Mon Sep 17 00:00:00 2001 From: Capelo Date: Thu, 29 May 2025 11:37:18 +0100 Subject: [PATCH 06/41] chore: refactoring code and cleaning tests --- next/src/form.ts | 19 ++++++++++++++----- next/src/mutations.ts | 34 +++++++++++++++++++++++----------- next/src/utils.ts | 2 +- next/test/fields.test.ts | 2 +- 4 files changed, 39 insertions(+), 18 deletions(-) diff --git a/next/src/form.ts b/next/src/form.ts index 1363583eb..259a682cc 100644 --- a/next/src/form.ts +++ b/next/src/form.ts @@ -4,8 +4,7 @@ import type { JsfObjectSchema, JsfSchema, SchemaValue } from './types' import type { ValidationOptions } from './validation/schema' import { getErrorMessage } from './errors/messages' import { buildFieldSchema } from './field/schema' -import { mutateSchema, updateFieldProperties } from './mutations' -import { applyComputedAttrsToSchema } from './validation/json-logic' +import { calculateFinalSchema, updateFieldProperties } from './mutations' import { validateSchema } from './validation/schema' export { ValidationOptions } from './validation/schema' @@ -243,15 +242,25 @@ export function createHeadlessForm( ): FormResult { const initialValues = options.initialValues || {} const strictInputType = options.strictInputType || false - // Make a (new) version with all the computed attrs computed and applied - const updatedSchema = mutateSchema(schema, schema['x-jsf-logic']?.computedValues, initialValues, options.validationOptions, undefined) + // Make a new version of the schema with all the computed attrs applied, as well as the final version of each property (taking into account conditional rules) + const updatedSchema = calculateFinalSchema({ + schema, + values: initialValues, + options: options.validationOptions, + }) + const fields = buildFields({ schema: updatedSchema, strictInputType }) // TODO: check if we need this isError variable exposed const isError = false const handleValidation = (value: SchemaValue) => { - const updatedSchema = mutateSchema(schema, schema['x-jsf-logic']?.computedValues, value, options.validationOptions, undefined) + const updatedSchema = calculateFinalSchema({ + schema, + values: value, + options: options.validationOptions, + }) + const result = validate(value, updatedSchema, options.validationOptions) // Fields properties might have changed, so we need to reset the fields by updating them in place diff --git a/next/src/mutations.ts b/next/src/mutations.ts index 1ddebe204..976644886 100644 --- a/next/src/mutations.ts +++ b/next/src/mutations.ts @@ -1,5 +1,5 @@ import type { Field } from './field/type' -import type { JsfObjectSchema, JsfSchema, JsonLogicContext, JsonLogicRules, NonBooleanJsfSchema, ObjectValue, SchemaValue } from './types' +import type { JsfObjectSchema, JsfSchema, JsonLogicContext, NonBooleanJsfSchema, ObjectValue, SchemaValue } from './types' import type { ValidationOptions } from './validation/schema' import { buildFieldSchema } from './field/schema' import { deepMerge } from './utils' @@ -8,18 +8,31 @@ import { validateSchema } from './validation/schema' import { isObjectValue, safeDeepClone } from './validation/util' /** - * Mutates a schema to take into account the computed values and the conditional rules - * @param schema - * @param computedValuesDefinition - The computed values definition - * @param values - The current form values - * @returns The mutated schema + * Creates a new version of the schema with all the computed attrs applied, as well as the + * final version of each property (taking into account conditional rules) + * @param params - The parameters for the function + * @param params.schema - The original schema + * @param params.values - The current form values + * @param params.options - Validation options + * @returns The new schema */ -export function mutateSchema(schema: JsfObjectSchema, computedValuesDefinition: JsonLogicRules['computedValues'], values: SchemaValue, options: ValidationOptions = {}, jsonLogicContext: JsonLogicContext | undefined): JsfObjectSchema { +export function calculateFinalSchema({ + schema, + values, + options = {}, +}: { + schema: JsfObjectSchema + values: SchemaValue + options?: ValidationOptions +}): JsfObjectSchema { + const jsonLogicContext = schema['x-jsf-logic'] ? getJsonLogicContextFromSchema(schema['x-jsf-logic'], values) : undefined const schemaCopy = safeDeepClone(schema) applySchemaRules(schemaCopy, values, options, jsonLogicContext) - applyComputedAttrsToSchema(schemaCopy, computedValuesDefinition, values) + if (jsonLogicContext?.schema.computedValues) { + applyComputedAttrsToSchema(schemaCopy, jsonLogicContext.schema.computedValues, values) + } return schemaCopy } @@ -61,9 +74,8 @@ function evaluateConditional( /** * Applies JSON Schema conditional rules to determine updated field properties - * @param fields - The fields to apply rules to - * @param values - The current form values * @param schema - The JSON schema containing the rules + * @param values - The current form values * @param options - Validation options * @param jsonLogicContext - JSON Logic context */ @@ -107,7 +119,7 @@ function applySchemaRules( /** * Processes a branch of a conditional rule, updating the properties of fields based on the branch's schema - * @param fields - The fields to process + * @param schema - The JSON schema containing the rules * @param values - The current form values * @param branch - The branch (schema representing and then/else) to process * @param options - Validation options diff --git a/next/src/utils.ts b/next/src/utils.ts index 5e7932566..098f066c2 100644 --- a/next/src/utils.ts +++ b/next/src/utils.ts @@ -53,7 +53,7 @@ export function convertKBToMB(kb: number): number { } /** - * Merges two objects recursively, regardless of the depth of the objects + * Merges two objects recursively * @param obj1 - The first object to merge * @param obj2 - The second object to merge */ diff --git a/next/test/fields.test.ts b/next/test/fields.test.ts index 35034c087..4fa389b71 100644 --- a/next/test/fields.test.ts +++ b/next/test/fields.test.ts @@ -554,7 +554,7 @@ describe('fields', () => { }) // Skipping these tests until we have group-array support - describe.skip('array type inputs', () => { + describe('array type inputs', () => { it('uses group-array when items has properties', () => { const schema = { type: 'array', From e22d96bd703c591d19c84443570597fdb3550a60 Mon Sep 17 00:00:00 2001 From: Capelo Date: Thu, 29 May 2025 14:16:29 +0100 Subject: [PATCH 07/41] chore: fix wrong fixture on json-logic-v0 test --- next/test/validation/json-logic-v0.test.js | 3 +-- next/test/validation/json-logic.fixtures.js | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/next/test/validation/json-logic-v0.test.js b/next/test/validation/json-logic-v0.test.js index 82289396e..8fdf2bbe1 100644 --- a/next/test/validation/json-logic-v0.test.js +++ b/next/test/validation/json-logic-v0.test.js @@ -237,8 +237,7 @@ describe('jsonLogic: cross-values validations', () => { }) }) - // TODO: Implement this test. - describe.skip('reduce', () => { + describe('reduce', () => { it('reduce: working_hours_per_day * work_days', () => { const { fields, handleValidation } = createHeadlessForm(schemaWithReduceAccumulator, { strictInputType: false, diff --git a/next/test/validation/json-logic.fixtures.js b/next/test/validation/json-logic.fixtures.js index d3a0542dd..4dc7d45b6 100644 --- a/next/test/validation/json-logic.fixtures.js +++ b/next/test/validation/json-logic.fixtures.js @@ -724,7 +724,7 @@ export const schemaWithReduceAccumulator = { 'type': 'number', 'x-jsf-logic-computedAttrs': { const: 'computed_work_hours_per_week', - defaultValue: 'computed_work_hours_per_week', + default: 'computed_work_hours_per_week', title: '{{computed_work_hours_per_week}} hours per week', }, }, From 786641ef9cff2cb2149b1d3cfafc733a1f5dda6e Mon Sep 17 00:00:00 2001 From: Capelo Date: Thu, 29 May 2025 17:04:38 +0100 Subject: [PATCH 08/41] chore: wip mutating arrays --- next/src/mutations.ts | 29 +++++++++++++++++++++++++---- next/test/fields/array.test.ts | 19 ++++++++----------- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/next/src/mutations.ts b/next/src/mutations.ts index 976644886..1404ba58a 100644 --- a/next/src/mutations.ts +++ b/next/src/mutations.ts @@ -3,6 +3,7 @@ import type { JsfObjectSchema, JsfSchema, JsonLogicContext, NonBooleanJsfSchema, import type { ValidationOptions } from './validation/schema' import { buildFieldSchema } from './field/schema' import { deepMerge } from './utils' +import { validateCondition } from './validation/conditions' import { applyComputedAttrsToSchema, getJsonLogicContextFromSchema } from './validation/json-logic' import { validateSchema } from './validation/schema' import { isObjectValue, safeDeepClone } from './validation/util' @@ -51,12 +52,11 @@ function evaluateConditional( rule: NonBooleanJsfSchema, options: ValidationOptions = {}, ) { - const ifErrors = validateSchema(values, rule.if!, options) - const matches = ifErrors.length === 0 + const conditionIsTrue = validateSchema(values, rule.if!, options).length === 0 // Prevent fields from being shown when required fields have type errors let hasTypeErrors = false - if (matches && rule.if?.required) { + if (conditionIsTrue && rule.if?.required) { const requiredFields = rule.if.required hasTypeErrors = requiredFields.some((fieldName) => { if (!schema.properties || !schema.properties[fieldName]) { @@ -69,7 +69,7 @@ function evaluateConditional( }) } - return { rule, matches: matches && !hasTypeErrors } + return { rule, matches: conditionIsTrue && !hasTypeErrors } } /** @@ -85,6 +85,13 @@ function applySchemaRules( options: ValidationOptions = {}, jsonLogicContext: JsonLogicContext | undefined, ) { + if (Array.isArray(values)) { + for (const value of values) { + applySchemaRules(schema, value, options, jsonLogicContext) + } + return + } + if (!isObjectValue(values)) { return } @@ -115,6 +122,20 @@ function applySchemaRules( processBranch(schema, values, rule.else, options, jsonLogicContext) } } + + if (schema.properties) { + for (const [key, property] of Object.entries(schema.properties)) { + if (typeof property === 'object') { + const propertySchema = property as JsfObjectSchema + if (propertySchema.type === 'object') { + applySchemaRules(propertySchema, values[key] as ObjectValue, options, jsonLogicContext) + } + if (propertySchema.items) { + applySchemaRules(propertySchema.items as JsfObjectSchema, values[key], options, jsonLogicContext) + } + } + } + } } /** diff --git a/next/test/fields/array.test.ts b/next/test/fields/array.test.ts index 79b04e54d..fba4afe75 100644 --- a/next/test/fields/array.test.ts +++ b/next/test/fields/array.test.ts @@ -787,7 +787,7 @@ describe('buildFieldArray', () => { // 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', () => { + describe('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 = { @@ -828,18 +828,15 @@ describe('buildFieldArray', () => { required: ['animals'], } - it('mutates array items correctly when there is only one item', () => { + fit('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', - }), - ], - }) + expect(form.handleValidation({ animals: [{ kind: 'dog', name: 'Buddy' }, { kind: 'cat', name: 'moustache' }] }).formErrors).toBeUndefined() + const firstField = form.fields[0]?.fields?.[1] + const secondField = form.fields[0]?.fields?.[2] + + expect(firstField?.label).toBe('Dog name') + expect(secondField?.label).toBe('Cat name') }) it('mutates array items correctly when there are multiple items', () => { From ad59f0a81712275b6da36f8e37e27ba755721420 Mon Sep 17 00:00:00 2001 From: Capelo Date: Thu, 29 May 2025 18:28:18 +0100 Subject: [PATCH 09/41] chore: revert array mutation --- next/src/mutations.ts | 7 ------- next/test/fields/array.test.ts | 10 ++++------ 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/next/src/mutations.ts b/next/src/mutations.ts index 1404ba58a..c629438f6 100644 --- a/next/src/mutations.ts +++ b/next/src/mutations.ts @@ -85,13 +85,6 @@ function applySchemaRules( options: ValidationOptions = {}, jsonLogicContext: JsonLogicContext | undefined, ) { - if (Array.isArray(values)) { - for (const value of values) { - applySchemaRules(schema, value, options, jsonLogicContext) - } - return - } - if (!isObjectValue(values)) { return } diff --git a/next/test/fields/array.test.ts b/next/test/fields/array.test.ts index fba4afe75..9f7e0e76d 100644 --- a/next/test/fields/array.test.ts +++ b/next/test/fields/array.test.ts @@ -787,7 +787,7 @@ describe('buildFieldArray', () => { // 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('mutation of array items', () => { + 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 = { @@ -828,15 +828,13 @@ describe('buildFieldArray', () => { required: ['animals'], } - fit('mutates array items correctly when there is only one item', () => { + it('mutates array items correctly when there is only one item', () => { const form = createHeadlessForm(schema) - expect(form.handleValidation({ animals: [{ kind: 'dog', name: 'Buddy' }, { kind: 'cat', name: 'moustache' }] }).formErrors).toBeUndefined() + expect(form.handleValidation({ animals: [{ kind: 'dog', name: 'Buddy' }] }).formErrors).toBeUndefined() const firstField = form.fields[0]?.fields?.[1] - const secondField = form.fields[0]?.fields?.[2] - expect(firstField?.label).toBe('Dog name') - expect(secondField?.label).toBe('Cat name') + expect(firstField?.label).toBe('Dog Name') }) it('mutates array items correctly when there are multiple items', () => { From 961e3098289fb0cbfd313c741c97b4d8830d8422 Mon Sep 17 00:00:00 2001 From: Capelo Date: Thu, 29 May 2025 18:57:28 +0100 Subject: [PATCH 10/41] fix: fix deepMerge for arrays --- next/src/utils.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/next/src/utils.ts b/next/src/utils.ts index 098f066c2..391dd1c4b 100644 --- a/next/src/utils.ts +++ b/next/src/utils.ts @@ -79,13 +79,20 @@ export function deepMerge>(obj1: T, obj2: T): void if (obj1[key] && typeof obj1[key] === 'object' && !Array.isArray(obj1[key])) { deepMerge(obj1[key], value) } - else { + else if (obj1[key] !== value) { // Otherwise just assign obj1[key as keyof T] = value } } - else { - // For non-objects (including arrays), just assign + else if (Array.isArray(value)) { + // cycle through the array and merge values if they're different (take objects into account) + for (const item of value) { + if (item && typeof item === 'object') { + deepMerge(obj1[key], item) + } + } + } + else if (obj1[key] !== value) { obj1[key as keyof T] = value } } From 011a3637f47c532cb718b5600bad2516c7c970d5 Mon Sep 17 00:00:00 2001 From: Capelo Date: Fri, 30 May 2025 09:19:26 +0100 Subject: [PATCH 11/41] chore: comment --- next/src/utils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/next/src/utils.ts b/next/src/utils.ts index 391dd1c4b..ea849c4cb 100644 --- a/next/src/utils.ts +++ b/next/src/utils.ts @@ -79,6 +79,7 @@ export function deepMerge>(obj1: T, obj2: T): void if (obj1[key] && typeof obj1[key] === 'object' && !Array.isArray(obj1[key])) { deepMerge(obj1[key], value) } + // If the value is different, assign it else if (obj1[key] !== value) { // Otherwise just assign obj1[key as keyof T] = value From 8ce3c45b9a24cd2aa1d59cb112c2ee1d0ac7be3b Mon Sep 17 00:00:00 2001 From: Capelo Date: Fri, 30 May 2025 09:27:33 +0100 Subject: [PATCH 12/41] fix: remove unused import --- next/src/mutations.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/next/src/mutations.ts b/next/src/mutations.ts index c629438f6..41c2840a6 100644 --- a/next/src/mutations.ts +++ b/next/src/mutations.ts @@ -3,7 +3,6 @@ import type { JsfObjectSchema, JsfSchema, JsonLogicContext, NonBooleanJsfSchema, import type { ValidationOptions } from './validation/schema' import { buildFieldSchema } from './field/schema' import { deepMerge } from './utils' -import { validateCondition } from './validation/conditions' import { applyComputedAttrsToSchema, getJsonLogicContextFromSchema } from './validation/json-logic' import { validateSchema } from './validation/schema' import { isObjectValue, safeDeepClone } from './validation/util' From c54ac6efc6da4f9d7cc05d797e9b6092cbe9f186 Mon Sep 17 00:00:00 2001 From: Capelo Date: Fri, 30 May 2025 09:52:47 +0100 Subject: [PATCH 13/41] chore: improved array handling --- next/src/utils.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/next/src/utils.ts b/next/src/utils.ts index ea849c4cb..2b39dbbe9 100644 --- a/next/src/utils.ts +++ b/next/src/utils.ts @@ -81,15 +81,19 @@ export function deepMerge>(obj1: T, obj2: T): void } // If the value is different, assign it else if (obj1[key] !== value) { - // Otherwise just assign obj1[key as keyof T] = value } } - else if (Array.isArray(value)) { - // cycle through the array and merge values if they're different (take objects into account) + // If the value is an array, cycle through it and merge values if they're different (take objects into account) + else if (obj1[key] && Array.isArray(value)) { + const originalArray = obj1[key] + // If the destiny value exists and it's an array, cycle through the incoming values and merge if they're different (take objects into account) for (const item of value) { if (item && typeof item === 'object') { - deepMerge(obj1[key], item) + deepMerge(originalArray, item) + } + else if (!originalArray.find((item: any) => item === value)) { + originalArray.push(item) } } } From 500e8bd77d5032612f80d4278cc4ba7351cad8f0 Mon Sep 17 00:00:00 2001 From: Capelo Date: Fri, 30 May 2025 10:00:35 +0100 Subject: [PATCH 14/41] chore: further array refinements --- next/src/utils.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/next/src/utils.ts b/next/src/utils.ts index 2b39dbbe9..80005f28a 100644 --- a/next/src/utils.ts +++ b/next/src/utils.ts @@ -92,8 +92,9 @@ export function deepMerge>(obj1: T, obj2: T): void if (item && typeof item === 'object') { deepMerge(originalArray, item) } - else if (!originalArray.find((item: any) => item === value)) { - originalArray.push(item) + // If the value is not an object, just assign it + else { + obj1[key as keyof T] = value as T[keyof T] } } } From 70b9a3bb3909d6e87a22d4ab598e87d87667afdb Mon Sep 17 00:00:00 2001 From: Capelo Date: Fri, 30 May 2025 10:24:40 +0100 Subject: [PATCH 15/41] chore: handling required array case --- next/src/utils.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/next/src/utils.ts b/next/src/utils.ts index 80005f28a..758d20445 100644 --- a/next/src/utils.ts +++ b/next/src/utils.ts @@ -92,7 +92,14 @@ export function deepMerge>(obj1: T, obj2: T): void if (item && typeof item === 'object') { deepMerge(originalArray, item) } - // If the value is not an object, just assign it + // "required" is a special case, it only allows for new elements to be added to the array + else if (key === 'required') { + // Add any new elements to the array + if (!originalArray.find((originalItem: any) => originalItem === item)) { + originalArray.push(item) + } + } + // Otherwise, just assign it else { obj1[key as keyof T] = value as T[keyof T] } From bdfdc1c275985635a789f1fb6fa3fa0ed15023f7 Mon Sep 17 00:00:00 2001 From: Capelo Date: Fri, 30 May 2025 12:00:53 +0100 Subject: [PATCH 16/41] fix: fix property cleaning on updated fields --- next/src/mutations.ts | 19 +++++++++++++++++++ next/src/utils.ts | 5 +++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/next/src/mutations.ts b/next/src/mutations.ts index 41c2840a6..5804d9d0f 100644 --- a/next/src/mutations.ts +++ b/next/src/mutations.ts @@ -159,6 +159,7 @@ export function updateFieldProperties(fields: Field[], schema: JsfObjectSchema): const newField = newFields.find(f => f.name === field.name) if (newField) { + removeNonExistentProperties(field, newField) deepMerge(field, newField) const fieldSchema = schema.properties?.[field.name] @@ -171,3 +172,21 @@ export function updateFieldProperties(fields: Field[], schema: JsfObjectSchema): } } } + +/** + * Recursively removes properties that don't exist in newObj + * @param obj - The object to remove properties from + * @param newObj - The object to compare with + */ +function removeNonExistentProperties(obj: Field, newObj: Field) { + for (const [key] of Object.entries(obj)) { + if (!newObj[key]) { + delete obj[key] + } + else if (obj[key] && typeof obj[key] === 'object' && !Array.isArray(obj[key]) + && newObj[key] && typeof newObj[key] === 'object' && !Array.isArray(newObj[key])) { + // Recursively process nested objects + removeNonExistentProperties(obj[key] as Field, newObj[key] as Field) + } + } +} diff --git a/next/src/utils.ts b/next/src/utils.ts index 758d20445..da64bbd1a 100644 --- a/next/src/utils.ts +++ b/next/src/utils.ts @@ -53,14 +53,15 @@ export function convertKBToMB(kb: number): number { } /** - * Merges two objects recursively + * Merges obj1 with obj2 recursively * @param obj1 - The first object to merge * @param obj2 - The second object to merge */ export function deepMerge>(obj1: T, obj2: T): void { // Handle null/undefined - if (!obj1 || !obj2) + if (!obj1 || !obj2) { return + } // Handle arrays if (Array.isArray(obj1) && Array.isArray(obj2)) { From 74ba3d1d4ef3bec125959de77c271711e8c668ac Mon Sep 17 00:00:00 2001 From: Capelo Date: Fri, 30 May 2025 12:01:40 +0100 Subject: [PATCH 17/41] chore: comments --- next/src/mutations.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/next/src/mutations.ts b/next/src/mutations.ts index 5804d9d0f..bee759a7d 100644 --- a/next/src/mutations.ts +++ b/next/src/mutations.ts @@ -146,7 +146,7 @@ function processBranch(schema: JsfObjectSchema, values: SchemaValue, branch: Jsf } /** - * Updates fields in place based on a schema, recursively if needed + * Updates fields (in place) based on a schema, recursively if needed * @param fields - The fields array to mutate * @param schema - The schema to use for updating fields */ @@ -159,6 +159,8 @@ export function updateFieldProperties(fields: Field[], schema: JsfObjectSchema): const newField = newFields.find(f => f.name === field.name) if (newField) { + // Properties might have been removed with the most recent schema (due to most recent form values) + // so we need to remove them from the original field removeNonExistentProperties(field, newField) deepMerge(field, newField) From 6d9f2fccd03b092bde40828c568dedc3907112e2 Mon Sep 17 00:00:00 2001 From: Capelo Date: Fri, 30 May 2025 12:21:46 +0100 Subject: [PATCH 18/41] chore: refactor --- next/src/utils.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/next/src/utils.ts b/next/src/utils.ts index da64bbd1a..656adba30 100644 --- a/next/src/utils.ts +++ b/next/src/utils.ts @@ -63,12 +63,6 @@ export function deepMerge>(obj1: T, obj2: T): void return } - // Handle arrays - if (Array.isArray(obj1) && Array.isArray(obj2)) { - obj1.push(...obj2) - return - } - // Handle non-objects if (typeof obj1 !== 'object' || typeof obj2 !== 'object') return @@ -91,7 +85,7 @@ export function deepMerge>(obj1: T, obj2: T): void // If the destiny value exists and it's an array, cycle through the incoming values and merge if they're different (take objects into account) for (const item of value) { if (item && typeof item === 'object') { - deepMerge(originalArray, item) + deepMerge(originalArray, value) } // "required" is a special case, it only allows for new elements to be added to the array else if (key === 'required') { From 2b02720f973e216bce0ad34040d0867df072f235 Mon Sep 17 00:00:00 2001 From: Capelo Date: Fri, 30 May 2025 16:31:20 +0100 Subject: [PATCH 19/41] chore: make fields visible by default --- next/src/field/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/next/src/field/schema.ts b/next/src/field/schema.ts index 561591ea5..af0307397 100644 --- a/next/src/field/schema.ts +++ b/next/src/field/schema.ts @@ -344,7 +344,7 @@ export function buildFieldSchema( type: inputType, jsonType: type || schema.type, required, - isVisible: inputType !== 'hidden', + isVisible: true, ...(errorMessage && { errorMessage }), } From a0a643ef92019cf0c69b136f784e0ce48bc6f424 Mon Sep 17 00:00:00 2001 From: Capelo Date: Fri, 30 May 2025 17:16:49 +0100 Subject: [PATCH 20/41] chore: improve error messages by calculating complete schema for field --- next/src/form.ts | 29 ++++++++++++++++++++++++++--- next/src/mutations.ts | 6 +++--- next/src/utils.ts | 14 +++++++++++--- 3 files changed, 40 insertions(+), 9 deletions(-) diff --git a/next/src/form.ts b/next/src/form.ts index 259a682cc..feb49a500 100644 --- a/next/src/form.ts +++ b/next/src/form.ts @@ -153,17 +153,40 @@ interface ValidationErrorWithMessage extends ValidationError { * @param errors - The validation errors * @returns The validation errors with error messages added */ -function addErrorMessages(errors: ValidationError[]): ValidationErrorWithMessage[] { +function addErrorMessages(errors: ValidationError[], rootSchema: JsfSchema): ValidationErrorWithMessage[] { return errors.map((error) => { const { schema, value, validation, customErrorMessage } = error + const completePropertySchema = getCompletePropertySchema(error.path, rootSchema, schema) + return { ...error, - message: getErrorMessage(schema, value, validation, customErrorMessage), + message: getErrorMessage(completePropertySchema, value, validation, customErrorMessage), } }) } +/** + * Get the complete property schema from the root schema and the property schema + * @param path - The path to the property + * @param rootSchema - The root schema + * @param propertySchema - The property schema + * @returns The complete property schema + */ +function getCompletePropertySchema(path: ValidationErrorPath, rootSchema: JsfSchema, propertySchema: JsfSchema): JsfSchema { + // Property name is the last segment of the path + const propertyName = path[path.length - 1] + + // Fetch the main property schema from the root schema + const mainSchema = rootSchema.properties?.[propertyName] as object + + // Return the complete property schema + return { + ...(mainSchema || {}), + ...(propertySchema as object), + } +} + /** * Apply custom error messages from the schema to validation errors * @param errors - The validation errors @@ -202,7 +225,7 @@ function validate(value: SchemaValue, schema: JsfSchema, options: ValidationOpti const result: ValidationResult = {} const errors = validateSchema(value, schema, options) - const errorsWithMessages = addErrorMessages(errors) + const errorsWithMessages = addErrorMessages(errors, schema) const processedErrors = applyCustomErrorMessages(errorsWithMessages, schema) const formErrors = validationErrorsToFormErrors(processedErrors) diff --git a/next/src/mutations.ts b/next/src/mutations.ts index bee759a7d..281bfa200 100644 --- a/next/src/mutations.ts +++ b/next/src/mutations.ts @@ -2,7 +2,7 @@ import type { Field } from './field/type' import type { JsfObjectSchema, JsfSchema, JsonLogicContext, NonBooleanJsfSchema, ObjectValue, SchemaValue } from './types' import type { ValidationOptions } from './validation/schema' import { buildFieldSchema } from './field/schema' -import { deepMerge } from './utils' +import { deepMergeSchemas } from './utils' import { applyComputedAttrsToSchema, getJsonLogicContextFromSchema } from './validation/json-logic' import { validateSchema } from './validation/schema' import { isObjectValue, safeDeepClone } from './validation/util' @@ -142,7 +142,7 @@ function processBranch(schema: JsfObjectSchema, values: SchemaValue, branch: Jsf const branchSchema = branch as JsfObjectSchema applySchemaRules(branchSchema, values, options, jsonLogicContext) - deepMerge(schema, branchSchema) + deepMergeSchemas(schema, branchSchema) } /** @@ -162,7 +162,7 @@ export function updateFieldProperties(fields: Field[], schema: JsfObjectSchema): // Properties might have been removed with the most recent schema (due to most recent form values) // so we need to remove them from the original field removeNonExistentProperties(field, newField) - deepMerge(field, newField) + deepMergeSchemas(field, newField) const fieldSchema = schema.properties?.[field.name] diff --git a/next/src/utils.ts b/next/src/utils.ts index 656adba30..1f8aa69d5 100644 --- a/next/src/utils.ts +++ b/next/src/utils.ts @@ -52,12 +52,15 @@ export function convertKBToMB(kb: number): number { return Number.parseFloat(mb.toFixed(2)) // Keep 2 decimal places } +// Keys to skip when merging schema objects +const KEYS_TO_SKIP = ['if', 'then', 'else', 'allOf', 'anyOf', 'oneOf'] + /** * Merges obj1 with obj2 recursively * @param obj1 - The first object to merge * @param obj2 - The second object to merge */ -export function deepMerge>(obj1: T, obj2: T): void { +export function deepMergeSchemas>(obj1: T, obj2: T): void { // Handle null/undefined if (!obj1 || !obj2) { return @@ -69,10 +72,15 @@ export function deepMerge>(obj1: T, obj2: T): void // Merge all properties from obj2 into obj1 for (const [key, value] of Object.entries(obj2)) { + // let's skip merging conditionals such as if, oneOf, then ,else, allOf, anyOf, etc. + if (KEYS_TO_SKIP.includes(key)) { + continue + } + if (value && typeof value === 'object' && !Array.isArray(value)) { // If both objects have this key and it's an object, merge recursively if (obj1[key] && typeof obj1[key] === 'object' && !Array.isArray(obj1[key])) { - deepMerge(obj1[key], value) + deepMergeSchemas(obj1[key], value) } // If the value is different, assign it else if (obj1[key] !== value) { @@ -85,7 +93,7 @@ export function deepMerge>(obj1: T, obj2: T): void // If the destiny value exists and it's an array, cycle through the incoming values and merge if they're different (take objects into account) for (const item of value) { if (item && typeof item === 'object') { - deepMerge(originalArray, value) + deepMergeSchemas(originalArray, value) } // "required" is a special case, it only allows for new elements to be added to the array else if (key === 'required') { From 5ab562b3ea399be5b6509b3a86dc95556cfe5a57 Mon Sep 17 00:00:00 2001 From: Capelo Date: Fri, 30 May 2025 17:16:54 +0100 Subject: [PATCH 21/41] fix: fix fixture --- src/tests/helpers.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tests/helpers.js b/src/tests/helpers.js index 783fcdbf5..38b635749 100644 --- a/src/tests/helpers.js +++ b/src/tests/helpers.js @@ -1401,11 +1401,11 @@ export const schemaDynamicValidationConst = { oneOf: [ { title: 'Yes', - value: 'yes', + const: 'yes', }, { title: 'No', - value: 'no', + const: 'no', }, ], 'x-jsf-presentation': { @@ -1418,11 +1418,11 @@ export const schemaDynamicValidationConst = { oneOf: [ { title: 'Yes', - value: 'yes', + const: 'yes', }, { title: 'No', - value: 'no', + const: 'no', }, ], 'x-jsf-presentation': { From 81e2182956a26c4c7a6d568259554f48e30e2e1a Mon Sep 17 00:00:00 2001 From: Capelo Date: Mon, 2 Jun 2025 10:08:59 +0100 Subject: [PATCH 22/41] fix: consider allof/anyof/oneof when merging schemas --- next/src/utils.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/next/src/utils.ts b/next/src/utils.ts index 1f8aa69d5..fc91e480d 100644 --- a/next/src/utils.ts +++ b/next/src/utils.ts @@ -53,7 +53,11 @@ export function convertKBToMB(kb: number): number { } // Keys to skip when merging schema objects -const KEYS_TO_SKIP = ['if', 'then', 'else', 'allOf', 'anyOf', 'oneOf'] +const KEYS_TO_SKIP = ['if', 'then', 'else'] + +function isObject(value: any): boolean { + return value && typeof value === 'object' && !Array.isArray(value) +} /** * Merges obj1 with obj2 recursively @@ -77,12 +81,13 @@ export function deepMergeSchemas>(obj1: T, obj2: T continue } - if (value && typeof value === 'object' && !Array.isArray(value)) { + // If the value is an object, merge recursively + if (isObject(value)) { // If both objects have this key and it's an object, merge recursively - if (obj1[key] && typeof obj1[key] === 'object' && !Array.isArray(obj1[key])) { + if (isObject(obj1[key])) { deepMergeSchemas(obj1[key], value) } - // If the value is different, assign it + // Otherwise, if the value is different, assign it else if (obj1[key] !== value) { obj1[key as keyof T] = value } From b7821ec9f8013dc342f94334f17dc5cbf1113e25 Mon Sep 17 00:00:00 2001 From: Capelo Date: Mon, 2 Jun 2025 10:09:17 +0100 Subject: [PATCH 23/41] fix: fix schemaDynamicValidationConst --- src/tests/helpers.js | 121 +++++++++++++++++++++++-------------------- 1 file changed, 65 insertions(+), 56 deletions(-) diff --git a/src/tests/helpers.js b/src/tests/helpers.js index 38b635749..00063376c 100644 --- a/src/tests/helpers.js +++ b/src/tests/helpers.js @@ -1392,81 +1392,90 @@ export const schemaWithOrderKeyword = JSONSchemaBuilder() .build(); export const schemaDynamicValidationConst = { + type: "object", + additionalProperties: false, properties: { - a_fieldset: mockFieldset, - a_group_array: simpleGroupArrayInput, - validate_tabs: { - title: 'Should "Tabs" value be required?', - description: 'Toggle this radio for changing the validation of the fieldset bellow', - oneOf: [ - { - title: 'Yes', - const: 'yes', - }, - { - title: 'No', - const: 'no', - }, - ], + a_fieldset: { + title: "Fieldset title", + description: "Fieldset description", 'x-jsf-presentation': { - inputType: 'radio', + inputType: "fieldset" }, + properties: { + username: { + title: "Username", + description: "Your username (max 10 characters)", + maxLength: 10, + 'x-jsf-presentation': { + inputType: "text", + maskSecret: 2 + }, + type: "string" + }, + tabs: { + title: "Tabs", + description: "How many open tabs do you have?", + 'x-jsf-presentation': { + inputType: "number" + }, + minimum: 1, + maximum: 10, + type: "number" + } + }, + required: [ + "username" + ], + type: "object" }, - mandatory_group_array: { - title: 'Add required group array field', - description: 'Toggle this radio for displaying a mandatory group array field', + validate_fieldset: { + title: "Fieldset validation", + type: "string", + description: "Select what fieldset fields are required", oneOf: [ { - title: 'Yes', - const: 'yes', + const: "all", + title: "All" }, { - title: 'No', - const: 'no', - }, + const: "username", + title: "Username" + } ], 'x-jsf-presentation': { - inputType: 'radio', - }, - }, + inputType: "select", + placeholder: "Select..." + } + } }, - allOf: [ - { - if: { - properties: { - mandatory_group_array: { - const: 'yes', - }, - }, - required: ['mandatory_group_array'], - }, - then: { - required: ['a_group_array'], - }, - else: { - properties: { - a_group_array: false, - }, - }, - }, + 'x-jsf-order': [ + "validate_fieldset", + "a_fieldset" + ], + required: [ + "a_fieldset", + "validate_fieldset" ], if: { properties: { - validate_tabs: { - const: 'yes', - }, + validate_fieldset: { + pattern: "^all$" + } }, - required: ['validate_tabs'], + required: [ + "validate_fieldset" + ] }, then: { properties: { a_fieldset: { - required: ['username', 'tabs'], - }, - }, - }, - required: ['a_fieldset', 'validate_tabs', 'mandatory_group_array'], - 'x-jsf-order': ['validate_tabs', 'a_fieldset', 'mandatory_group_array', 'a_group_array'], + required: [ + "username", + "tabs" + ] + } + } + } }; export const schemaDynamicValidationMinimumMaximum = JSONSchemaBuilder() From e9735cd4a9676480f07bd7affdf6c3323a085ff0 Mon Sep 17 00:00:00 2001 From: Capelo Date: Mon, 2 Jun 2025 12:26:42 +0100 Subject: [PATCH 24/41] fix: add fallback values object on applySchemaRules fn --- next/src/mutations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/next/src/mutations.ts b/next/src/mutations.ts index 281bfa200..f5fd09e3e 100644 --- a/next/src/mutations.ts +++ b/next/src/mutations.ts @@ -80,7 +80,7 @@ function evaluateConditional( */ function applySchemaRules( schema: JsfObjectSchema, - values: SchemaValue, + values: SchemaValue = {}, options: ValidationOptions = {}, jsonLogicContext: JsonLogicContext | undefined, ) { From 5ae491b1890b506f3f52370a08cb8463c5d9e381 Mon Sep 17 00:00:00 2001 From: Capelo Date: Tue, 3 Jun 2025 06:52:30 +0100 Subject: [PATCH 25/41] chore: add support for value as a const substitute --- next/src/types.ts | 3 + next/src/validation/const.ts | 5 +- src/tests/helpers.js | 122 ++++++++++++++++------------------- 3 files changed, 63 insertions(+), 67 deletions(-) diff --git a/next/src/types.ts b/next/src/types.ts index 050c6b5bd..5ceeaee41 100644 --- a/next/src/types.ts +++ b/next/src/types.ts @@ -62,6 +62,9 @@ export type JsfSchema = JSONSchema & { 'if'?: JsfSchema 'then'?: JsfSchema 'else'?: JsfSchema + // while value is not part of the spec, we're keeping it for v0 backwards compatibility + 'value'?: SchemaValue + 'const'?: SchemaValue // Note: if we don't have this property here, when inspecting any recursive // schema (like an if inside another schema), the required property won't be // present in the type diff --git a/next/src/validation/const.ts b/next/src/validation/const.ts index 32c86923a..118bc7d76 100644 --- a/next/src/validation/const.ts +++ b/next/src/validation/const.ts @@ -19,11 +19,12 @@ export function validateConst( schema: NonBooleanJsfSchema, path: ValidationErrorPath = [], ): ValidationError[] { - if (schema.const === undefined) { + const constValue = schema.const || schema.value + if (constValue === undefined) { return [] } - if (!deepEqual(schema.const, value)) { + if (!deepEqual(constValue, value)) { return [ { path, validation: 'const', schema, value }, ] diff --git a/src/tests/helpers.js b/src/tests/helpers.js index 00063376c..48693408c 100644 --- a/src/tests/helpers.js +++ b/src/tests/helpers.js @@ -1392,91 +1392,83 @@ export const schemaWithOrderKeyword = JSONSchemaBuilder() .build(); export const schemaDynamicValidationConst = { - type: "object", - additionalProperties: false, properties: { - a_fieldset: { - title: "Fieldset title", - description: "Fieldset description", - 'x-jsf-presentation': { - inputType: "fieldset" - }, - properties: { - username: { - title: "Username", - description: "Your username (max 10 characters)", - maxLength: 10, - 'x-jsf-presentation': { - inputType: "text", - maskSecret: 2 - }, - type: "string" + a_fieldset: mockFieldset, + a_group_array: simpleGroupArrayInput, + validate_tabs: { + title: 'Should "Tabs" value be required?', + description: 'Toggle this radio for changing the validation of the fieldset bellow', + oneOf: [ + { + title: 'Yes', + value: 'yes', + }, + { + title: 'No', + value: 'no', }, - tabs: { - title: "Tabs", - description: "How many open tabs do you have?", - 'x-jsf-presentation': { - inputType: "number" - }, - minimum: 1, - maximum: 10, - type: "number" - } - }, - required: [ - "username" ], - type: "object" + 'x-jsf-presentation': { + inputType: 'radio', + }, }, - validate_fieldset: { - title: "Fieldset validation", - type: "string", - description: "Select what fieldset fields are required", + mandatory_group_array: { + title: 'Add required group array field', + description: 'Toggle this radio for displaying a mandatory group array field', oneOf: [ { - const: "all", - title: "All" + title: 'Yes', + value: 'yes', }, { - const: "username", - title: "Username" - } + title: 'No', + value: 'no', + }, ], 'x-jsf-presentation': { - inputType: "select", - placeholder: "Select..." - } - } + inputType: 'radio', + }, + }, }, - 'x-jsf-order': [ - "validate_fieldset", - "a_fieldset" - ], - required: [ - "a_fieldset", - "validate_fieldset" + allOf: [ + { + if: { + properties: { + mandatory_group_array: { + const: 'yes', + }, + }, + required: ['mandatory_group_array'], + }, + then: { + required: ['a_group_array'], + }, + else: { + properties: { + a_group_array: false, + }, + }, + }, ], if: { properties: { - validate_fieldset: { - pattern: "^all$" - } + validate_tabs: { + const: 'yes', + }, }, - required: [ - "validate_fieldset" - ] + required: ['validate_tabs'], }, then: { properties: { a_fieldset: { - required: [ - "username", - "tabs" - ] - } - } - } + required: ['username', 'tabs'], + }, + }, + }, + required: ['a_fieldset', 'validate_tabs', 'mandatory_group_array'], + 'x-jsf-order': ['validate_tabs', 'a_fieldset', 'mandatory_group_array', 'a_group_array'], }; +; export const schemaDynamicValidationMinimumMaximum = JSONSchemaBuilder() .addInput({ From 0d465f0129230c92c97830d74df630c36de2f936 Mon Sep 17 00:00:00 2001 From: Capelo Date: Wed, 4 Jun 2025 10:24:22 +0100 Subject: [PATCH 26/41] fix: consider original schema for calculating correct input type for hidden fields --- next/src/field/schema.ts | 42 +++++++++++++++++++++------------- next/src/form.ts | 10 ++++---- next/src/mutations.ts | 6 ++--- next/test/fields.test.ts | 34 +++++++++++++++++++++++++-- next/test/fields/array.test.ts | 7 ++++-- 5 files changed, 71 insertions(+), 28 deletions(-) diff --git a/next/src/field/schema.ts b/next/src/field/schema.ts index af0307397..42d54a755 100644 --- a/next/src/field/schema.ts +++ b/next/src/field/schema.ts @@ -1,4 +1,4 @@ -import type { JsfObjectSchema, JsfSchema, JsfSchemaType, NonBooleanJsfSchema } from '../types' +import type { JsfObjectSchema, JsfSchema, JsfSchemaType, NonBooleanJsfSchema, SchemaValue } from '../types' import type { Field, FieldOption, FieldType } from './type' import { setCustomOrder } from '../custom/order' @@ -46,9 +46,9 @@ function addOptions(field: Field, schema: NonBooleanJsfSchema) { * This adds the fields attribute to based on the schema's items. * Since options and fields are mutually exclusive, we only add fields if no options were provided. */ -function addFields(field: Field, schema: NonBooleanJsfSchema, strictInputType?: boolean) { +function addFields(field: Field, schema: NonBooleanJsfSchema, originalSchema: JsfSchema, strictInputType?: boolean) { if (field.options === undefined) { - const fields = getFields(schema, strictInputType) + const fields = getFields(schema, originalSchema, strictInputType) if (fields) { field.fields = fields } @@ -129,8 +129,6 @@ You can fix the json schema or skip this error by calling createHeadlessForm(sch if (schema.properties) { return 'select' } - - // Otherwise, assume "string" as the fallback type and get input from it } // Get input type from schema (fallback type is "string") @@ -201,7 +199,7 @@ function getFieldOptions(schema: NonBooleanJsfSchema) { if (schema.enum) { const enumAsOneOf: JsfSchema['oneOf'] = schema.enum?.map(value => ({ title: typeof value === 'string' ? value : JSON.stringify(value), - const: value, + const: value as SchemaValue, })) || [] return convertToOptions(enumAsOneOf) } @@ -213,14 +211,15 @@ function getFieldOptions(schema: NonBooleanJsfSchema) { * Get the fields for an object schema * @param schema - The schema of the field * @param strictInputType - Whether to strictly enforce the input type + * @param originalSchema - The original schema (needed for calculating the original input type, for hidden fields) * @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 { +function getObjectFields(schema: NonBooleanJsfSchema, originalSchema: 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) + const field = buildFieldSchema(schema.properties[key], key, isRequired, originalSchema.properties?.[key] || schema.properties[key], strictInputType) if (field) { fields.push(field) } @@ -235,9 +234,10 @@ function getObjectFields(schema: NonBooleanJsfSchema, strictInputType?: boolean) * Get the fields for an array schema * @param schema - The schema of the field * @param strictInputType - Whether to strictly enforce the input type + * @param originalSchema - The original schema (needed for calculating the original input type, for hidden fields) * @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[] { +function getArrayFields(schema: NonBooleanJsfSchema, originalSchema: NonBooleanJsfSchema, strictInputType?: boolean): Field[] { const fields: Field[] = [] if (typeof schema.items !== 'object' || schema.items === null) { @@ -249,7 +249,7 @@ function getArrayFields(schema: NonBooleanJsfSchema, strictInputType?: boolean): for (const key in objectSchema.properties) { const isFieldRequired = objectSchema.required?.includes(key) || false - const field = buildFieldSchema(objectSchema.properties[key], key, isFieldRequired, strictInputType) + const field = buildFieldSchema(objectSchema.properties[key], key, isFieldRequired, originalSchema, strictInputType) if (field) { field.nameKey = key fields.push(field) @@ -257,7 +257,7 @@ function getArrayFields(schema: NonBooleanJsfSchema, strictInputType?: boolean): } } else { - const field = buildFieldSchema(schema.items, 'item', false, strictInputType) + const field = buildFieldSchema(schema.items, 'item', false, originalSchema, strictInputType) if (field) { fields.push(field) } @@ -272,14 +272,15 @@ function getArrayFields(schema: NonBooleanJsfSchema, strictInputType?: boolean): * 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 + * @param originalSchema - The original schema (needed for calculating the original input type, for hidden fields) * @returns The fields for the schema */ -function getFields(schema: NonBooleanJsfSchema, strictInputType?: boolean): Field[] | null { +function getFields(schema: NonBooleanJsfSchema, originalSchema: NonBooleanJsfSchema, strictInputType?: boolean): Field[] | null { if (typeof schema.properties === 'object' && schema.properties !== null) { - return getObjectFields(schema, strictInputType) + return getObjectFields(schema, originalSchema, strictInputType) } else if (typeof schema.items === 'object' && schema.items !== null) { - return getArrayFields(schema, strictInputType) + return getArrayFields(schema, originalSchema, strictInputType) } return null @@ -300,17 +301,26 @@ const excludedSchemaProps = [ /** * Build a field from any schema + * @param schema - The schema of the field + * @param name - The name of the field + * @param required - Whether the field is required + * @param originalSchema - The original schema (needed for calculating the original input type, for hidden fields) + * @param strictInputType - Whether to strictly enforce the input type + * @param type - The schema type + * @returns The field */ export function buildFieldSchema( schema: JsfSchema, name: string, required: boolean = false, + originalSchema: NonBooleanJsfSchema, strictInputType: boolean = false, type: JsfSchemaType = undefined, ): Field | null { // If schema is boolean false, return a field with isVisible=false if (schema === false) { - const inputType = getInputType(type, name, schema, strictInputType) + // If the schema is false, we use the original schema to get the input type + const inputType = getInputType(type, name, originalSchema, strictInputType) return { type: inputType, name, @@ -369,7 +379,7 @@ export function buildFieldSchema( } addOptions(field, schema) - addFields(field, schema) + addFields(field, schema, originalSchema) return field } diff --git a/next/src/form.ts b/next/src/form.ts index feb49a500..141d30113 100644 --- a/next/src/form.ts +++ b/next/src/form.ts @@ -253,9 +253,9 @@ export interface CreateHeadlessFormOptions { strictInputType?: boolean } -function buildFields(params: { schema: JsfObjectSchema, strictInputType?: boolean }): Field[] { - const { schema, strictInputType } = params - const fields = buildFieldSchema(schema, 'root', true, strictInputType, 'object')?.fields || [] +function buildFields(params: { schema: JsfObjectSchema, originalSchema: JsfObjectSchema, strictInputType?: boolean }): Field[] { + const { schema, originalSchema, strictInputType } = params + const fields = buildFieldSchema(schema, 'root', true, originalSchema, strictInputType, 'object')?.fields || [] return fields } @@ -272,7 +272,7 @@ export function createHeadlessForm( options: options.validationOptions, }) - const fields = buildFields({ schema: updatedSchema, strictInputType }) + const fields = buildFields({ schema: updatedSchema, originalSchema: schema, strictInputType }) // TODO: check if we need this isError variable exposed const isError = false @@ -287,7 +287,7 @@ export function createHeadlessForm( const result = validate(value, updatedSchema, options.validationOptions) // Fields properties might have changed, so we need to reset the fields by updating them in place - updateFieldProperties(fields, updatedSchema) + updateFieldProperties(fields, updatedSchema, schema) return result } diff --git a/next/src/mutations.ts b/next/src/mutations.ts index f5fd09e3e..6a83614d9 100644 --- a/next/src/mutations.ts +++ b/next/src/mutations.ts @@ -150,9 +150,9 @@ function processBranch(schema: JsfObjectSchema, values: SchemaValue, branch: Jsf * @param fields - The fields array to mutate * @param schema - The schema to use for updating fields */ -export function updateFieldProperties(fields: Field[], schema: JsfObjectSchema): void { +export function updateFieldProperties(fields: Field[], schema: JsfObjectSchema, originalSchema: JsfSchema): void { // Get new fields from schema - const newFields = buildFieldSchema(schema, 'root', true, false, 'object')?.fields || [] + const newFields = buildFieldSchema(schema, 'root', true, originalSchema, false, 'object')?.fields || [] // cycle through the original fields and merge the new fields with the original fields for (const field of fields) { @@ -168,7 +168,7 @@ export function updateFieldProperties(fields: Field[], schema: JsfObjectSchema): if (fieldSchema && typeof fieldSchema === 'object') { if (field.fields && fieldSchema.type === 'object') { - updateFieldProperties(field.fields, fieldSchema as JsfObjectSchema) + updateFieldProperties(field.fields, fieldSchema as JsfObjectSchema, originalSchema) } } } diff --git a/next/test/fields.test.ts b/next/test/fields.test.ts index 4fa389b71..82698f889 100644 --- a/next/test/fields.test.ts +++ b/next/test/fields.test.ts @@ -1,9 +1,13 @@ -import type { JsfSchema, NonBooleanJsfSchema } from '../src/types' +import type { JsfSchema, JsfSchemaType, NonBooleanJsfSchema } from '../src/types' import { describe, expect, it } from '@jest/globals' import { TypeName } from 'json-schema-typed' -import { buildFieldSchema } from '../src/field/schema' +import { buildFieldSchema as buildField } from '../src/field/schema' describe('fields', () => { + function buildFieldSchema(schema: JsfSchema, name: string, required: boolean = false, strictInputType?: boolean, type?: JsfSchemaType) { + return buildField(schema, name, required, schema, strictInputType, type) + } + it('should build a field from a schema', () => { const schema = { type: 'object', @@ -61,6 +65,32 @@ describe('fields', () => { ]) }) + it('should use the original schema to fetch the input type if the schema is false', () => { + const schema: JsfSchema = false + const originalSchema: JsfSchema = { + type: 'object', + title: 'root', + properties: { + age: { 'type': 'number', 'title': 'Age', 'x-jsf-presentation': { inputType: 'number' } }, + amount: { type: 'number', title: 'Amount' }, + }, + } + + const field = buildField(schema, 'root', true, originalSchema) + + // Both fields should have the same input type + expect(field).toEqual( + { + inputType: 'fieldset', + type: 'fieldset', + jsonType: 'boolean', + name: 'root', + required: true, + isVisible: false, + }, + ) + }) + it('should build an object field with multiple properties', () => { const schema = { type: 'object', diff --git a/next/test/fields/array.test.ts b/next/test/fields/array.test.ts index 9f7e0e76d..a95f1005c 100644 --- a/next/test/fields/array.test.ts +++ b/next/test/fields/array.test.ts @@ -1,9 +1,12 @@ -import type { JsfObjectSchema, JsfSchema } from '../../src/types' +import type { JsfObjectSchema, JsfSchema, JsfSchemaType } from '../../src/types' import { describe, expect, it } from '@jest/globals' import { createHeadlessForm } from '../../src' -import { buildFieldSchema } from '../../src/field/schema' +import { buildFieldSchema as buildField } from '../../src/field/schema' describe('buildFieldArray', () => { + function buildFieldSchema(schema: JsfSchema, name: string, required: boolean = false, strictInputType?: boolean, type?: JsfSchemaType) { + return buildField(schema, name, required, schema, strictInputType, type) + } it('should build a field from an array schema', () => { const schema: JsfSchema = { type: 'array', From da7f9db15ce1c82e74125cf89f178db68822d9fd Mon Sep 17 00:00:00 2001 From: Capelo Date: Wed, 4 Jun 2025 11:02:21 +0100 Subject: [PATCH 27/41] fix: fix missing multiple attribute in array --- src/tests/helpers.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tests/helpers.js b/src/tests/helpers.js index 48693408c..e840670de 100644 --- a/src/tests/helpers.js +++ b/src/tests/helpers.js @@ -1543,6 +1543,7 @@ export const schemaDynamicValidationContains = JSONSchemaBuilder() }, 'x-jsf-presentation': { inputType: 'select', + multiple: true, options: [ { label: 'All', From b7095a9413f48aa23658d661ea0cdc6f75169dc0 Mon Sep 17 00:00:00 2001 From: Capelo Date: Wed, 4 Jun 2025 11:02:29 +0100 Subject: [PATCH 28/41] chore: reorder jsdoc params --- next/src/field/schema.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/next/src/field/schema.ts b/next/src/field/schema.ts index 42d54a755..b40915acb 100644 --- a/next/src/field/schema.ts +++ b/next/src/field/schema.ts @@ -210,8 +210,8 @@ function getFieldOptions(schema: NonBooleanJsfSchema) { /** * Get the fields for an object schema * @param schema - The schema of the field - * @param strictInputType - Whether to strictly enforce the input type * @param originalSchema - The original schema (needed for calculating the original input type, for hidden fields) + * @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, originalSchema: NonBooleanJsfSchema, strictInputType?: boolean): Field[] | null { @@ -233,8 +233,8 @@ function getObjectFields(schema: NonBooleanJsfSchema, originalSchema: NonBoolean /** * Get the fields for an array schema * @param schema - The schema of the field - * @param strictInputType - Whether to strictly enforce the input type * @param originalSchema - The original schema (needed for calculating the original input type, for hidden fields) + * @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, originalSchema: NonBooleanJsfSchema, strictInputType?: boolean): Field[] { @@ -271,8 +271,8 @@ function getArrayFields(schema: NonBooleanJsfSchema, originalSchema: NonBooleanJ /** * 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 * @param originalSchema - The original schema (needed for calculating the original input type, for hidden fields) + * @param strictInputType - Whether to strictly enforce the input type * @returns The fields for the schema */ function getFields(schema: NonBooleanJsfSchema, originalSchema: NonBooleanJsfSchema, strictInputType?: boolean): Field[] | null { From d72c8077437f2e51b88d9834d0410b31a02eaea4 Mon Sep 17 00:00:00 2001 From: Capelo Date: Wed, 4 Jun 2025 12:05:08 +0100 Subject: [PATCH 29/41] chore: refactor const fallback --- next/src/validation/const.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/next/src/validation/const.ts b/next/src/validation/const.ts index 118bc7d76..aee9a8d50 100644 --- a/next/src/validation/const.ts +++ b/next/src/validation/const.ts @@ -19,7 +19,8 @@ export function validateConst( schema: NonBooleanJsfSchema, path: ValidationErrorPath = [], ): ValidationError[] { - const constValue = schema.const || schema.value + const constValue = typeof schema.const !== 'undefined' ? schema.const : schema.value + if (constValue === undefined) { return [] } From e1f36f870e6fff9cc7a62a83539d489afb83e76c Mon Sep 17 00:00:00 2001 From: Capelo Date: Wed, 4 Jun 2025 13:57:29 +0100 Subject: [PATCH 30/41] chore: refactor buildFieldSchema to accept object --- next/src/field/schema.ts | 51 ++++++++++++++++++++++++++-------- next/src/form.ts | 8 +++++- next/src/mutations.ts | 8 +++++- next/test/fields.test.ts | 16 +++++++++-- next/test/fields/array.test.ts | 9 +++++- 5 files changed, 76 insertions(+), 16 deletions(-) diff --git a/next/src/field/schema.ts b/next/src/field/schema.ts index b40915acb..03d72363a 100644 --- a/next/src/field/schema.ts +++ b/next/src/field/schema.ts @@ -42,6 +42,8 @@ function addOptions(field: Field, schema: NonBooleanJsfSchema) { * Add fields attribute to a field * @param field - The field to add the fields to * @param schema - The schema of the field + * @param originalSchema - The original schema (needed for calculating the original input type, for hidden fields) + * @param strictInputType - Whether to strictly enforce the input type * @description * This adds the fields attribute to based on the schema's items. * Since options and fields are mutually exclusive, we only add fields if no options were provided. @@ -219,7 +221,13 @@ function getObjectFields(schema: NonBooleanJsfSchema, originalSchema: NonBoolean for (const key in schema.properties) { const isRequired = schema.required?.includes(key) || false - const field = buildFieldSchema(schema.properties[key], key, isRequired, originalSchema.properties?.[key] || schema.properties[key], strictInputType) + const field = buildFieldSchema({ + schema: schema.properties[key], + name: key, + required: isRequired, + originalSchema: originalSchema.properties?.[key] || schema.properties[key], + strictInputType, + }) if (field) { fields.push(field) } @@ -249,7 +257,13 @@ function getArrayFields(schema: NonBooleanJsfSchema, originalSchema: NonBooleanJ for (const key in objectSchema.properties) { const isFieldRequired = objectSchema.required?.includes(key) || false - const field = buildFieldSchema(objectSchema.properties[key], key, isFieldRequired, originalSchema, strictInputType) + const field = buildFieldSchema({ + schema: objectSchema.properties[key], + name: key, + required: isFieldRequired, + originalSchema, + strictInputType, + }) if (field) { field.nameKey = key fields.push(field) @@ -257,7 +271,13 @@ function getArrayFields(schema: NonBooleanJsfSchema, originalSchema: NonBooleanJ } } else { - const field = buildFieldSchema(schema.items, 'item', false, originalSchema, strictInputType) + const field = buildFieldSchema({ + schema: schema.items, + name: 'item', + required: false, + originalSchema, + strictInputType, + }) if (field) { fields.push(field) } @@ -299,6 +319,15 @@ const excludedSchemaProps = [ 'properties', // Handled separately ] +interface BuildFieldSchemaParams { + schema: JsfSchema + name: string + required?: boolean + originalSchema: NonBooleanJsfSchema + strictInputType?: boolean + type?: JsfSchemaType +} + /** * Build a field from any schema * @param schema - The schema of the field @@ -309,14 +338,14 @@ const excludedSchemaProps = [ * @param type - The schema type * @returns The field */ -export function buildFieldSchema( - schema: JsfSchema, - name: string, - required: boolean = false, - originalSchema: NonBooleanJsfSchema, - strictInputType: boolean = false, - type: JsfSchemaType = undefined, -): Field | null { +export function buildFieldSchema({ + schema, + name, + required = false, + originalSchema, + strictInputType = false, + type = undefined, +}: BuildFieldSchemaParams): Field | null { // If schema is boolean false, return a field with isVisible=false if (schema === false) { // If the schema is false, we use the original schema to get the input type diff --git a/next/src/form.ts b/next/src/form.ts index 141d30113..840eda19d 100644 --- a/next/src/form.ts +++ b/next/src/form.ts @@ -255,7 +255,13 @@ export interface CreateHeadlessFormOptions { function buildFields(params: { schema: JsfObjectSchema, originalSchema: JsfObjectSchema, strictInputType?: boolean }): Field[] { const { schema, originalSchema, strictInputType } = params - const fields = buildFieldSchema(schema, 'root', true, originalSchema, strictInputType, 'object')?.fields || [] + const fields = buildFieldSchema({ + schema, + name: 'root', + required: true, + originalSchema, + strictInputType, + })?.fields || [] return fields } diff --git a/next/src/mutations.ts b/next/src/mutations.ts index 6a83614d9..0a4045193 100644 --- a/next/src/mutations.ts +++ b/next/src/mutations.ts @@ -152,7 +152,13 @@ function processBranch(schema: JsfObjectSchema, values: SchemaValue, branch: Jsf */ export function updateFieldProperties(fields: Field[], schema: JsfObjectSchema, originalSchema: JsfSchema): void { // Get new fields from schema - const newFields = buildFieldSchema(schema, 'root', true, originalSchema, false, 'object')?.fields || [] + const newFields = buildFieldSchema({ + schema, + name: 'root', + required: true, + originalSchema, + strictInputType: false, + })?.fields || [] // cycle through the original fields and merge the new fields with the original fields for (const field of fields) { diff --git a/next/test/fields.test.ts b/next/test/fields.test.ts index 82698f889..8e27a37a1 100644 --- a/next/test/fields.test.ts +++ b/next/test/fields.test.ts @@ -5,7 +5,14 @@ import { buildFieldSchema as buildField } from '../src/field/schema' describe('fields', () => { function buildFieldSchema(schema: JsfSchema, name: string, required: boolean = false, strictInputType?: boolean, type?: JsfSchemaType) { - return buildField(schema, name, required, schema, strictInputType, type) + return buildField({ + schema, + name, + required, + type, + originalSchema: schema, + strictInputType, + }) } it('should build a field from a schema', () => { @@ -76,7 +83,12 @@ describe('fields', () => { }, } - const field = buildField(schema, 'root', true, originalSchema) + const field = buildField({ + schema, + name: 'root', + required: true, + originalSchema, + }) // Both fields should have the same input type expect(field).toEqual( diff --git a/next/test/fields/array.test.ts b/next/test/fields/array.test.ts index a95f1005c..e647530d5 100644 --- a/next/test/fields/array.test.ts +++ b/next/test/fields/array.test.ts @@ -5,7 +5,14 @@ import { buildFieldSchema as buildField } from '../../src/field/schema' describe('buildFieldArray', () => { function buildFieldSchema(schema: JsfSchema, name: string, required: boolean = false, strictInputType?: boolean, type?: JsfSchemaType) { - return buildField(schema, name, required, schema, strictInputType, type) + return buildField({ + schema, + name, + required, + type, + originalSchema: schema, + strictInputType, + }) } it('should build a field from an array schema', () => { const schema: JsfSchema = { From cd57b6cf633c651236d7d3473344d3e86c842c1e Mon Sep 17 00:00:00 2001 From: Capelo Date: Wed, 4 Jun 2025 13:58:38 +0100 Subject: [PATCH 31/41] chore: jsdoc --- next/src/field/schema.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/next/src/field/schema.ts b/next/src/field/schema.ts index 03d72363a..4deae8105 100644 --- a/next/src/field/schema.ts +++ b/next/src/field/schema.ts @@ -330,12 +330,13 @@ interface BuildFieldSchemaParams { /** * Build a field from any schema - * @param schema - The schema of the field - * @param name - The name of the field - * @param required - Whether the field is required - * @param originalSchema - The original schema (needed for calculating the original input type, for hidden fields) - * @param strictInputType - Whether to strictly enforce the input type - * @param type - The schema type + * @param params - The parameters for building the field + * @param params.schema - The schema of the field + * @param params.name - The name of the field + * @param params.required - Whether the field is required + * @param params.originalSchema - The original schema (needed for calculating the original input type conditionally hidden fields) + * @param params.strictInputType - Whether to strictly enforce the input type + * @param params.type - The schema type * @returns The field */ export function buildFieldSchema({ From b6aca91dface6021db6de1b3da3360eaddf31caa Mon Sep 17 00:00:00 2001 From: Capelo Date: Wed, 4 Jun 2025 14:01:13 +0100 Subject: [PATCH 32/41] chore: jsdoc --- next/src/field/schema.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/next/src/field/schema.ts b/next/src/field/schema.ts index 4deae8105..610ca0a1f 100644 --- a/next/src/field/schema.ts +++ b/next/src/field/schema.ts @@ -42,7 +42,7 @@ function addOptions(field: Field, schema: NonBooleanJsfSchema) { * Add fields attribute to a field * @param field - The field to add the fields to * @param schema - The schema of the field - * @param originalSchema - The original schema (needed for calculating the original input type, for hidden fields) + * @param originalSchema - The original schema (needed for calculating the original input type on conditionally hidden fields) * @param strictInputType - Whether to strictly enforce the input type * @description * This adds the fields attribute to based on the schema's items. @@ -212,7 +212,7 @@ function getFieldOptions(schema: NonBooleanJsfSchema) { /** * Get the fields for an object schema * @param schema - The schema of the field - * @param originalSchema - The original schema (needed for calculating the original input type, for hidden fields) + * @param originalSchema - The original schema (needed for calculating the original input type on conditionally hidden fields) * @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 */ @@ -241,7 +241,7 @@ function getObjectFields(schema: NonBooleanJsfSchema, originalSchema: NonBoolean /** * Get the fields for an array schema * @param schema - The schema of the field - * @param originalSchema - The original schema (needed for calculating the original input type, for hidden fields) + * @param originalSchema - The original schema (needed for calculating the original input type on conditionally hidden fields) * @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 */ @@ -291,7 +291,7 @@ function getArrayFields(schema: NonBooleanJsfSchema, originalSchema: NonBooleanJ /** * Get the fields for a schema from either `items` or `properties` * @param schema - The schema of the field - * @param originalSchema - The original schema (needed for calculating the original input type, for hidden fields) + * @param originalSchema - The original schema (needed for calculating the original input type on conditionally hidden fields) * @param strictInputType - Whether to strictly enforce the input type * @returns The fields for the schema */ @@ -334,7 +334,7 @@ interface BuildFieldSchemaParams { * @param params.schema - The schema of the field * @param params.name - The name of the field * @param params.required - Whether the field is required - * @param params.originalSchema - The original schema (needed for calculating the original input type conditionally hidden fields) + * @param params.originalSchema - The original schema (needed for calculating the original input type on conditionally hidden fields) * @param params.strictInputType - Whether to strictly enforce the input type * @param params.type - The schema type * @returns The field From 1b1dda12c8e97c13a2379f54ddd5e8a20d2a065d Mon Sep 17 00:00:00 2001 From: Capelo Date: Wed, 4 Jun 2025 14:42:59 +0100 Subject: [PATCH 33/41] chore: refactor, remove unnecessary fn --- next/src/form.ts | 27 ++------------------------- 1 file changed, 2 insertions(+), 25 deletions(-) diff --git a/next/src/form.ts b/next/src/form.ts index 840eda19d..1b4223665 100644 --- a/next/src/form.ts +++ b/next/src/form.ts @@ -153,40 +153,17 @@ interface ValidationErrorWithMessage extends ValidationError { * @param errors - The validation errors * @returns The validation errors with error messages added */ -function addErrorMessages(errors: ValidationError[], rootSchema: JsfSchema): ValidationErrorWithMessage[] { +function addErrorMessages(errors: ValidationError[]): ValidationErrorWithMessage[] { return errors.map((error) => { const { schema, value, validation, customErrorMessage } = error - const completePropertySchema = getCompletePropertySchema(error.path, rootSchema, schema) - return { ...error, - message: getErrorMessage(completePropertySchema, value, validation, customErrorMessage), + message: getErrorMessage(schema, value, validation, customErrorMessage), } }) } -/** - * Get the complete property schema from the root schema and the property schema - * @param path - The path to the property - * @param rootSchema - The root schema - * @param propertySchema - The property schema - * @returns The complete property schema - */ -function getCompletePropertySchema(path: ValidationErrorPath, rootSchema: JsfSchema, propertySchema: JsfSchema): JsfSchema { - // Property name is the last segment of the path - const propertyName = path[path.length - 1] - - // Fetch the main property schema from the root schema - const mainSchema = rootSchema.properties?.[propertyName] as object - - // Return the complete property schema - return { - ...(mainSchema || {}), - ...(propertySchema as object), - } -} - /** * Apply custom error messages from the schema to validation errors * @param errors - The validation errors From 8a5e5ddfb85edf421dbd5895ffa1807731d853ef Mon Sep 17 00:00:00 2001 From: Capelo Date: Wed, 4 Jun 2025 14:47:19 +0100 Subject: [PATCH 34/41] chore: fix lint --- src/tests/helpers.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/tests/helpers.js b/src/tests/helpers.js index e840670de..c5c3cf6af 100644 --- a/src/tests/helpers.js +++ b/src/tests/helpers.js @@ -1468,8 +1468,6 @@ export const schemaDynamicValidationConst = { required: ['a_fieldset', 'validate_tabs', 'mandatory_group_array'], 'x-jsf-order': ['validate_tabs', 'a_fieldset', 'mandatory_group_array', 'a_group_array'], }; -; - export const schemaDynamicValidationMinimumMaximum = JSONSchemaBuilder() .addInput({ a_number: mockNumberInput, From 151ffee606f24897e460a734b9ad77e7d7ff21a1 Mon Sep 17 00:00:00 2001 From: Capelo Date: Wed, 4 Jun 2025 14:47:52 +0100 Subject: [PATCH 35/41] chore: remove old argument --- next/src/form.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/next/src/form.ts b/next/src/form.ts index 1b4223665..671d7ce44 100644 --- a/next/src/form.ts +++ b/next/src/form.ts @@ -202,7 +202,7 @@ function validate(value: SchemaValue, schema: JsfSchema, options: ValidationOpti const result: ValidationResult = {} const errors = validateSchema(value, schema, options) - const errorsWithMessages = addErrorMessages(errors, schema) + const errorsWithMessages = addErrorMessages(errors) const processedErrors = applyCustomErrorMessages(errorsWithMessages, schema) const formErrors = validationErrorsToFormErrors(processedErrors) From e26e69395c0c7be3ed8decee757f793f42abbb42 Mon Sep 17 00:00:00 2001 From: Capelo Date: Wed, 4 Jun 2025 14:54:49 +0100 Subject: [PATCH 36/41] chore: rever need of value/const replacement --- next/src/types.ts | 2 -- next/src/validation/const.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/next/src/types.ts b/next/src/types.ts index 5ceeaee41..7fe0de60d 100644 --- a/next/src/types.ts +++ b/next/src/types.ts @@ -62,8 +62,6 @@ export type JsfSchema = JSONSchema & { 'if'?: JsfSchema 'then'?: JsfSchema 'else'?: JsfSchema - // while value is not part of the spec, we're keeping it for v0 backwards compatibility - 'value'?: SchemaValue 'const'?: SchemaValue // Note: if we don't have this property here, when inspecting any recursive // schema (like an if inside another schema), the required property won't be diff --git a/next/src/validation/const.ts b/next/src/validation/const.ts index aee9a8d50..2d8cc5364 100644 --- a/next/src/validation/const.ts +++ b/next/src/validation/const.ts @@ -19,7 +19,7 @@ export function validateConst( schema: NonBooleanJsfSchema, path: ValidationErrorPath = [], ): ValidationError[] { - const constValue = typeof schema.const !== 'undefined' ? schema.const : schema.value + const constValue = schema.const if (constValue === undefined) { return [] From c922796a2bf5899d574036b404845aae49194d80 Mon Sep 17 00:00:00 2001 From: Capelo Date: Wed, 4 Jun 2025 15:01:24 +0100 Subject: [PATCH 37/41] chore: cleanup type --- next/src/types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/next/src/types.ts b/next/src/types.ts index 7fe0de60d..050c6b5bd 100644 --- a/next/src/types.ts +++ b/next/src/types.ts @@ -62,7 +62,6 @@ export type JsfSchema = JSONSchema & { 'if'?: JsfSchema 'then'?: JsfSchema 'else'?: JsfSchema - 'const'?: SchemaValue // Note: if we don't have this property here, when inspecting any recursive // schema (like an if inside another schema), the required property won't be // present in the type From d6afa5b0df89454815fc714187c5481924feb78c Mon Sep 17 00:00:00 2001 From: Capelo Date: Wed, 4 Jun 2025 15:04:04 +0100 Subject: [PATCH 38/41] chore: rename --- next/src/utils.ts | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/next/src/utils.ts b/next/src/utils.ts index fc91e480d..495a7d189 100644 --- a/next/src/utils.ts +++ b/next/src/utils.ts @@ -60,22 +60,22 @@ function isObject(value: any): boolean { } /** - * Merges obj1 with obj2 recursively - * @param obj1 - The first object to merge - * @param obj2 - The second object to merge + * Merges schema 2 into schema 1 recursively + * @param schema1 - The first schema to merge + * @param schema2 - The second schema to merge */ -export function deepMergeSchemas>(obj1: T, obj2: T): void { +export function deepMergeSchemas>(schema1: T, schema2: T): void { // Handle null/undefined - if (!obj1 || !obj2) { + if (!schema1 || !schema2) { return } // Handle non-objects - if (typeof obj1 !== 'object' || typeof obj2 !== 'object') + if (typeof schema1 !== 'object' || typeof schema2 !== 'object') return // Merge all properties from obj2 into obj1 - for (const [key, value] of Object.entries(obj2)) { + for (const [key, value] of Object.entries(schema2)) { // let's skip merging conditionals such as if, oneOf, then ,else, allOf, anyOf, etc. if (KEYS_TO_SKIP.includes(key)) { continue @@ -84,17 +84,17 @@ export function deepMergeSchemas>(obj1: T, obj2: T // If the value is an object, merge recursively if (isObject(value)) { // If both objects have this key and it's an object, merge recursively - if (isObject(obj1[key])) { - deepMergeSchemas(obj1[key], value) + if (isObject(schema1[key])) { + deepMergeSchemas(schema1[key], value) } // Otherwise, if the value is different, assign it - else if (obj1[key] !== value) { - obj1[key as keyof T] = value + else if (schema1[key] !== value) { + schema1[key as keyof T] = value } } // If the value is an array, cycle through it and merge values if they're different (take objects into account) - else if (obj1[key] && Array.isArray(value)) { - const originalArray = obj1[key] + else if (schema1[key] && Array.isArray(value)) { + const originalArray = schema1[key] // If the destiny value exists and it's an array, cycle through the incoming values and merge if they're different (take objects into account) for (const item of value) { if (item && typeof item === 'object') { @@ -109,12 +109,12 @@ export function deepMergeSchemas>(obj1: T, obj2: T } // Otherwise, just assign it else { - obj1[key as keyof T] = value as T[keyof T] + schema1[key as keyof T] = value as T[keyof T] } } } - else if (obj1[key] !== value) { - obj1[key as keyof T] = value + else if (schema1[key] !== value) { + schema1[key as keyof T] = value } } } From 55e34752cdd0b0b9311b0db21b2772f7dee1514c Mon Sep 17 00:00:00 2001 From: Capelo Date: Wed, 4 Jun 2025 15:13:14 +0100 Subject: [PATCH 39/41] chore: cleanup --- next/src/utils.ts | 44 ++++++++++++++++++++++------------------ next/test/fields.test.ts | 3 +++ 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/next/src/utils.ts b/next/src/utils.ts index 495a7d189..a404125f2 100644 --- a/next/src/utils.ts +++ b/next/src/utils.ts @@ -65,40 +65,43 @@ function isObject(value: any): boolean { * @param schema2 - The second schema to merge */ export function deepMergeSchemas>(schema1: T, schema2: T): void { - // Handle null/undefined + // Handle null/undefined values if (!schema1 || !schema2) { return } // Handle non-objects - if (typeof schema1 !== 'object' || typeof schema2 !== 'object') + if (typeof schema1 !== 'object' || typeof schema2 !== 'object') { return + } - // Merge all properties from obj2 into obj1 - for (const [key, value] of Object.entries(schema2)) { - // let's skip merging conditionals such as if, oneOf, then ,else, allOf, anyOf, etc. + // Merge all properties from schema2 into schema1 + for (const [key, schema2Value] of Object.entries(schema2)) { + // let's skip merging some properties if (KEYS_TO_SKIP.includes(key)) { continue } - // If the value is an object, merge recursively - if (isObject(value)) { - // If both objects have this key and it's an object, merge recursively - if (isObject(schema1[key])) { - deepMergeSchemas(schema1[key], value) + const schema1Value = schema1[key] + + // If the value is an object: + if (isObject(schema2Value)) { + // If both schemas have this key and it's an object, merge recursively + if (isObject(schema1Value)) { + deepMergeSchemas(schema1Value, schema2Value) } - // Otherwise, if the value is different, assign it - else if (schema1[key] !== value) { - schema1[key as keyof T] = value + // Otherwise, if the value is different, just assign it + else if (schema1Value !== schema2Value) { + schema1[key as keyof T] = schema2Value } } // If the value is an array, cycle through it and merge values if they're different (take objects into account) - else if (schema1[key] && Array.isArray(value)) { - const originalArray = schema1[key] + else if (schema1Value && Array.isArray(schema2Value)) { + const originalArray = schema1Value // If the destiny value exists and it's an array, cycle through the incoming values and merge if they're different (take objects into account) - for (const item of value) { + for (const item of schema2Value) { if (item && typeof item === 'object') { - deepMergeSchemas(originalArray, value) + deepMergeSchemas(originalArray, schema2Value) } // "required" is a special case, it only allows for new elements to be added to the array else if (key === 'required') { @@ -109,12 +112,13 @@ export function deepMergeSchemas>(schema1: T, sche } // Otherwise, just assign it else { - schema1[key as keyof T] = value as T[keyof T] + schema1[key as keyof T] = schema2Value as T[keyof T] } } } - else if (schema1[key] !== value) { - schema1[key as keyof T] = value + // Finally, if the value is different, just assign it + else if (schema1[key] !== schema2Value) { + schema1[key as keyof T] = schema2Value } } } diff --git a/next/test/fields.test.ts b/next/test/fields.test.ts index 8e27a37a1..11cd35c74 100644 --- a/next/test/fields.test.ts +++ b/next/test/fields.test.ts @@ -4,6 +4,9 @@ import { TypeName } from 'json-schema-typed' import { buildFieldSchema as buildField } from '../src/field/schema' describe('fields', () => { + /** + * Auxiliary test function to build a field from a schema (consider the schema and original schema as the same) + */ function buildFieldSchema(schema: JsfSchema, name: string, required: boolean = false, strictInputType?: boolean, type?: JsfSchemaType) { return buildField({ schema, From 1b6cc82ed7f43101969afcda094219e889cf12cf Mon Sep 17 00:00:00 2001 From: Capelo Date: Wed, 4 Jun 2025 21:59:04 +0100 Subject: [PATCH 40/41] chore: address PR comments fix: provide empty fields for hidden fieldsets/group-arrays chore: update test feat: add 'value' fallback when const is not present for const validation fix: delete conditional branches after processing them chore: add comment --- next/src/field/schema.ts | 5 +- next/src/mutations.ts | 4 ++ next/src/types.ts | 2 + next/src/utils.ts | 2 +- next/src/validation/const.ts | 2 +- next/test/fields.test.ts | 1 + next/test/validation/const.test.ts | 76 ++++++++++++++++++++++++++++++ 7 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 next/test/validation/const.test.ts diff --git a/next/src/field/schema.ts b/next/src/field/schema.ts index 610ca0a1f..0000902db 100644 --- a/next/src/field/schema.ts +++ b/next/src/field/schema.ts @@ -349,8 +349,10 @@ export function buildFieldSchema({ }: BuildFieldSchemaParams): Field | null { // If schema is boolean false, return a field with isVisible=false if (schema === false) { - // If the schema is false, we use the original schema to get the input type + // If the schema is false (hidden field), we use the original schema to get the input type const inputType = getInputType(type, name, originalSchema, strictInputType) + const inputHasInnerFields = ['fieldset', 'group-array'].includes(inputType) + return { type: inputType, name, @@ -358,6 +360,7 @@ export function buildFieldSchema({ jsonType: 'boolean', required, isVisible: false, + ...(inputHasInnerFields && { fields: [] }), } } diff --git a/next/src/mutations.ts b/next/src/mutations.ts index 0a4045193..be8ab58e3 100644 --- a/next/src/mutations.ts +++ b/next/src/mutations.ts @@ -108,10 +108,14 @@ function applySchemaRules( // If the rule matches, process the then branch if (matches && rule.then) { processBranch(schema, values, rule.then, options, jsonLogicContext) + // Delete the then branch to avoid processing it again when validating the schema + delete rule.then } // If the rule doesn't match, process the else branch else if (!matches && rule.else) { processBranch(schema, values, rule.else, options, jsonLogicContext) + // Delete the else branch to avoid processing it again when validating the schema + delete rule.else } } diff --git a/next/src/types.ts b/next/src/types.ts index 050c6b5bd..d97da7820 100644 --- a/next/src/types.ts +++ b/next/src/types.ts @@ -62,6 +62,8 @@ export type JsfSchema = JSONSchema & { 'if'?: JsfSchema 'then'?: JsfSchema 'else'?: JsfSchema + // while value is not part of the spec, we're keeping it for v0 backwards compatibility + 'value'?: SchemaValue // Note: if we don't have this property here, when inspecting any recursive // schema (like an if inside another schema), the required property won't be // present in the type diff --git a/next/src/utils.ts b/next/src/utils.ts index a404125f2..5d99bd0a1 100644 --- a/next/src/utils.ts +++ b/next/src/utils.ts @@ -52,7 +52,7 @@ export function convertKBToMB(kb: number): number { return Number.parseFloat(mb.toFixed(2)) // Keep 2 decimal places } -// Keys to skip when merging schema objects +// When merging schemas, we should skip merging the if/then/else properties as we could be creating wrong conditions const KEYS_TO_SKIP = ['if', 'then', 'else'] function isObject(value: any): boolean { diff --git a/next/src/validation/const.ts b/next/src/validation/const.ts index 2d8cc5364..aee9a8d50 100644 --- a/next/src/validation/const.ts +++ b/next/src/validation/const.ts @@ -19,7 +19,7 @@ export function validateConst( schema: NonBooleanJsfSchema, path: ValidationErrorPath = [], ): ValidationError[] { - const constValue = schema.const + const constValue = typeof schema.const !== 'undefined' ? schema.const : schema.value if (constValue === undefined) { return [] diff --git a/next/test/fields.test.ts b/next/test/fields.test.ts index 11cd35c74..6f721592c 100644 --- a/next/test/fields.test.ts +++ b/next/test/fields.test.ts @@ -99,6 +99,7 @@ describe('fields', () => { inputType: 'fieldset', type: 'fieldset', jsonType: 'boolean', + fields: [], name: 'root', required: true, isVisible: false, diff --git a/next/test/validation/const.test.ts b/next/test/validation/const.test.ts new file mode 100644 index 000000000..add1715cb --- /dev/null +++ b/next/test/validation/const.test.ts @@ -0,0 +1,76 @@ +import type { NonBooleanJsfSchema } from '../../src/types' +import { describe, expect, it } from '@jest/globals' +import { validateConst } from '../../src/validation/const' + +describe('const schema validation', () => { + it('should return empty array when const value matches', () => { + const schema: NonBooleanJsfSchema = { const: 42 } + const value = 42 + expect(validateConst(value, schema)).toEqual([]) + }) + + it('should return error when const value does not match', () => { + const schema: NonBooleanJsfSchema = { const: 42 } + const value = 43 + const result = validateConst(value, schema) + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + path: [], + validation: 'const', + schema, + value: 43, + }) + }) + + it('should handle nested objects with deep equality', () => { + const schema: NonBooleanJsfSchema = { + const: { foo: { bar: 42 } }, + } + const value = { foo: { bar: 42 } } + expect(validateConst(value, schema)).toEqual([]) + }) + + it('should handle arrays with deep equality', () => { + const schema: NonBooleanJsfSchema = { + const: [1, 2, { foo: 'bar' }], + } + const value = [1, 2, { foo: 'bar' }] + expect(validateConst(value, schema)).toEqual([]) + }) + + it('should return empty array when const is not present', () => { + const schema: NonBooleanJsfSchema = {} + const value = 42 + expect(validateConst(value, schema)).toEqual([]) + }) + + it('should handle schema.value as fallback for const', () => { + const schema: NonBooleanJsfSchema = { value: 42 } + const input = 42 + expect(validateConst(input, schema)).toEqual([]) + }) + + it('should handle different types correctly', () => { + const testCases = [ + { schema: { const: 'string' }, value: 'string', shouldPass: true }, + { schema: { const: true }, value: false, shouldPass: false }, + { schema: { const: null }, value: null, shouldPass: true }, + { schema: { const: 0 }, value: '0', shouldPass: false }, + { schema: { const: [] }, value: [], shouldPass: true }, + { schema: { const: {} }, value: {}, shouldPass: true }, + ] + + testCases.forEach(({ schema, value, shouldPass }) => { + const result = validateConst(value, schema as NonBooleanJsfSchema) + expect(result.length).toBe(shouldPass ? 0 : 1) + }) + }) + + it('should handle path parameter correctly', () => { + const schema: NonBooleanJsfSchema = { const: 42 } + const value = 43 + const path = ['foo', 'bar'] + const result = validateConst(value, schema, path) + expect(result[0].path).toEqual(path) + }) +}) From 9c0eeda74167b8fe623ab593354844c4856d1da7 Mon Sep 17 00:00:00 2001 From: Capelo Date: Wed, 4 Jun 2025 22:35:11 +0100 Subject: [PATCH 41/41] chore: small simplification --- next/src/errors/messages.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/next/src/errors/messages.ts b/next/src/errors/messages.ts index dfe83e11a..588f85698 100644 --- a/next/src/errors/messages.ts +++ b/next/src/errors/messages.ts @@ -13,10 +13,8 @@ function isCheckbox(schema: NonBooleanJsfSchema): boolean { return schema['x-jsf-presentation']?.inputType === 'checkbox' } -const CHECKBOX_ERROR_MESSAGES = { - required: 'Please acknowledge this field', - const: 'Please acknowledge this field', -} +// Both required and const error messages are the same for checkboxes +const CHECKBOX_ACK_ERROR_MESSAGE = 'Please acknowledge this field' export function getErrorMessage( schema: NonBooleanJsfSchema, @@ -31,7 +29,7 @@ export function getErrorMessage( return getTypeErrorMessage(schema.type) case 'required': if (isCheckbox(schema)) { - return CHECKBOX_ERROR_MESSAGES.required + return CHECKBOX_ACK_ERROR_MESSAGE } return 'Required field' case 'forbidden': @@ -39,7 +37,7 @@ export function getErrorMessage( case 'const': // Boolean checkboxes that are required will come as a "const" validation error as the "empty" value is false if (isCheckbox(schema) && value === false) { - return CHECKBOX_ERROR_MESSAGES.const + return CHECKBOX_ACK_ERROR_MESSAGE } return `The only accepted value is ${JSON.stringify(schema.const)}.` case 'enum':