From f3fbf98a7a8b338ad016007810362209ff138182 Mon Sep 17 00:00:00 2001 From: Capelo Date: Tue, 20 May 2025 15:23:55 +0100 Subject: [PATCH 01/22] feat: support computed attributes by mutating the schema in one pass --- next/src/form.ts | 16 +++--- next/src/mutations.ts | 83 ++++++++++++++++++++++++++++++- next/src/types.ts | 2 +- next/src/validation/json-logic.ts | 55 ++++++++++++++------ 4 files changed, 131 insertions(+), 25 deletions(-) diff --git a/next/src/form.ts b/next/src/form.ts index d65ea6a3a..5f2bc972f 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 { applyComputedAttrsToSchema, mutateFields } from './mutations' import { validateSchema } from './validation/schema' export { ValidationOptions } from './validation/schema' @@ -242,22 +242,25 @@ export function createHeadlessForm( ): FormResult { const initialValues = options.initialValues || {} const strictInputType = options.strictInputType || false - const fields = buildFields({ schema, strictInputType }) + // Make a (new) version with all the computed attrs computed and applied + const updatedSchema = applyComputedAttrsToSchema(schema, initialValues) + const fields = buildFields({ schema: updatedSchema, strictInputType }) // Making sure field properties are correct for the initial values - mutateFields(fields, initialValues, schema) + mutateFields(fields, initialValues, updatedSchema, options.validationOptions) // TODO: check if we need this isError variable exposed const isError = false const handleValidation = (value: SchemaValue) => { - const result = validate(value, schema, options.validationOptions) + const updatedSchema = applyComputedAttrsToSchema(schema, value) + 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, schema) + buildFieldsInPlace(fields, updatedSchema) // Updating field properties based on the new form value - mutateFields(fields, value, schema, options.validationOptions) + mutateFields(fields, value, updatedSchema, options.validationOptions) return result } @@ -274,6 +277,7 @@ export function createHeadlessForm( * 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 + * @param jsonLogicContext - JSON Logic context */ function buildFieldsInPlace(fields: Field[], schema: JsfObjectSchema): void { // Clear existing fields array diff --git a/next/src/mutations.ts b/next/src/mutations.ts index 93ed53d01..9f4727214 100644 --- a/next/src/mutations.ts +++ b/next/src/mutations.ts @@ -1,9 +1,11 @@ +import type { JSONSchema } from 'json-schema-typed' import type { Field } from './field/type' -import type { JsfObjectSchema, JsfSchema, 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 { computePropertyValues } 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 @@ -166,3 +168,80 @@ function processBranch(fields: Field[], values: SchemaValue, branch: JsfSchema, // Apply rules to the branch applySchemaRules(fields, values, branch as JsfObjectSchema, options) } + +/** + * 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. + * + * @param schema - The schema to apply computed attributes to + * @param values - The current form values + * @returns The schema with computed attributes applied + */ +export function applyComputedAttrsToSchema(schema: JsfObjectSchema, values: SchemaValue) { + // make a version with all the computed attrs computed and applied + // check if the schema has a 'x-jsf-logic' property with a 'computedValues' property + if (schema['x-jsf-logic']?.computedValues) { + // apply the computed values to the schema + const computedValuesDefinition = schema['x-jsf-logic'].computedValues + const computedValues: Record = {} + + Object.entries(computedValuesDefinition).forEach(([name, definition]) => { + const computedValue = computePropertyValues(definition.rule, values) + computedValues[name] = computedValue + }) + + const schemaCopy = safeDeepClone(schema) + + cycleThroughPropertiesAndApplyValues(schemaCopy, computedValues) + + return schemaCopy + } + else { + return schema + } +} + +/** + * Cycles through the properties of a schema and applies the computed values to it + * @param schemaCopy - The schema to apply computed values to + * @param computedValues - The computed values to apply + */ +function cycleThroughPropertiesAndApplyValues(schemaCopy: JsfObjectSchema, computedValues: Record) { + for (const propertyName in schemaCopy.properties) { + const computedAttrs = schemaCopy.properties[propertyName]['x-jsf-logic-computedAttrs'] + if (computedAttrs) { + const propertySchema = schemaCopy.properties[propertyName] as JsfObjectSchema + cycleThroughAttrsAndApplyValues(propertySchema, computedValues, computedAttrs) + + if (propertySchema.type === 'object' && propertySchema.properties) { + Object.entries(propertySchema.properties).forEach(([_, property]) => { + cycleThroughPropertiesAndApplyValues(property as JsfObjectSchema, computedValues) + }) + } + delete propertySchema['x-jsf-logic-computedAttrs'] + } + } +} + +/** + * Cycles through the attributes of a schema and applies the computed values to it + * @param propertySchema - The schema to apply computed values to + * @param computedValues - The computed values to apply + */ +function cycleThroughAttrsAndApplyValues(propertySchema: JsfObjectSchema, computedValues: Record, computedAttrs: JsfSchema['x-jsf-logic-computedAttrs']) { + for (const key in computedAttrs) { + const attributeName = key as keyof NonBooleanJsfSchema + const computationName = computedAttrs[key] + // const computedValue = computedValues[key] + // If the computation points to a string, it's a computed value and we can apply it directly + if (typeof computationName === 'string') { + const computedValue = computedValues[computationName] + propertySchema[attributeName] = computedValue + } + else { + Object.entries(computationName).forEach(([key, value]) => { + propertySchema[attributeName][key] = computedValues[value] + }) + } + } +} diff --git a/next/src/types.ts b/next/src/types.ts index a08f87fe1..050c6b5bd 100644 --- a/next/src/types.ts +++ b/next/src/types.ts @@ -76,7 +76,7 @@ export type JsfSchema = JSONSchema & { // Extra validations to run. References validations in the `x-jsf-logic` root property. 'x-jsf-logic-validations'?: string[] // Extra attributes to add to the schema. References computedValues in the `x-jsf-logic` root property. - 'x-jsf-logic-computedAttrs'?: Partial> + 'x-jsf-logic-computedAttrs'?: Record } /** diff --git a/next/src/validation/json-logic.ts b/next/src/validation/json-logic.ts index bcd4ccbf8..7b4dc30d2 100644 --- a/next/src/validation/json-logic.ts +++ b/next/src/validation/json-logic.ts @@ -1,5 +1,6 @@ +import type { RulesLogic } from 'json-logic-js' import type { ValidationError, ValidationErrorPath } from '../errors' -import type { JsfSchema, JsonLogicContext, NonBooleanJsfSchema, ObjectValue, SchemaValue } from '../types' +import type { JsfSchema, JsonLogicContext, JsonLogicRules, NonBooleanJsfSchema, ObjectValue, SchemaValue } from '../types' import type { ValidationOptions } from './schema' import jsonLogic from 'json-logic-js' import { validateSchema } from './schema' @@ -102,8 +103,8 @@ export function validateJsonLogicComputedAttributes( // add the new computed attributes to the schema Object.entries(computedAttributes).forEach(([schemaKey, value]) => { if (schemaKey === 'x-jsf-errorMessage') { - const computedErrorMessages = computeErrorMessages( - value as JsfSchema['x-jsf-errorMessage'], + const computedErrorMessages = computePropertyAttributes( + value as Record, jsonLogicContext, ) if (computedErrorMessages) { @@ -168,29 +169,51 @@ function interpolate(message: string, jsonLogicContext: JsonLogicContext | undef } /** - * Computes the error messages for a given schema, running the handlebars expressions through the JSON Logic context + * Calculates the computed attributes for a given schema, + * running the handlebars expressions through the JSON Logic context * - * @param value The error message to compute + * @param value The computed attributes to compute * @param jsonLogicContext The JSON Logic context - * @returns The computed error messages + * @returns The computed computed attributes */ -function computeErrorMessages( - value: JsfSchema['x-jsf-errorMessage'], +function computePropertyAttributes( + value: Record, jsonLogicContext: JsonLogicContext | undefined, -): JsfSchema['x-jsf-errorMessage'] | undefined { +): Record | undefined { if (!value) { return undefined } - const computedErrorMessages: JsfSchema['x-jsf-errorMessage'] = {} + const computedObjectValues: Record = {} - Object.entries(value).forEach(([key, message]) => { - let computedMessage = message - if (containsHandlebars(message)) { - computedMessage = interpolate(message, jsonLogicContext) + Object.entries(value).forEach(([key, value]) => { + let computedMessage = value + // If the message contains handlebars, interpolate it + if (containsHandlebars(value)) { + computedMessage = interpolate(value, jsonLogicContext) } - computedErrorMessages[key] = computedMessage + else { + // Check if the object value is the name of a computed value and if so, calculate it + const rule = jsonLogicContext?.schema?.computedValues?.[value]?.rule + if (rule) { + computedMessage = jsonLogic.apply(rule, replaceUndefinedAndNullValuesWithNaN(jsonLogicContext.value as ObjectValue)) + } + } + computedObjectValues[key] = computedMessage }) - return computedErrorMessages + return computedObjectValues +} + +export function computePropertyValues( + rule: RulesLogic, + values: SchemaValue, +): any { + if (!rule) { + return undefined + } + + const result: any = jsonLogic.apply(rule, replaceUndefinedAndNullValuesWithNaN(values as ObjectValue)) + + return result } From fb4057f4f2b7a53469c1fdc4d839b84b5ab5fbd5 Mon Sep 17 00:00:00 2001 From: Capelo Date: Tue, 20 May 2025 15:31:12 +0100 Subject: [PATCH 02/22] chore: refactor --- next/src/form.ts | 3 +- next/src/mutations.ts | 77 ------------------------------- next/src/validation/json-logic.ts | 77 +++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 78 deletions(-) diff --git a/next/src/form.ts b/next/src/form.ts index 5f2bc972f..3216ca6c5 100644 --- a/next/src/form.ts +++ b/next/src/form.ts @@ -4,7 +4,8 @@ import type { JsfObjectSchema, JsfSchema, SchemaValue } from './types' import type { ValidationOptions } from './validation/schema' import { getErrorMessage } from './errors/messages' import { buildFieldSchema } from './field/schema' -import { applyComputedAttrsToSchema, mutateFields } from './mutations' +import { mutateFields } from './mutations' +import { applyComputedAttrsToSchema } from './validation/json-logic' import { validateSchema } from './validation/schema' export { ValidationOptions } from './validation/schema' diff --git a/next/src/mutations.ts b/next/src/mutations.ts index 9f4727214..583ebaaa7 100644 --- a/next/src/mutations.ts +++ b/next/src/mutations.ts @@ -168,80 +168,3 @@ function processBranch(fields: Field[], values: SchemaValue, branch: JsfSchema, // Apply rules to the branch applySchemaRules(fields, values, branch as JsfObjectSchema, options) } - -/** - * 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. - * - * @param schema - The schema to apply computed attributes to - * @param values - The current form values - * @returns The schema with computed attributes applied - */ -export function applyComputedAttrsToSchema(schema: JsfObjectSchema, values: SchemaValue) { - // make a version with all the computed attrs computed and applied - // check if the schema has a 'x-jsf-logic' property with a 'computedValues' property - if (schema['x-jsf-logic']?.computedValues) { - // apply the computed values to the schema - const computedValuesDefinition = schema['x-jsf-logic'].computedValues - const computedValues: Record = {} - - Object.entries(computedValuesDefinition).forEach(([name, definition]) => { - const computedValue = computePropertyValues(definition.rule, values) - computedValues[name] = computedValue - }) - - const schemaCopy = safeDeepClone(schema) - - cycleThroughPropertiesAndApplyValues(schemaCopy, computedValues) - - return schemaCopy - } - else { - return schema - } -} - -/** - * Cycles through the properties of a schema and applies the computed values to it - * @param schemaCopy - The schema to apply computed values to - * @param computedValues - The computed values to apply - */ -function cycleThroughPropertiesAndApplyValues(schemaCopy: JsfObjectSchema, computedValues: Record) { - for (const propertyName in schemaCopy.properties) { - const computedAttrs = schemaCopy.properties[propertyName]['x-jsf-logic-computedAttrs'] - if (computedAttrs) { - const propertySchema = schemaCopy.properties[propertyName] as JsfObjectSchema - cycleThroughAttrsAndApplyValues(propertySchema, computedValues, computedAttrs) - - if (propertySchema.type === 'object' && propertySchema.properties) { - Object.entries(propertySchema.properties).forEach(([_, property]) => { - cycleThroughPropertiesAndApplyValues(property as JsfObjectSchema, computedValues) - }) - } - delete propertySchema['x-jsf-logic-computedAttrs'] - } - } -} - -/** - * Cycles through the attributes of a schema and applies the computed values to it - * @param propertySchema - The schema to apply computed values to - * @param computedValues - The computed values to apply - */ -function cycleThroughAttrsAndApplyValues(propertySchema: JsfObjectSchema, computedValues: Record, computedAttrs: JsfSchema['x-jsf-logic-computedAttrs']) { - for (const key in computedAttrs) { - const attributeName = key as keyof NonBooleanJsfSchema - const computationName = computedAttrs[key] - // const computedValue = computedValues[key] - // If the computation points to a string, it's a computed value and we can apply it directly - if (typeof computationName === 'string') { - const computedValue = computedValues[computationName] - propertySchema[attributeName] = computedValue - } - else { - Object.entries(computationName).forEach(([key, value]) => { - propertySchema[attributeName][key] = computedValues[value] - }) - } - } -} diff --git a/next/src/validation/json-logic.ts b/next/src/validation/json-logic.ts index 7b4dc30d2..fd190a927 100644 --- a/next/src/validation/json-logic.ts +++ b/next/src/validation/json-logic.ts @@ -217,3 +217,80 @@ export function computePropertyValues( return result } + +/** + * 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. + * + * @param schema - The schema to apply computed attributes to + * @param values - The current form values + * @returns The schema with computed attributes applied + */ +export function applyComputedAttrsToSchema(schema: JsfObjectSchema, values: SchemaValue) { + // If the schema has any computed attributes, we need to apply them by cloning the schema and applying the computed values + // Otherwise, we return the original schema + if (schema['x-jsf-logic']?.computedValues) { + // apply the computed values to the schema + const computedValuesDefinition = schema['x-jsf-logic'].computedValues + const computedValues: Record = {} + + Object.entries(computedValuesDefinition).forEach(([name, definition]) => { + const computedValue = computePropertyValues(definition.rule, values) + computedValues[name] = computedValue + }) + + const schemaCopy = safeDeepClone(schema) + + cycleThroughPropertiesAndApplyValues(schemaCopy, computedValues) + + return schemaCopy + } + else { + return schema + } +} + +/** + * Cycles through the properties of a schema and applies the computed values to it + * @param schemaCopy - The schema to apply computed values to + * @param computedValues - The computed values to apply + */ +function cycleThroughPropertiesAndApplyValues(schemaCopy: JsfObjectSchema, computedValues: Record) { + for (const propertyName in schemaCopy.properties) { + const computedAttrs = schemaCopy.properties[propertyName]['x-jsf-logic-computedAttrs'] + if (computedAttrs) { + const propertySchema = schemaCopy.properties[propertyName] as JsfObjectSchema + cycleThroughAttrsAndApplyValues(propertySchema, computedValues, computedAttrs) + + if (propertySchema.type === 'object' && propertySchema.properties) { + Object.entries(propertySchema.properties).forEach(([_, property]) => { + cycleThroughPropertiesAndApplyValues(property as JsfObjectSchema, computedValues) + }) + } + delete propertySchema['x-jsf-logic-computedAttrs'] + } + } +} + +/** + * Cycles through the attributes of a schema and applies the computed values to it + * @param propertySchema - The schema to apply computed values to + * @param computedValues - The computed values to apply + */ +function cycleThroughAttrsAndApplyValues(propertySchema: JsfObjectSchema, computedValues: Record, computedAttrs: JsfSchema['x-jsf-logic-computedAttrs']) { + for (const key in computedAttrs) { + const attributeName = key as keyof NonBooleanJsfSchema + const computationName = computedAttrs[key] + // const computedValue = computedValues[key] + // If the computation points to a string, it's a computed value and we can apply it directly + if (typeof computationName === 'string') { + const computedValue = computedValues[computationName] + propertySchema[attributeName] = computedValue + } + else { + Object.entries(computationName).forEach(([key, value]) => { + propertySchema[attributeName][key] = computedValues[value] + }) + } + } +} From 115f49e3caae7c66c5d3d6916a758f9cc250d3e6 Mon Sep 17 00:00:00 2001 From: Capelo Date: Tue, 20 May 2025 15:31:32 +0100 Subject: [PATCH 03/22] chore: add missing import --- next/src/validation/json-logic.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/next/src/validation/json-logic.ts b/next/src/validation/json-logic.ts index fd190a927..67ba362c8 100644 --- a/next/src/validation/json-logic.ts +++ b/next/src/validation/json-logic.ts @@ -1,6 +1,6 @@ import type { RulesLogic } from 'json-logic-js' import type { ValidationError, ValidationErrorPath } from '../errors' -import type { JsfSchema, JsonLogicContext, JsonLogicRules, NonBooleanJsfSchema, ObjectValue, SchemaValue } from '../types' +import type { JsfObjectSchema, JsfSchema, JsonLogicContext, JsonLogicRules, NonBooleanJsfSchema, ObjectValue, SchemaValue } from '../types' import type { ValidationOptions } from './schema' import jsonLogic from 'json-logic-js' import { validateSchema } from './schema' From 611ceb257739b80f96a4d1786773f283f6a4b58b Mon Sep 17 00:00:00 2001 From: Capelo Date: Tue, 20 May 2025 15:31:48 +0100 Subject: [PATCH 04/22] chore: lint fix --- next/src/validation/json-logic.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/next/src/validation/json-logic.ts b/next/src/validation/json-logic.ts index 67ba362c8..30d13c878 100644 --- a/next/src/validation/json-logic.ts +++ b/next/src/validation/json-logic.ts @@ -1,6 +1,6 @@ import type { RulesLogic } from 'json-logic-js' import type { ValidationError, ValidationErrorPath } from '../errors' -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 './schema' import jsonLogic from 'json-logic-js' import { validateSchema } from './schema' From 7f727780a71fcb3f1d63b95e26a69a5175254fcf Mon Sep 17 00:00:00 2001 From: Capelo Date: Tue, 20 May 2025 16:18:18 +0100 Subject: [PATCH 05/22] chore: refactor --- next/src/validation/json-logic.ts | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/next/src/validation/json-logic.ts b/next/src/validation/json-logic.ts index 30d13c878..b0f8d338f 100644 --- a/next/src/validation/json-logic.ts +++ b/next/src/validation/json-logic.ts @@ -206,15 +206,17 @@ function computePropertyAttributes( } export function computePropertyValues( + name: string, rule: RulesLogic, values: SchemaValue, ): any { if (!rule) { - return undefined + throw new Error( + `[json-schema-form] json-logic error: Computed value "${name}" doesn't exist`, + ) } const result: any = jsonLogic.apply(rule, replaceUndefinedAndNullValuesWithNaN(values as ObjectValue)) - return result } @@ -235,7 +237,7 @@ export function applyComputedAttrsToSchema(schema: JsfObjectSchema, values: Sche const computedValues: Record = {} Object.entries(computedValuesDefinition).forEach(([name, definition]) => { - const computedValue = computePropertyValues(definition.rule, values) + const computedValue = computePropertyValues(name, definition.rule, values) computedValues[name] = computedValue }) @@ -278,18 +280,31 @@ function cycleThroughPropertiesAndApplyValues(schemaCopy: JsfObjectSchema, compu * @param computedValues - The computed values to apply */ function cycleThroughAttrsAndApplyValues(propertySchema: JsfObjectSchema, computedValues: Record, computedAttrs: JsfSchema['x-jsf-logic-computedAttrs']) { + function evalStringOrTemplate(message: string) { + // If it's a string, we can apply it directly by referencing the computed value by key + if (!containsHandlebars(message)) { + return computedValues[message] + } + + // If it's a template, we need to interpolate it, replacing the handlebars with the computed value + return message.replace(/\{\{(.*?)\}\}/g, (_, computation) => { + const computationName = computation.trim() + return computedValues[computationName] || `{{${computationName}}}` + }) + } + for (const key in computedAttrs) { const attributeName = key as keyof NonBooleanJsfSchema const computationName = computedAttrs[key] - // const computedValue = computedValues[key] - // If the computation points to a string, it's a computed value and we can apply it directly if (typeof computationName === 'string') { - const computedValue = computedValues[computationName] - propertySchema[attributeName] = computedValue + propertySchema[attributeName] = evalStringOrTemplate(computationName) } else { + if (!propertySchema[attributeName]) { + propertySchema[attributeName] = {} + } Object.entries(computationName).forEach(([key, value]) => { - propertySchema[attributeName][key] = computedValues[value] + propertySchema[attributeName][key] = evalStringOrTemplate(value) }) } } From a4072dd1ad6936edc8f805398a9e14271c4da2a0 Mon Sep 17 00:00:00 2001 From: Capelo Date: Tue, 20 May 2025 16:46:59 +0100 Subject: [PATCH 06/22] chore: remove validateJsonLogicComputedAttributes as its done via the schema manipulation --- next/src/validation/json-logic.ts | 141 +-------------- next/src/validation/schema.ts | 3 +- next/test/validation/json-logic.test.ts | 229 ------------------------ 3 files changed, 5 insertions(+), 368 deletions(-) diff --git a/next/src/validation/json-logic.ts b/next/src/validation/json-logic.ts index b0f8d338f..76196c766 100644 --- a/next/src/validation/json-logic.ts +++ b/next/src/validation/json-logic.ts @@ -70,141 +70,6 @@ export function validateJsonLogicRules( }).flat() } -/** - * Validates the JSON Logic computed attributes for a given schema. - * - * @param {SchemaValue} values - Current form values. - * @param {NonBooleanJsfSchema} schema - JSON Schema to validate. - * @param {ValidationOptions} options - Validation options. - * @param {JsonLogicContext | undefined} jsonLogicContext - JSON Logic context. - * @param {ValidationErrorPath} path - Current validation error path. - * @throws {Error} If a computed attribute has missing rule. - */ -export function validateJsonLogicComputedAttributes( - values: SchemaValue, - schema: NonBooleanJsfSchema, - options: ValidationOptions = {}, - jsonLogicContext: JsonLogicContext | undefined, - path: ValidationErrorPath = [], -): ValidationError[] { - const computedAttributes = schema['x-jsf-logic-computedAttrs'] - - // if the current schema has no computed attributes, we skip the validation - if (!computedAttributes || Object.keys(computedAttributes).length === 0) { - return [] - } - - // Create a copy of the schema - const schemaCopy: NonBooleanJsfSchema = safeDeepClone(schema) - - // Remove the computed attributes from the schema - delete schemaCopy['x-jsf-logic-computedAttrs'] - - // add the new computed attributes to the schema - Object.entries(computedAttributes).forEach(([schemaKey, value]) => { - if (schemaKey === 'x-jsf-errorMessage') { - const computedErrorMessages = computePropertyAttributes( - value as Record, - jsonLogicContext, - ) - if (computedErrorMessages) { - schemaCopy['x-jsf-errorMessage'] = computedErrorMessages - } - } - else { - const validationName = value as string - const computedAttributeRule = jsonLogicContext?.schema?.computedValues?.[validationName]?.rule - - const formValue = jsonLogicContext?.value - - // if the computation name does not reference any valid rule, we throw an error - if (!computedAttributeRule) { - throw new Error(`[json-schema-form] json-logic error: Computed value "${validationName}" has missing rule.`) - } - - const result: any = jsonLogic.apply(computedAttributeRule, replaceUndefinedAndNullValuesWithNaN(formValue as ObjectValue)) - - // If running the apply function returns null, some variables are probably missing - if (result === null) { - return - } - - schemaCopy[schemaKey as keyof NonBooleanJsfSchema] = result - } - }) - - // Validate the modified schema - return validateSchema(values, schemaCopy, options, path, jsonLogicContext) -} - -/** - * Interpolates handlebars expressions in a message with computed values - * @param message The message containing handlebars expressions - * @param jsonLogicContext JSON Logic context containing computations - * @returns Interpolated message with computed values - */ -function interpolate(message: string, jsonLogicContext: JsonLogicContext | undefined): string { - if (!jsonLogicContext?.schema?.computedValues) { - console.warn('No computed values found in the JSON Logic context') - return message - } - - return message.replace(/\{\{(.*?)\}\}/g, (_, computation) => { - const computationName = computation.trim() - const computedRule = jsonLogicContext.schema.computedValues?.[computationName]?.rule - - if (!computedRule) { - throw new Error( - `[json-schema-form] json-logic error: Computed value "${computationName}" doesn't exist`, - ) - } - - const result = jsonLogic.apply( - computedRule, - replaceUndefinedAndNullValuesWithNaN(jsonLogicContext.value as ObjectValue), - ) - - return result?.toString() ?? `{{${computationName}}}` - }) -} - -/** - * Calculates the computed attributes for a given schema, - * running the handlebars expressions through the JSON Logic context - * - * @param value The computed attributes to compute - * @param jsonLogicContext The JSON Logic context - * @returns The computed computed attributes - */ -function computePropertyAttributes( - value: Record, - jsonLogicContext: JsonLogicContext | undefined, -): Record | undefined { - if (!value) { - return undefined - } - - const computedObjectValues: Record = {} - - Object.entries(value).forEach(([key, value]) => { - let computedMessage = value - // If the message contains handlebars, interpolate it - if (containsHandlebars(value)) { - computedMessage = interpolate(value, jsonLogicContext) - } - else { - // Check if the object value is the name of a computed value and if so, calculate it - const rule = jsonLogicContext?.schema?.computedValues?.[value]?.rule - if (rule) { - computedMessage = jsonLogic.apply(rule, replaceUndefinedAndNullValuesWithNaN(jsonLogicContext.value as ObjectValue)) - } - } - computedObjectValues[key] = computedMessage - }) - - return computedObjectValues -} - export function computePropertyValues( name: string, rule: RulesLogic, @@ -229,10 +94,12 @@ export function computePropertyValues( * @returns The schema with computed attributes applied */ export function applyComputedAttrsToSchema(schema: JsfObjectSchema, values: SchemaValue) { - // If the schema has any computed attributes, we need to apply them by cloning the schema and applying the computed values + // 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 if (schema['x-jsf-logic']?.computedValues) { - // apply the computed values to the schema const computedValuesDefinition = schema['x-jsf-logic'].computedValues const computedValues: Record = {} diff --git a/next/src/validation/schema.ts b/next/src/validation/schema.ts index 5c9cfc7d9..82bd07b51 100644 --- a/next/src/validation/schema.ts +++ b/next/src/validation/schema.ts @@ -7,7 +7,7 @@ import { validateConst } from './const' import { validateDate } from './custom/date' import { validateEnum } from './enum' import { validateFile } from './file' -import { validateJsonLogicComputedAttributes, validateJsonLogicRules } from './json-logic' +import { validateJsonLogicRules } from './json-logic' import { validateNumber } from './number' import { validateObject } from './object' import { validateString } from './string' @@ -251,7 +251,6 @@ export function validateSchema( // Custom validations ...validateDate(value, schema, options, path), ...validateJsonLogicSchema(value, jsonLogicRootSchema, options, path, jsonLogicContext), - ...validateJsonLogicComputedAttributes(value, schema, options, jsonLogicContext, path), ...validateJsonLogicRules(schema, jsonLogicContext, path), ] } diff --git a/next/test/validation/json-logic.test.ts b/next/test/validation/json-logic.test.ts index f808b9690..174fa1d8b 100644 --- a/next/test/validation/json-logic.test.ts +++ b/next/test/validation/json-logic.test.ts @@ -351,232 +351,3 @@ describe('validateJsonLogicRules', () => { }) }) }) - -describe('validateJsonLogicComputedAttributes', () => { - const validateJsonLogicComputedAttributes = JsonLogicValidation.validateJsonLogicComputedAttributes - - beforeEach(() => { - jest.clearAllMocks() - }) - - it('returns empty array when no computed attributes exist', () => { - const schema: NonBooleanJsfSchema = { - type: 'object', - properties: {}, - } - - const result = validateJsonLogicComputedAttributes({}, schema, {}, undefined, []) - expect(result).toEqual([]) - }) - - it('ignores computed attributes when computation name does not reference a valid rule', () => { - const schema: NonBooleanJsfSchema = { - 'type': 'object', - 'properties': { - age: { type: 'number' }, - }, - 'x-jsf-logic-computedAttrs': { - minimum: 'nonexistentRule', - }, - } - - const jsonLogicContext: JsonLogicContext = { schema: { computedValues: {} }, value: { age: 16 } } - expect(() => validateJsonLogicComputedAttributes({ age: 16 }, schema, {}, jsonLogicContext, [])).toThrow( - `[json-schema-form] json-logic error: Computed value "nonexistentRule" has missing rule.`, - ) - - expect(jsonLogic.apply).not.toHaveBeenCalled() - }) - - it('applies computed attribute when rule exists and updates schema validation', () => { - const schema: NonBooleanJsfSchema = { - 'type': 'number', - 'x-jsf-logic-computedAttrs': { - minimum: 'computeMinAge', - }, - } - - const jsonLogicContext: JsonLogicContext = { - schema: { - computedValues: { - computeMinAge: { - rule: { '+': [{ var: 'baseAge' }, 5] }, - }, - }, - }, - value: { baseAge: 13 }, - }; - - // Mock jsonLogic.apply to return 18 (13 + 5) - (jsonLogic.apply as jest.Mock).mockReturnValue(18) - - // Test with value less than computed minimum - let result = validateJsonLogicComputedAttributes(17, schema, {}, jsonLogicContext, [], - ) - - expect(result).toHaveLength(1) - expect(result[0].validation).toBe('minimum') - - // Test with value equal to computed minimum - result = validateJsonLogicComputedAttributes(18, schema, {}, jsonLogicContext, []) - expect(result).toHaveLength(0) - - expect(jsonLogic.apply).toHaveBeenCalledWith({ '+': [{ var: 'baseAge' }, 5] }, { baseAge: 13 }) - }) - - it('ignores undefined results from json logic computation', () => { - const schema: NonBooleanJsfSchema = { - 'type': 'number', - 'x-jsf-logic-computedAttrs': { - minimum: 'computeMinAge', - }, - } - - const jsonLogicContext: JsonLogicContext = { - schema: { - computedValues: { - computeMinAge: { - rule: { var: 'nonexistentVar' }, - }, - }, - }, - value: { baseAge: 13 }, - }; - - // Mock jsonLogic.apply to return undefined - (jsonLogic.apply as jest.Mock).mockReturnValue(undefined) - - const result = validateJsonLogicComputedAttributes(17, schema, {}, jsonLogicContext, []) - expect(result).toHaveLength(0) - expect(jsonLogic.apply).toHaveBeenCalledWith({ var: 'nonexistentVar' }, { baseAge: 13 }) - }) - - it('handles multiple computed attributes', () => { - const schema: NonBooleanJsfSchema = { - 'type': 'number', - 'x-jsf-logic-computedAttrs': { - minimum: 'computeMinAge', - maximum: 'computeMaxAge', - }, - } - - const jsonLogicContext: JsonLogicContext = { - schema: { - computedValues: { - computeMinAge: { - rule: { '+': [{ var: 'baseAge' }, 5] }, - }, - computeMaxAge: { - rule: { '*': [{ var: 'baseAge' }, 2] }, - }, - }, - }, - value: { baseAge: 10 }, - }; - - // Mock jsonLogic.apply to return 15 for min (10 + 5) and 20 for max (10 * 2) - (jsonLogic.apply as jest.Mock) - .mockReturnValueOnce(15) // minimum - .mockReturnValueOnce(20) // maximum - - // Test with value within computed range - let result = validateJsonLogicComputedAttributes(17, schema, {}, jsonLogicContext, []) - expect(result).toHaveLength(0); - - (jsonLogic.apply as jest.Mock) - .mockReturnValueOnce(15) // minimum - .mockReturnValueOnce(20) // maximum - - // Test with value outside computed range - result = validateJsonLogicComputedAttributes(21, schema, {}, jsonLogicContext, []) - expect(result).toHaveLength(1) - expect(result[0].validation).toBe('maximum') - - expect(jsonLogic.apply).toHaveBeenCalledTimes(4) - }) - - it('handles null and undefined values by converting them to NaN', () => { - const schema: NonBooleanJsfSchema = { - 'type': 'number', - 'x-jsf-logic-computedAttrs': { - minimum: 'computeMinAge', - }, - } - - const jsonLogicContext: JsonLogicContext = { - schema: { - computedValues: { - computeMinAge: { - rule: { '+': [{ var: 'baseAge' }, 5] }, - }, - }, - }, - value: { baseAge: undefined }, - } - - validateJsonLogicComputedAttributes(17, schema, {}, jsonLogicContext, []) - - expect(jsonLogic.apply).toHaveBeenCalledWith({ '+': [{ var: 'baseAge' }, 5] }, { baseAge: Number.NaN }) - }) - - describe('Computed "error message" attribute', () => { - it('should interpolate computed attribute values in custom error messages', () => { - const schema: NonBooleanJsfSchema = { - 'properties': { - someProperty: { - 'type': 'number', - 'x-jsf-logic-computedAttrs': { - 'minimum': 'computeMinAge', - 'x-jsf-errorMessage': { - minimum: 'Must be at least {{computeMinAge}} units', - }, - }, - }, - }, - 'x-jsf-logic': { - computedValues: { - computeMinAge: { - rule: { '+': [{ var: 'someProperty' }, 5] }, - }, - }, - }, - }; - - (jsonLogic.apply as jest.Mock).mockReturnValue(15) - - const result = validateSchema({ someProperty: 10 }, schema) - expect(result).toHaveLength(1) - expect(result[0].validation).toBe('minimum') - expect(result[0].schema['x-jsf-errorMessage']?.minimum).toBe('Must be at least 15 units') - }) - - it('should use the variable name if the computed attribute is not found', () => { - const schema: NonBooleanJsfSchema = { - 'properties': { - someProperty: { - 'type': 'number', - 'x-jsf-logic-computedAttrs': { - 'minimum': 'computeMinAge', - 'x-jsf-errorMessage': { - minimum: 'Must be at least {{invalidVar}} units', - }, - }, - }, - }, - 'x-jsf-logic': { - computedValues: { - computeMinAge: { - rule: { '+': [{ var: 'someProperty' }, 5] }, - }, - }, - }, - }; - - (jsonLogic.apply as jest.Mock).mockReturnValue(15) - - expect(() => validateSchema({ someProperty: 10 }, schema)).toThrow( - `[json-schema-form] json-logic error: Computed value "invalidVar" doesn't exist`, - ) - }) - }) -}) From 2d16dec025f19379ffbb09067256465ffcc3503a Mon Sep 17 00:00:00 2001 From: Capelo Date: Tue, 20 May 2025 17:31:41 +0100 Subject: [PATCH 07/22] fix: fix missing recursion --- next/src/validation/json-logic.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/next/src/validation/json-logic.ts b/next/src/validation/json-logic.ts index 76196c766..ac30ed30a 100644 --- a/next/src/validation/json-logic.ts +++ b/next/src/validation/json-logic.ts @@ -93,7 +93,7 @@ export function computePropertyValues( * @param values - The current form values * @returns The schema with computed attributes applied */ -export function applyComputedAttrsToSchema(schema: JsfObjectSchema, values: SchemaValue) { +export function applyComputedAttrsToSchema(schema: JsfObjectSchema, values: SchemaValue): JsfObjectSchema { // If the schema has any computed attributes, we need to: // - clone the original schema // - calculate all the computed values @@ -127,17 +127,16 @@ export function applyComputedAttrsToSchema(schema: JsfObjectSchema, values: Sche function cycleThroughPropertiesAndApplyValues(schemaCopy: JsfObjectSchema, computedValues: Record) { for (const propertyName in schemaCopy.properties) { const computedAttrs = schemaCopy.properties[propertyName]['x-jsf-logic-computedAttrs'] + const propertySchema = schemaCopy.properties[propertyName] as JsfObjectSchema if (computedAttrs) { - const propertySchema = schemaCopy.properties[propertyName] as JsfObjectSchema cycleThroughAttrsAndApplyValues(propertySchema, computedValues, computedAttrs) + } - if (propertySchema.type === 'object' && propertySchema.properties) { - Object.entries(propertySchema.properties).forEach(([_, property]) => { - cycleThroughPropertiesAndApplyValues(property as JsfObjectSchema, computedValues) - }) - } - delete propertySchema['x-jsf-logic-computedAttrs'] + if (propertySchema.type === 'object' && propertySchema.properties) { + cycleThroughPropertiesAndApplyValues(propertySchema, computedValues) } + + delete propertySchema['x-jsf-logic-computedAttrs'] } } From 134ae9b1483bc486b7e86f94774713d93f897805 Mon Sep 17 00:00:00 2001 From: Capelo Date: Tue, 20 May 2025 17:36:08 +0100 Subject: [PATCH 08/22] chore: add unit test for applyComputedAttrsToSchema --- next/test/validation/json-logic.test.ts | 115 +++++++++++++++++++++++- 1 file changed, 114 insertions(+), 1 deletion(-) diff --git a/next/test/validation/json-logic.test.ts b/next/test/validation/json-logic.test.ts index 174fa1d8b..9afed5430 100644 --- a/next/test/validation/json-logic.test.ts +++ b/next/test/validation/json-logic.test.ts @@ -1,4 +1,4 @@ -import type { JsfSchema, JsonLogicContext, NonBooleanJsfSchema } from '../../src/types' +import type { JsfObjectSchema, JsfSchema, JsonLogicContext, NonBooleanJsfSchema } from '../../src/types' import { beforeEach, describe, expect, it, jest } from '@jest/globals' import jsonLogic from 'json-logic-js' import * as JsonLogicValidation from '../../src/validation/json-logic' @@ -351,3 +351,116 @@ describe('validateJsonLogicRules', () => { }) }) }) + +describe('applyComputedAttrsToSchema', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('returns original schema when no computed values exist', () => { + const schema: JsfObjectSchema = { + type: 'object', + properties: { + foo: { type: 'string' }, + }, + } + + const result = JsonLogicValidation.applyComputedAttrsToSchema(schema, {}) + expect(result).toBe(schema) + }) + + it('applies computed values to schema properties', () => { + const schema: JsfObjectSchema = { + 'type': 'object', + 'properties': { + person: { + type: 'object', + properties: { + age: { + 'type': 'number', + 'x-jsf-logic-computedAttrs': { + minimum: 'computedMin', + }, + }, + }, + }, + }, + 'x-jsf-logic': { + computedValues: { + computedMin: { + rule: { '==': [{ var: 'person.age' }, 21] }, + }, + }, + }, + }; + + (jsonLogic.apply as jest.Mock).mockReturnValue(21) + + const result: JsfObjectSchema = JsonLogicValidation.applyComputedAttrsToSchema(schema, { person: { age: 21 } }) + + expect(result).not.toBe(schema) + const ageProperties = result.properties?.person?.properties?.age as JsfObjectSchema + expect(ageProperties?.minimum).toBe(21) + expect(ageProperties?.['x-jsf-logic-computedAttrs']).toBeUndefined() + }) + + it('handles handlebars template strings in computed values', () => { + const schema: JsfObjectSchema = { + 'type': 'object', + 'properties': { + age: { + 'type': 'number', + 'x-jsf-logic-computedAttrs': { + description: 'Minimum allowed is {{computedMin}}', + }, + }, + }, + 'x-jsf-logic': { + computedValues: { + computedMin: { + rule: { '+': [19, 2] }, + }, + }, + }, + }; + + (jsonLogic.apply as jest.Mock).mockReturnValue(21) + + const result = JsonLogicValidation.applyComputedAttrsToSchema(schema, { age: 30 }) + + const ageProperties = result.properties?.age as JsfObjectSchema + + expect(ageProperties?.description).toBe('Minimum allowed is 21') + }) + + it('handles object-type computed attributes', () => { + const schema: JsfObjectSchema = { + 'type': 'object', + 'properties': { + age: { + 'type': 'number', + 'x-jsf-logic-computedAttrs': { + 'minimum': 'computedMin', + 'x-jsf-errorMessage': { + minimum: 'This is a custom message {{computedMin}}', + }, + }, + }, + }, + 'x-jsf-logic': { + computedValues: { + computedMin: { + rule: { '+': [{ var: 'age' }, 2] }, + }, + }, + }, + }; + + (jsonLogic.apply as jest.Mock).mockReturnValue(21) + + const result = JsonLogicValidation.applyComputedAttrsToSchema(schema, { age: 19 }) + + const ageProperties = result.properties?.age as JsfObjectSchema + expect(ageProperties?.minimum).toBe(21) + }) +}) From f01bb598b3c8b6c741daa312bbd217a7ddf0a873 Mon Sep 17 00:00:00 2001 From: Capelo Date: Tue, 20 May 2025 17:48:13 +0100 Subject: [PATCH 09/22] fix: fix lint --- next/src/mutations.ts | 6 ++---- next/src/validation/json-logic.ts | 2 -- next/test/validation/json-logic.test.ts | 1 - 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/next/src/mutations.ts b/next/src/mutations.ts index 583ebaaa7..93ed53d01 100644 --- a/next/src/mutations.ts +++ b/next/src/mutations.ts @@ -1,11 +1,9 @@ -import type { JSONSchema } from 'json-schema-typed' import type { Field } from './field/type' -import type { JsfObjectSchema, JsfSchema, JsonLogicContext, NonBooleanJsfSchema, ObjectValue, SchemaValue } from './types' +import type { JsfObjectSchema, JsfSchema, NonBooleanJsfSchema, ObjectValue, SchemaValue } from './types' import type { ValidationOptions } from './validation/schema' import { buildFieldSchema } from './field/schema' -import { computePropertyValues } from './validation/json-logic' import { validateSchema } from './validation/schema' -import { isObjectValue, safeDeepClone } from './validation/util' +import { isObjectValue } from './validation/util' /** * Updates field properties based on JSON schema conditional rules diff --git a/next/src/validation/json-logic.ts b/next/src/validation/json-logic.ts index ac30ed30a..9bfd209b5 100644 --- a/next/src/validation/json-logic.ts +++ b/next/src/validation/json-logic.ts @@ -1,9 +1,7 @@ import type { RulesLogic } from 'json-logic-js' import type { ValidationError, ValidationErrorPath } from '../errors' import type { JsfObjectSchema, JsfSchema, JsonLogicContext, NonBooleanJsfSchema, ObjectValue, SchemaValue } from '../types' -import type { ValidationOptions } from './schema' import jsonLogic from 'json-logic-js' -import { validateSchema } from './schema' import { safeDeepClone } from './util' /** diff --git a/next/test/validation/json-logic.test.ts b/next/test/validation/json-logic.test.ts index 9afed5430..2009a8256 100644 --- a/next/test/validation/json-logic.test.ts +++ b/next/test/validation/json-logic.test.ts @@ -3,7 +3,6 @@ import { beforeEach, describe, expect, it, jest } from '@jest/globals' import jsonLogic from 'json-logic-js' import * as JsonLogicValidation from '../../src/validation/json-logic' import * as SchemaValidation from '../../src/validation/schema' -import { validateSchema } from '../../src/validation/schema' import { errorLike } from '../test-utils' const validateJsonLogicRules = JsonLogicValidation.validateJsonLogicRules From 2e0bbb9f2e338a10c627fcd5449fac93199db8f0 Mon Sep 17 00:00:00 2001 From: Capelo Date: Tue, 20 May 2025 17:53:15 +0100 Subject: [PATCH 10/22] fix: fix lint --- next/src/form.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/next/src/form.ts b/next/src/form.ts index 3216ca6c5..fd9df916f 100644 --- a/next/src/form.ts +++ b/next/src/form.ts @@ -278,7 +278,6 @@ export function createHeadlessForm( * 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 - * @param jsonLogicContext - JSON Logic context */ function buildFieldsInPlace(fields: Field[], schema: JsfObjectSchema): void { // Clear existing fields array From 03842c9f93b9201f24575f014ed8d77e9c6d8da4 Mon Sep 17 00:00:00 2001 From: Capelo Date: Fri, 23 May 2025 11:00:11 +0100 Subject: [PATCH 11/22] feat: support computed attributes on conditional branches --- next/src/form.ts | 4 +-- next/src/mutations.ts | 36 ++++++++++++++------- next/src/validation/json-logic.ts | 43 +++++++++++++++++++++---- next/src/validation/schema.ts | 16 +++------ next/test/validation/json-logic.test.ts | 8 ++--- 5 files changed, 71 insertions(+), 36 deletions(-) diff --git a/next/src/form.ts b/next/src/form.ts index fd9df916f..da7bed8cf 100644 --- a/next/src/form.ts +++ b/next/src/form.ts @@ -244,7 +244,7 @@ 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, initialValues) + 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 @@ -254,7 +254,7 @@ export function createHeadlessForm( const isError = false const handleValidation = (value: SchemaValue) => { - const updatedSchema = applyComputedAttrsToSchema(schema, value) + const updatedSchema = applyComputedAttrsToSchema(schema, schema['x-jsf-logic']?.computedValues, value) 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 93ed53d01..b6dddd496 100644 --- a/next/src/mutations.ts +++ b/next/src/mutations.ts @@ -1,7 +1,8 @@ import type { Field } from './field/type' -import type { JsfObjectSchema, JsfSchema, 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 { applyComputedAttrsToSchema, computePropertyValues, getJsonLogicContextFromSchema } from './validation/json-logic' import { validateSchema } from './validation/schema' import { isObjectValue } from './validation/util' @@ -22,8 +23,12 @@ export function mutateFields( return } - // Apply rules to current level of fields - applySchemaRules(fields, values, schema, options) + // 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 + + // Apply schema rules to current level of fields + applySchemaRules(fields, values, schema, options, jsonLogicContext) // Process nested object fields that have conditional logic for (const fieldName in schema.properties) { @@ -31,7 +36,7 @@ export function mutateFields( const field = fields.find(field => field.name === fieldName) if (field?.fields) { - applySchemaRules(field.fields, values[fieldName], fieldSchema as JsfObjectSchema, options) + applySchemaRules(field.fields, values[fieldName], fieldSchema as JsfObjectSchema, options, jsonLogicContext) } } } @@ -77,13 +82,14 @@ function evaluateConditional( * @param values - The current form values * @param schema - The JSON schema containing the rules * @param options - Validation options - * + * @param jsonLogicContext - JSON Logic context */ function applySchemaRules( fields: Field[], values: SchemaValue, schema: JsfObjectSchema, options: ValidationOptions = {}, + jsonLogicContext: JsonLogicContext | undefined, ) { if (!isObjectValue(values)) { return @@ -108,11 +114,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) + processBranch(fields, 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) + processBranch(fields, values, rule.else, options, jsonLogicContext) } } } @@ -123,14 +129,20 @@ function applySchemaRules( * @param values - The current form values * @param branch - The branch (schema representing and then/else) to process * @param options - Validation options + * @param jsonLogicContext - JSON Logic context */ -function processBranch(fields: Field[], values: SchemaValue, branch: JsfSchema, options: ValidationOptions = {}) { +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. - // Note: False schemas mean the field should be hidden in the form (isVisible = false) for (const fieldName in branch.properties) { - const fieldSchema = branch.properties[fieldName] + let fieldSchema = branch.properties[fieldName] + + // If the field schema has computed attributes, we need to apply them to the field schema + if (fieldSchema['x-jsf-logic-computedAttrs']) { + fieldSchema = applyComputedAttrsToSchema(fieldSchema as JsfObjectSchema, jsonLogicContext?.schema.computedValues, values) + } + 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) @@ -139,7 +151,7 @@ function processBranch(fields: Field[], values: SchemaValue, branch: JsfSchema, } // If the field has inner fields, we need to process them else if (field?.fields) { - processBranch(field.fields, values, fieldSchema) + 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 @@ -164,5 +176,5 @@ function processBranch(fields: Field[], values: SchemaValue, branch: JsfSchema, } // Apply rules to the branch - applySchemaRules(fields, values, branch as JsfObjectSchema, options) + 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 9bfd209b5..269fffc06 100644 --- a/next/src/validation/json-logic.ts +++ b/next/src/validation/json-logic.ts @@ -1,9 +1,28 @@ import type { RulesLogic } from 'json-logic-js' import type { ValidationError, ValidationErrorPath } from '../errors' -import type { JsfObjectSchema, JsfSchema, JsonLogicContext, NonBooleanJsfSchema, ObjectValue, SchemaValue } from '../types' +import type { JsfObjectSchema, JsfSchema, JsonLogicContext, JsonLogicRules, JsonLogicSchema, NonBooleanJsfSchema, ObjectValue, SchemaValue } from '../types' import jsonLogic from 'json-logic-js' import { safeDeepClone } from './util' +/** + * Builds a json-logic context based on a schema and the current value + * @param schema - The schema to build the context from + * @param value - The current value of the form + * @returns The json-logic context + */ +export function getJsonLogicContextFromSchema(schema: JsonLogicSchema, value: SchemaValue): JsonLogicContext { + const { validations, computedValues } = schema + const jsonLogicRules: JsonLogicRules = { + validations, + computedValues, + } + const jsonLogicContext = { + schema: jsonLogicRules, + value, + } + return jsonLogicContext +} + /** * Checks if a string contains handlebars syntax ({{...}}) * @param value The string to check @@ -88,17 +107,17 @@ export function computePropertyValues( * it creates a deep clone of the schema and applies the computed values to the clone,otherwise it returns the original schema. * * @param schema - The schema to apply computed attributes to + * @param computedValues - The computed values to apply * @param values - The current form values * @returns The schema with computed attributes applied */ -export function applyComputedAttrsToSchema(schema: JsfObjectSchema, values: SchemaValue): JsfObjectSchema { +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 - if (schema['x-jsf-logic']?.computedValues) { - const computedValuesDefinition = schema['x-jsf-logic'].computedValues + if (computedValuesDefinition) { const computedValues: Record = {} Object.entries(computedValuesDefinition).forEach(([name, definition]) => { @@ -123,9 +142,8 @@ export function applyComputedAttrsToSchema(schema: JsfObjectSchema, values: Sche * @param computedValues - The computed values to apply */ function cycleThroughPropertiesAndApplyValues(schemaCopy: JsfObjectSchema, computedValues: Record) { - for (const propertyName in schemaCopy.properties) { - const computedAttrs = schemaCopy.properties[propertyName]['x-jsf-logic-computedAttrs'] - const propertySchema = schemaCopy.properties[propertyName] as JsfObjectSchema + function processProperty(propertySchema: JsfObjectSchema) { + const computedAttrs = propertySchema['x-jsf-logic-computedAttrs'] if (computedAttrs) { cycleThroughAttrsAndApplyValues(propertySchema, computedValues, computedAttrs) } @@ -136,6 +154,17 @@ function cycleThroughPropertiesAndApplyValues(schemaCopy: JsfObjectSchema, compu delete propertySchema['x-jsf-logic-computedAttrs'] } + + // If this is a full property schema, we need to cycle through the properties and apply the computed values + // Otherwise, just process the property + if (schemaCopy.properties) { + for (const propertyName in schemaCopy.properties) { + processProperty(schemaCopy.properties[propertyName] as JsfObjectSchema) + } + } + else { + processProperty(schemaCopy) + } } /** diff --git a/next/src/validation/schema.ts b/next/src/validation/schema.ts index 82bd07b51..efe3439b0 100644 --- a/next/src/validation/schema.ts +++ b/next/src/validation/schema.ts @@ -7,7 +7,7 @@ import { validateConst } from './const' import { validateDate } from './custom/date' import { validateEnum } from './enum' import { validateFile } from './file' -import { validateJsonLogicRules } from './json-logic' +import { getJsonLogicContextFromSchema, validateJsonLogicRules } from './json-logic' import { validateNumber } from './number' import { validateObject } from './object' import { validateString } from './string' @@ -167,21 +167,15 @@ export function validateSchema( let jsonLogicContext = rootJsonLogicContext let jsonLogicRootSchema: JsonLogicRootSchema | undefined - // If we have a root jsonLogicContext, we shoud use that. + // If we have a root jsonLogicContext, we should use that. // If not, it probably means the current schema is the root schema (or that there's no json-logic node in the current schema) if (!rootJsonLogicContext && schema['x-jsf-logic']) { // - We should set the jsonLogicContext's schema as the schema in the 'x-jsf-logic' property - const { validations, computedValues, ...rest } = schema['x-jsf-logic'] - const jsonLogicRules: JsonLogicRules = { - validations, - computedValues, - } - jsonLogicContext = { - schema: jsonLogicRules, - value, - } + jsonLogicContext = getJsonLogicContextFromSchema(schema['x-jsf-logic'], value) + // - We need to validate any schema that's in the 'x-jsf-logic' property, like if/then/else/allOf/etc. // This is done below in the validateJsonLogicSchema call. + const { validations, computedValues, ...rest } = schema['x-jsf-logic'] jsonLogicRootSchema = rest } diff --git a/next/test/validation/json-logic.test.ts b/next/test/validation/json-logic.test.ts index 2009a8256..c655fb61d 100644 --- a/next/test/validation/json-logic.test.ts +++ b/next/test/validation/json-logic.test.ts @@ -364,7 +364,7 @@ describe('applyComputedAttrsToSchema', () => { }, } - const result = JsonLogicValidation.applyComputedAttrsToSchema(schema, {}) + const result = JsonLogicValidation.applyComputedAttrsToSchema(schema, schema['x-jsf-logic']?.computedValues, {}) expect(result).toBe(schema) }) @@ -395,7 +395,7 @@ describe('applyComputedAttrsToSchema', () => { (jsonLogic.apply as jest.Mock).mockReturnValue(21) - const result: JsfObjectSchema = JsonLogicValidation.applyComputedAttrsToSchema(schema, { person: { age: 21 } }) + const result: JsfObjectSchema = JsonLogicValidation.applyComputedAttrsToSchema(schema, schema['x-jsf-logic']?.computedValues, { person: { age: 21 } }) expect(result).not.toBe(schema) const ageProperties = result.properties?.person?.properties?.age as JsfObjectSchema @@ -425,7 +425,7 @@ describe('applyComputedAttrsToSchema', () => { (jsonLogic.apply as jest.Mock).mockReturnValue(21) - const result = JsonLogicValidation.applyComputedAttrsToSchema(schema, { age: 30 }) + const result = JsonLogicValidation.applyComputedAttrsToSchema(schema, schema['x-jsf-logic']?.computedValues, { age: 30 }) const ageProperties = result.properties?.age as JsfObjectSchema @@ -457,7 +457,7 @@ describe('applyComputedAttrsToSchema', () => { (jsonLogic.apply as jest.Mock).mockReturnValue(21) - const result = JsonLogicValidation.applyComputedAttrsToSchema(schema, { age: 19 }) + const result = JsonLogicValidation.applyComputedAttrsToSchema(schema, schema['x-jsf-logic']?.computedValues, { age: 19 }) const ageProperties = result.properties?.age as JsfObjectSchema expect(ageProperties?.minimum).toBe(21) From e2919c7fd16f058b497d7d92fc563ea7d572583a Mon Sep 17 00:00:00 2001 From: Capelo Date: Fri, 23 May 2025 11:41:20 +0100 Subject: [PATCH 12/22] chore: adding all json logic tests from v0 --- next/test/test-utils.ts | 14 +- next/test/validation/jsonLogic-v0.test.js | 607 ++++++++++++ next/test/validation/jsonLogic.fixtures.js | 1034 ++++++++++++++++++++ 3 files changed, 1654 insertions(+), 1 deletion(-) create mode 100644 next/test/validation/jsonLogic-v0.test.js create mode 100644 next/test/validation/jsonLogic.fixtures.js diff --git a/next/test/test-utils.ts b/next/test/test-utils.ts index f2e9d799e..67c4b251a 100644 --- a/next/test/test-utils.ts +++ b/next/test/test-utils.ts @@ -1,5 +1,5 @@ import type { ValidationError } from '../src/errors' -import { expect } from '@jest/globals' +import { expect, jest } from '@jest/globals' /** * Helper function for asserting that a `ValidationError` has some expected fields. It automatically populates the `schema` and `value` properties with "any value" @@ -13,3 +13,15 @@ export function errorLike(errorFields: Partial) { ...errorFields, }) } + +export function mockConsole() { + jest.spyOn(console, 'warn').mockImplementation(() => {}) + jest.spyOn(console, 'error').mockImplementation(() => {}) +} + +export function restoreConsoleAndEnsureItWasNotCalled() { + expect(console.error).not.toHaveBeenCalled(); + (console.error as jest.Mock).mockRestore() + expect(console.warn).not.toHaveBeenCalled(); + (console.warn as jest.Mock).mockRestore() +} diff --git a/next/test/validation/jsonLogic-v0.test.js b/next/test/validation/jsonLogic-v0.test.js new file mode 100644 index 000000000..a2af7a311 --- /dev/null +++ b/next/test/validation/jsonLogic-v0.test.js @@ -0,0 +1,607 @@ +import { createHeadlessForm } from '@/createHeadlessForm' +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals' +import { mockConsole, restoreConsoleAndEnsureItWasNotCalled } from '../test-utils' +import { + badSchemaThatWillNotSetAForcedValue, + createSchemaWithRulesOnFieldA, + createSchemaWithThreePropertiesWithRuleOnFieldA, + multiRuleSchema, + schemaInlineComputedAttrForMaximumMinimumValues, + schemaInlineComputedAttrForTitle, + schemaWhereValidationAndComputedValueIsAppliedOnNormalThenStatement, + schemaWithBadOperation, + schemaWithChecksAndThenValidationsOnThen, + schemaWithComputedAttributes, + schemaWithComputedAttributesAndErrorMessages, + schemaWithComputedAttributeThatDoesntExist, + schemaWithComputedAttributeThatDoesntExistDescription, + schemaWithComputedAttributeThatDoesntExistTitle, + schemaWithComputedValueChecksInIf, + schemaWithDeepVarThatDoesNotExist, + schemaWithDeepVarThatDoesNotExistOnFieldset, + schemaWithGreaterThanChecksForThreeFields, + schemaWithIfStatementWithComputedValuesAndValidationChecks, + schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar, + schemaWithInlineMultipleRulesForComputedAttributes, + schemaWithInlineRuleForComputedAttributeWithCopy, + schemaWithJSFLogicAndInlineRule, + schemaWithMissingComputedValue, + schemaWithMissingRule, + schemaWithMultipleComputedValueChecks, + schemaWithNativeAndJSONLogicChecks, + schemaWithNonRequiredField, + schemaWithPropertiesCheckAndValidationsInAIf, + schemaWithPropertyThatDoesNotExistInThatLevelButDoesInFieldset, + schemaWithReduceAccumulator, + schemaWithTwoRules, + schemaWithTwoValidationsWhereOneOfThemIsAppliedConditionally, + schemaWithUnknownVariableInComputedValues, + schemaWithUnknownVariableInValidations, + schemaWithValidationThatDoesNotExistOnProperty, +} from './jsonLogic.fixtures' + +beforeEach(mockConsole) +afterEach(restoreConsoleAndEnsureItWasNotCalled) + +describe('jsonLogic: cross-values validations', () => { + describe('does not conflict with native JSON schema', () => { + it('given an optional field and empty value, jsonLogic validations are ignored', () => { + const { handleValidation } = createHeadlessForm(schemaWithNonRequiredField, { + strictInputType: false, + }) + expect(handleValidation({}).formErrors).toBeUndefined() + expect(handleValidation({ field_a: 0, field_b: 10 }).formErrors).toEqual({ + field_b: 'Must be greater than field_a', + }) + expect(handleValidation({ field_a: 'incorrect value' }).formErrors).toEqual({ + field_a: 'The value must be a number', + }) + expect(handleValidation({ field_a: 11 }).formErrors).toBeUndefined() + }) + + it('native validations have higher precedence than jsonLogic validations', () => { + const { handleValidation } = createHeadlessForm(schemaWithNativeAndJSONLogicChecks, { + strictInputType: false, + }) + expect(handleValidation({}).formErrors).toEqual({ field_a: 'Required field' }) + expect(handleValidation({ field_a: 0 }).formErrors).toEqual({ + field_a: 'Must be greater or equal to 100', + }) + expect(handleValidation({ field_a: 101 }).formErrors).toEqual({ + field_a: 'Must be a multiple of 10', + }) + expect(handleValidation({ field_a: 110 }).formErrors).toBeUndefined() + }) + }) + + describe('relative: <, >, =', () => { + it('bigger: field_a > field_b', () => { + const schema = createSchemaWithRulesOnFieldA({ + a_greater_than_b: { + errorMessage: 'Field A must be bigger than field B', + rule: { '>': [{ var: 'field_a' }, { var: 'field_b' }] }, + }, + }) + const { handleValidation } = createHeadlessForm(schema, { strictInputType: false }) + const { formErrors } = handleValidation({ field_a: 1, field_b: 2 }) + expect(formErrors.field_a).toEqual('Field A must be bigger than field B') + expect(handleValidation({ field_a: 2, field_b: 0 }).formErrors).toEqual(undefined) + }) + + it('smaller: field_a < field_b', () => { + const schema = createSchemaWithRulesOnFieldA({ + a_less_than_b: { + errorMessage: 'Field A must be smaller than field B', + rule: { '<': [{ var: 'field_a' }, { var: 'field_b' }] }, + }, + }) + const { handleValidation } = createHeadlessForm(schema, { strictInputType: false }) + const { formErrors } = handleValidation({ field_a: 2, field_b: 2 }) + expect(formErrors.field_a).toEqual('Field A must be smaller than field B') + expect(handleValidation({ field_a: 0, field_b: 2 }).formErrors).toEqual(undefined) + }) + + it('equal: field_a = field_b', () => { + const schema = createSchemaWithRulesOnFieldA({ + a_equals_b: { + errorMessage: 'Field A must equal field B', + rule: { '==': [{ var: 'field_a' }, { var: 'field_b' }] }, + }, + }) + const { handleValidation } = createHeadlessForm(schema, { strictInputType: false }) + const { formErrors } = handleValidation({ field_a: 3, field_b: 2 }) + expect(formErrors.field_a).toEqual('Field A must equal field B') + expect(handleValidation({ field_a: 2, field_b: 2 }).formErrors).toEqual(undefined) + }) + }) + + describe.skip('incorrectly written schemas', () => { + afterEach(() => console.error.mockClear()) + + const cases = [ + [ + 'x-jsf-logic.validations: throw when theres a missing rule', + schemaWithMissingRule, + '[json-schema-form] json-logic error: Validation "a_greater_than_ten" has missing rule.', + ], + [ + 'x-jsf-logic.validations: throw when theres a value that does not exist in a rule', + schemaWithUnknownVariableInValidations, + '[json-schema-form] json-logic error: rule "a_equals_ten" has no variable "field_a".', + ], + [ + 'x-jsf-logic.computedValues: throw when theres a value that does not exist in a rule', + schemaWithUnknownVariableInComputedValues, + '[json-schema-form] json-logic error: rule "a_times_ten" has no variable "field_a".', + ], + [ + 'x-jsf-logic.computedValues: throw when theres a missing computed value', + schemaWithMissingComputedValue, + '[json-schema-form] json-logic error: Computed value "a_plus_ten" has missing rule.', + ], + [ + 'x-jsf-logic-computedAttrs: error if theres a value that does not exist on an attribute.', + schemaWithComputedAttributeThatDoesntExist, + `[json-schema-form] json-logic error: Computed value "iDontExist" doesn't exist in field "field_a".`, + ], + [ + 'x-jsf-logic-computedAttrs: error if theres a value that does not exist on a template string (title).', + schemaWithComputedAttributeThatDoesntExistTitle, + `[json-schema-form] json-logic error: Computed value "iDontExist" doesn't exist in field "field_a".`, + ], + [ + 'x-jsf-logic-computedAttrs: error if theres a value that does not exist on a template string (description).', + schemaWithComputedAttributeThatDoesntExistDescription, + `[json-schema-form] json-logic error: Computed value "iDontExist" doesn't exist in field "field_a".`, + ], + [ + 'x-jsf-logic-computedAttrs:, error if theres a value referenced that does not exist on an inline rule.', + schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar, + `[json-schema-form] json-logic error: fieldName "IdontExist" doesn't exist in field "field_a.x-jsf-logic-computedAttrs.title".`, + ], + [ + 'x-jsf-logic.validations: error if a field does not exist in a deeply nested rule', + schemaWithDeepVarThatDoesNotExist, + '[json-schema-form] json-logic error: rule "dummy_rule" has no variable "field_b".', + ], + [ + 'x-jsf-logic.validations: error if rule does not exist on a fieldset property', + schemaWithDeepVarThatDoesNotExistOnFieldset, + '[json-schema-form] json-logic error: rule "dummy_rule" has no variable "field_a".', + ], + [ + 'x-jsf-validations: error if a validation name does not exist', + schemaWithValidationThatDoesNotExistOnProperty, + `[json-schema-form] json-logic error: "field_a" required validation "iDontExist" doesn't exist.`, + ], + [ + 'x-jsf-logic.validations: A top level logic keyword will not be able to reference fieldset properties', + schemaWithPropertyThatDoesNotExistInThatLevelButDoesInFieldset, + '[json-schema-form] json-logic error: rule "validation_parent" has no variable "child".', + ], + [ + 'x-jsf-logic.validations: error if unknown operation', + schemaWithBadOperation, + '[json-schema-form] json-logic error: in "badOperator" rule there is an unknown operator "++".', + ], + ] + + it.each(cases)('%p', (_, schema, expectedErrorString) => { + const { error } = createHeadlessForm(schema, { strictInputType: false }) + const expectedError = new Error(expectedErrorString) + expect(console.error).toHaveBeenCalledWith('JSON Schema invalid!', expectedError) + expect(error).toEqual(expectedError) + }) + }) + + describe('arithmetic: +, -, *, /', () => { + it('multiple: field_a > field_b * 2', () => { + const schema = createSchemaWithRulesOnFieldA({ + a_greater_than_b_multiplied_by_2: { + errorMessage: 'Field A must be at least twice as big as field b', + rule: { '>': [{ var: 'field_a' }, { '*': [{ var: 'field_b' }, 2] }] }, + }, + }) + const { handleValidation } = createHeadlessForm(schema, { strictInputType: false }) + + const { formErrors } = handleValidation({ field_a: 1, field_b: 4 }) + expect(formErrors.field_a).toEqual('Field A must be at least twice as big as field b') + expect(handleValidation({ field_a: 3, field_b: 1 }).formErrors).toEqual(undefined) + }) + + it('divide: field_a > field_b / 2', () => { + const { handleValidation } = createHeadlessForm( + createSchemaWithRulesOnFieldA({ + a_greater_than_b_divided_by_2: { + errorMessage: 'Field A must be greater than field_b / 2', + rule: { '>': [{ var: 'field_a' }, { '/': [{ var: 'field_b' }, 2] }] }, + }, + }), + { strictInputType: false }, + ) + expect(handleValidation({ field_a: 2, field_b: 4 }).formErrors).toEqual({ + field_a: 'Field A must be greater than field_b / 2', + }) + expect(handleValidation({ field_a: 3, field_b: 5 }).formErrors).toEqual(undefined) + }) + + it('sum: field_a > field_b + field_c', () => { + const schema = createSchemaWithThreePropertiesWithRuleOnFieldA({ + a_is_greater_than_b_plus_c: { + errorMessage: 'Field A must be greater than field_b and field_b added together', + rule: { + '>': [{ var: 'field_a' }, { '+': [{ var: 'field_b' }, { var: 'field_c' }] }], + }, + }, + }) + const { handleValidation } = createHeadlessForm(schema, { strictInputType: false }) + const { formErrors } = handleValidation({ field_a: 0, field_b: 1, field_c: 2 }) + expect(formErrors.field_a).toEqual( + 'Field A must be greater than field_b and field_b added together', + ) + expect(handleValidation({ field_a: 4, field_b: 1, field_c: 2 }).formErrors).toEqual( + undefined, + ) + }) + }) + + // TODO: Implement this test. + describe.skip('reduce', () => { + it('reduce: working_hours_per_day * work_days', () => { + const { fields, handleValidation } = createHeadlessForm(schemaWithReduceAccumulator, { + strictInputType: false, + }) + handleValidation({ + work_days: ['monday', 'tuesday'], + working_hours_per_day: 8, + }) + const field = fields.find(i => i.name === 'working_hours_per_week') + expect(field.const).toEqual(16) + expect(field.default).toEqual(16) + expect(field.label).toEqual('16 hours per week') + }) + }) + + describe('logical: ||, &&', () => { + it('aND: field_a > field_b && field_a > field_c (implicit with multiple rules in a single field)', () => { + const schema = createSchemaWithThreePropertiesWithRuleOnFieldA({ + a_is_greater_than_b: { + errorMessage: 'Field A must be greater than field_b', + rule: { + '>': [{ var: 'field_a' }, { var: 'field_b' }], + }, + }, + a_is_greater_than_c: { + errorMessage: 'Field A must be greater than field_c', + rule: { + '>': [{ var: 'field_a' }, { var: 'field_c' }], + }, + }, + }) + const { handleValidation } = createHeadlessForm(schema, { strictInputType: false }) + expect(handleValidation({ field_a: 1, field_b: 10, field_c: 0 }).formErrors.field_a).toEqual( + 'Field A must be greater than field_b', + ) + expect(handleValidation({ field_a: 1, field_b: 0, field_c: 10 }).formErrors.field_a).toEqual( + 'Field A must be greater than field_c', + ) + expect(handleValidation({ field_a: 10, field_b: 5, field_c: 5 }).formErrors).toEqual( + undefined, + ) + }) + + it('oR: field_a > field_b or field_a > field_c', () => { + const schema = createSchemaWithThreePropertiesWithRuleOnFieldA({ + field_a_is_greater_than_b_or_c: { + errorMessage: 'Field A must be greater than field_b or field_c', + rule: { + or: [ + { '>': [{ var: 'field_a' }, { var: 'field_b' }] }, + { '>': [{ var: 'field_a' }, { var: 'field_c' }] }, + ], + }, + }, + }) + const { handleValidation } = createHeadlessForm(schema, { strictInputType: false }) + expect(handleValidation({ field_a: 0, field_b: 10, field_c: 10 }).formErrors.field_a).toEqual( + 'Field A must be greater than field_b or field_c', + ) + expect(handleValidation({ field_a: 1, field_b: 0, field_c: 10 }).formErrors).toEqual( + undefined, + ) + expect(handleValidation({ field_a: 10, field_b: 5, field_c: 5 }).formErrors).toEqual( + undefined, + ) + }) + }) + + describe('multiple validations', () => { + it('two rules: A > B; A is even', () => { + const { handleValidation } = createHeadlessForm(multiRuleSchema, { strictInputType: false }) + expect(handleValidation({ field_a: 1 }).formErrors).toEqual({ + field_a: 'A must be even', + field_b: 'Required field', + }) + expect(handleValidation({ field_a: 1, field_b: 2 }).formErrors).toEqual({ + field_a: 'A must be bigger than B', + }) + expect(handleValidation({ field_a: 3, field_b: 2 }).formErrors).toEqual({ + field_a: 'A must be even', + }) + expect(handleValidation({ field_a: 4, field_b: 2 }).formErrors).toEqual(undefined) + }) + + it('2 seperate fields with rules failing', () => { + const { handleValidation } = createHeadlessForm(schemaWithTwoRules, { + strictInputType: false, + }) + expect(handleValidation({ field_a: 1, field_b: 3 }).formErrors).toEqual({ + field_a: 'A must be bigger than B', + field_b: 'B must be even', + }) + expect(handleValidation({ field_a: 4, field_b: 2 }).formErrors).toEqual(undefined) + }) + }) + + describe('derive values', () => { + it('field_b is field_a * 2', () => { + const { fields, handleValidation } = createHeadlessForm(schemaWithComputedAttributes, { + strictInputType: false, + initialValues: { field_a: 2 }, + }) + const fieldB = fields.find(i => i.name === 'field_b') + expect(fieldB.description).toEqual( + 'This field is 2 times bigger than field_a with value of 4.', + ) + expect(fieldB.default).toEqual(4) + expect(fieldB.forcedValue).toEqual(4) + handleValidation({ field_a: 4 }) + expect(fieldB.default).toEqual(8) + expect(fieldB.label).toEqual('This is 8!') + }) + + it('a forced value will not be set when const and default are not equal', () => { + const { fields } = createHeadlessForm(badSchemaThatWillNotSetAForcedValue, { + strictInputType: false, + initialValues: { field_a: 2 }, + }) + expect(fields[1]).toMatchObject({ const: 6, default: 4 }) + expect(fields[1]).not.toMatchObject({ forcedValue: expect.any(Number) }) + }) + + it('derived errorMessages and statements work', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaWithComputedAttributesAndErrorMessages, + { strictInputType: false }, + ) + const fieldB = fields.find(i => i.name === 'field_b') + expect(handleValidation({ field_a: 2, field_b: 0 }).formErrors).toEqual({ + field_b: 'Must be bigger than 4', + }) + expect(handleValidation({ field_a: 2, field_b: 100 }).formErrors).toEqual({ + field_b: 'Must be smaller than 8', + }) + expect(fieldB.minimum).toEqual(4) + expect(fieldB.maximum).toEqual(8) + expect(fieldB.statement).toEqual({ description: 'Must be bigger than 4 and smaller than 8' }) + }) + + it('use a inline-rule in a schema for a title attribute', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaWithInlineRuleForComputedAttributeWithCopy, + { + strictInputType: false, + }, + ) + const [, fieldB] = fields + expect(handleValidation({ field_a: 0, field_b: null }).formErrors).toEqual(undefined) + expect(fieldB.label).toEqual('I need this to work using the 10.') + expect(handleValidation({ field_a: 10 }).formErrors).toEqual(undefined) + expect(fieldB.label).toEqual('I need this to work using the 20.') + }) + + it('use multiple inline rules with different identifiers', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaWithInlineMultipleRulesForComputedAttributes, + { + strictInputType: false, + }, + ) + const [, fieldB] = fields + expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toEqual(undefined) + expect(fieldB.description).toEqual('Must be between 5 and 20.') + }) + + it('use an inline rule in a schema for a title but it just uses the value', () => { + const { fields, handleValidation } = createHeadlessForm(schemaInlineComputedAttrForTitle, { + strictInputType: false, + }) + const [, fieldB] = fields + expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toEqual(undefined) + expect(fieldB.label).toEqual('20') + }) + + it('use an inline rule for a minimum, maximum value', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaInlineComputedAttrForMaximumMinimumValues, + { + strictInputType: false, + }, + ) + const [, fieldB] = fields + + // FIXME: We are currently setting NaN here because of how the data clean up works for json-logic + // We should probably set this as undefined when theres no values set? + // tracked in INF-53. + expect(fieldB).toMatchObject({ minimum: Number.NaN, maximum: Number.NaN }) + expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toBeUndefined() + expect(fieldB).toMatchObject({ minimum: 0, maximum: 20 }) + expect(handleValidation({ field_a: 50, field_b: 20 }).formErrors).toEqual({ + field_b: 'Must be greater or equal to 40', + }) + expect(fieldB).toMatchObject({ minimum: 40, maximum: 60 }) + expect(handleValidation({ field_a: 50, field_b: 70 }).formErrors).toEqual({ + field_b: 'Must be smaller or equal to 60', + }) + expect(fieldB).toMatchObject({ minimum: 40, maximum: 60 }) + expect(handleValidation({ field_a: 50, field_b: 50 }).formErrors).toBeUndefined() + expect(fieldB).toMatchObject({ minimum: 40, maximum: 60 }) + }) + + it('mix use of multiple inline rules and an external rule', () => { + const { fields, handleValidation } = createHeadlessForm(schemaWithJSFLogicAndInlineRule, { + strictInputType: false, + }) + handleValidation({ field_a: 10 }) + const [, fieldB] = fields + expect(fieldB.label).toEqual('Going to use 20 and 4') + }) + }) + + describe('conditionals', () => { + it('when field_a > field_b, show field_c', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaWithGreaterThanChecksForThreeFields, + { strictInputType: false }, + ) + const fieldC = fields.find(i => i.name === 'field_c') + expect(fieldC.isVisible).toEqual(false) + + expect(handleValidation({ field_a: 1, field_b: 3 }).formErrors).toEqual(undefined) + expect(handleValidation({ field_a: 1 }).formErrors).toEqual({ + field_b: 'Required field', + }) + expect(handleValidation({ field_a: 1, field_b: undefined }).formErrors).toEqual({ + field_b: 'Required field', + }) + expect(handleValidation({ field_a: 10, field_b: 3 }).formErrors).toEqual({ + field_c: 'Required field', + }) + expect(fieldC.isVisible).toEqual(true) + expect(handleValidation({ field_a: 10, field_b: 3, field_c: 0 }).formErrors).toEqual( + undefined, + ) + }) + + it('a schema with both a `x-jsf-validations` and `properties` check', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaWithPropertiesCheckAndValidationsInAIf, + { strictInputType: false }, + ) + const fieldC = fields.find(i => i.name === 'field_c') + expect(handleValidation({ field_a: 1, field_b: 3 }).formErrors).toEqual(undefined) + expect(fieldC.isVisible).toEqual(false) + expect(handleValidation({ field_a: 10, field_b: 3 }).formErrors).toEqual({ + field_c: 'Required field', + }) + expect(fieldC.isVisible).toEqual(true) + expect(handleValidation({ field_a: 5, field_b: 3 }).formErrors).toEqual(undefined) + }) + + it('conditionally apply a validation on a property depending on values', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaWithChecksAndThenValidationsOnThen, + { strictInputType: false }, + ) + const cField = fields.find(i => i.name === 'field_c') + expect(cField.isVisible).toEqual(false) + expect(cField.description).toEqual(undefined) + expect(handleValidation({ field_a: 10, field_b: 5 }).formErrors).toEqual({ + field_c: 'Required field', + }) + expect(handleValidation({ field_a: 10, field_b: 5, field_c: 0 }).formErrors).toEqual({ + field_c: 'Needs more numbers', + }) + expect(cField.description).toBe('I am a description!') + expect(handleValidation({ field_a: 10, field_b: 5, field_c: 201 }).formErrors).toEqual( + undefined, + ) + expect(handleValidation({ field_a: 5, field_b: 10 }).formErrors).toBeUndefined() + expect(cField).toMatchObject({ + isVisible: false, + // description: null, the description will currently be `I am a description!`, how do we change it back to null from here? Needs to be fixed by RMT-58 + }) + }) + + it('should apply a conditional based on a true computedValue', () => { + const { fields, handleValidation } = createHeadlessForm(schemaWithComputedValueChecksInIf, { + strictInputType: false, + }) + const cField = fields.find(i => i.name === 'field_c') + expect(cField.isVisible).toEqual(false) + expect(cField.description).toEqual(undefined) + expect(handleValidation({ field_a: 10, field_b: 5 }).formErrors).toEqual({ + field_c: 'Required field', + }) + expect(handleValidation({ field_a: 10, field_b: 5, field_c: 201 }).formErrors).toEqual( + undefined, + ) + }) + + it('handle multiple computedValue checks by ANDing them together', () => { + const { handleValidation } = createHeadlessForm(schemaWithMultipleComputedValueChecks, { + strictInputType: false, + }) + expect(handleValidation({}).formErrors).toEqual({ + field_a: 'Required field', + field_b: 'Required field', + }) + expect(handleValidation({ field_a: 10, field_b: 8 }).formErrors).toEqual({ + field_c: 'Required field', + }) + expect(handleValidation({ field_a: 10, field_b: 8, field_c: 0 }).formErrors).toEqual({ + field_c: 'Must be two times B', + }) + expect(handleValidation({ field_a: 10, field_b: 8, field_c: 17 }).formErrors).toEqual( + undefined, + ) + }) + + it('handle having a true condition with both validations and computedValue checks', () => { + const { handleValidation } = createHeadlessForm( + schemaWithIfStatementWithComputedValuesAndValidationChecks, + { strictInputType: false }, + ) + expect(handleValidation({ field_a: 1, field_b: 1 }).formErrors).toEqual(undefined) + expect(handleValidation({ field_a: 10, field_b: 20 }).formErrors).toEqual(undefined) + expect(handleValidation({ field_a: 10, field_b: 9 }).formErrors).toEqual({ + field_c: 'Required field', + }) + expect(handleValidation({ field_a: 10, field_b: 9, field_c: 10 }).formErrors).toEqual( + undefined, + ) + }) + + it('apply validations and computed values on normal if statement.', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaWhereValidationAndComputedValueIsAppliedOnNormalThenStatement, + { strictInputType: false }, + ) + expect(handleValidation({ field_a: 0, field_b: 0 }).formErrors).toEqual(undefined) + expect(handleValidation({ field_a: 20, field_b: 0 }).formErrors).toEqual({ + field_b: 'Must be greater than Field A + 10', + }) + const [, fieldB] = fields + expect(fieldB.label).toEqual('Must be greater than 30.') + expect(handleValidation({ field_a: 20, field_b: 31 }).formErrors).toEqual(undefined) + }) + + it('when we have a required validation on a top level property and another validation is added, both should be accounted for.', () => { + const { handleValidation } = createHeadlessForm( + schemaWithTwoValidationsWhereOneOfThemIsAppliedConditionally, + { strictInputType: false }, + ) + expect(handleValidation({ field_a: 10, field_b: 0 }).formErrors).toEqual({ + field_b: 'Must be greater than A', + }) + expect(handleValidation({ field_a: 10, field_b: 20 }).formErrors).toEqual(undefined) + expect(handleValidation({ field_a: 20, field_b: 10 }).formErrors).toEqual({ + field_b: 'Must be greater than two times A', + }) + expect(handleValidation({ field_a: 20, field_b: 21 }).formErrors).toEqual({ + field_b: 'Must be greater than two times A', + }) + expect(handleValidation({ field_a: 20, field_b: 41 }).formErrors).toEqual() + }) + }) +}) diff --git a/next/test/validation/jsonLogic.fixtures.js b/next/test/validation/jsonLogic.fixtures.js new file mode 100644 index 000000000..e57c8730d --- /dev/null +++ b/next/test/validation/jsonLogic.fixtures.js @@ -0,0 +1,1034 @@ +export function createSchemaWithRulesOnFieldA(rules) { + return { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-validations': Object.keys(rules), + }, + field_b: { + type: 'number', + }, + }, + required: ['field_a', 'field_b'], + 'x-jsf-logic': { validations: rules }, + }; +} + +export function createSchemaWithThreePropertiesWithRuleOnFieldA(rules) { + return { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-validations': Object.keys(rules), + }, + field_b: { + type: 'number', + }, + field_c: { + type: 'number', + }, + }, + 'x-jsf-logic': { validations: rules }, + required: ['field_a', 'field_b', 'field_c'], + }; +} + +export const schemaWithNonRequiredField = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-validations': ['a_greater_than_field_b'], + }, + }, + 'x-jsf-logic': { + validations: { + a_greater_than_field_b: { + errorMessage: 'Must be greater than field_a', + rule: { + '>': [{ var: 'field_a' }, { var: 'field_b' }], + }, + }, + }, + }, + required: [], +}; + +export const schemaWithNativeAndJSONLogicChecks = { + properties: { + field_a: { + type: 'number', + minimum: 100, + 'x-jsf-logic-validations': ['a_multiple_of_ten'], + }, + }, + 'x-jsf-logic': { + validations: { + a_multiple_of_ten: { + errorMessage: 'Must be a multiple of 10', + rule: { + '===': [{ '%': [{ var: 'field_a' }, 10] }, 0], + }, + }, + }, + }, + required: ['field_a'], +}; + +export const schemaWithMissingRule = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-validations': ['a_greater_than_ten'], + }, + }, + 'x-jsf-logic': { + validations: { + a_greater_than_ten: { + errorMessage: 'Must be greater than 10', + // rule: { '>': [{ var: 'field_a' }, 10] }, this missing causes test to fail. + }, + }, + }, + required: [], +}; + +export const schemaWithUnknownVariableInValidations = { + properties: { + // field_a: { type: 'number' }, this missing causes test to fail. + }, + 'x-jsf-logic': { + validations: { + a_equals_ten: { + errorMessage: 'Must equal 10', + rule: { '===': [{ var: 'field_a' }, 10] }, + }, + }, + }, +}; + +export const schemaWithUnknownVariableInComputedValues = { + properties: { + // field_a: { type: 'number' }, this missing causes test to fail. + }, + 'x-jsf-logic': { + computedValues: { + a_times_ten: { + rule: { '*': [{ var: 'field_a' }, 10] }, + }, + }, + }, +}; + +export const schemaWithMissingComputedValue = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: '{{a_plus_ten}}', + }, + }, + }, + 'x-jsf-logic': { + computedValues: { + a_plus_ten: { + // rule: { '+': [{ var: 'field_a' }, 10 ]} this missing causes test to fail. + }, + }, + }, + required: [], +}; + +export const multiRuleSchema = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-validations': ['a_bigger_than_b', 'is_even_number'], + }, + field_b: { + type: 'number', + }, + }, + required: ['field_a', 'field_b'], + 'x-jsf-logic': { + validations: { + a_bigger_than_b: { + errorMessage: 'A must be bigger than B', + rule: { + '>': [{ var: 'field_a' }, { var: 'field_b' }], + }, + }, + is_even_number: { + errorMessage: 'A must be even', + rule: { + '===': [{ '%': [{ var: 'field_a' }, 2] }, 0], + }, + }, + }, + }, +}; + +export const schemaWithTwoRules = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-validations': ['a_bigger_than_b'], + }, + field_b: { + type: 'number', + 'x-jsf-logic-validations': ['is_even_number'], + }, + }, + required: ['field_a', 'field_b'], + 'x-jsf-logic': { + validations: { + a_bigger_than_b: { + errorMessage: 'A must be bigger than B', + rule: { + '>': [{ var: 'field_a' }, { var: 'field_b' }], + }, + }, + is_even_number: { + errorMessage: 'B must be even', + rule: { + '===': [{ '%': [{ var: 'field_b' }, 2] }, 0], + }, + }, + }, + }, +}; + +export const schemaWithComputedAttributes = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: 'This is {{a_times_two}}!', + const: 'a_times_two', + default: 'a_times_two', + description: 'This field is 2 times bigger than field_a with value of {{a_times_two}}.', + }, + }, + }, + required: ['field_a', 'field_b'], + 'x-jsf-logic': { + computedValues: { + a_times_two: { + rule: { + '*': [{ var: 'field_a' }, 2], + }, + }, + }, + }, +}; + +export const badSchemaThatWillNotSetAForcedValue = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + const: 'a_times_three', + default: 'a_times_two', + }, + }, + }, + 'x-jsf-logic': { + computedValues: { + a_times_two: { + rule: { + '*': [{ var: 'field_a' }, 2], + }, + }, + a_times_three: { + rule: { + '*': [{ var: 'field_a' }, 3], + }, + }, + }, + }, +}; + +export const schemaWithInlineRuleForComputedAttributeWithoutCopy = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: { + rule: { + '+': [{ var: 'field_a' }, 10], + }, + }, + }, + }, + }, +}; + +export const schemaWithComputedAttributeThatDoesntExist = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + minimum: 'iDontExist', + }, + }, + }, + // x-jsf-logic: { computedValues: { iDontExist: { rule: 10 }} this missing causes test to fail. +}; + +export const schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar = { + properties: { + // iDontExist: { type: 'number' } this missing causes test to fail. + field_a: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: { + rule: { + '+': [{ var: 'IdontExist' }, 10], + }, + }, + }, + }, + }, +}; + +export const schemaWithComputedAttributeThatDoesntExistTitle = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: `this doesn't exist {{iDontExist}}`, + }, + }, + }, +}; + +export const schemaWithComputedAttributeThatDoesntExistDescription = { + properties: { + // iDontExist: { type: 'number'}, this missing causes test to fail + field_a: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + description: `this doesn't exist {{iDontExist}}`, + }, + }, + }, +}; + +export const schemaWithComputedAttributesAndErrorMessages = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + minimum: 'a_times_two', + maximum: 'a_times_four', + 'x-jsf-errorMessage': { + minimum: 'Must be bigger than {{a_times_two}}', + maximum: 'Must be smaller than {{a_times_four}}', + }, + 'x-jsf-presentation': { + statement: { + description: 'Must be bigger than {{a_times_two}} and smaller than {{a_times_four}}', + }, + }, + }, + }, + }, + required: ['field_a', 'field_b'], + 'x-jsf-logic': { + computedValues: { + a_times_two: { + rule: { + '*': [{ var: 'field_a' }, 2], + }, + }, + a_times_four: { + rule: { + '*': [{ var: 'field_a' }, 4], + }, + }, + }, + }, +}; + +export const schemaWithDeepVarThatDoesNotExist = { + properties: { + field_a: { + type: 'number', + }, + }, + 'x-jsf-logic': { + validations: { + dummy_rule: { + errorMessage: 'Random stuff to illustrate a deeply nested rule.', + rule: { + '>': [{ var: 'field_a' }, { '*': [2, { '/': [2, { '*': [1, { var: 'field_b' }] }] }] }], + }, + }, + }, + }, + required: [], +}; + +export const schemaWithDeepVarThatDoesNotExistOnFieldset = { + properties: { + field_a: { + type: 'object', + properties: { + child: { + type: 'number', + }, + }, + 'x-jsf-logic': { + validations: { + dummy_rule: { + errorMessage: 'Must be greater than 10', + rule: { + '>': [{ var: 'child' }, { '*': [2, { '/': [2, { '*': [1, { var: 'field_a' }] }] }] }], + }, + }, + }, + }, + }, + }, + required: [], +}; + +export const schemaWithValidationThatDoesNotExistOnProperty = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-validations': ['iDontExist'], + }, + }, +}; + +export const schemaWithPropertyThatDoesNotExistInThatLevelButDoesInFieldset = { + properties: { + field_a: { + type: 'object', + 'x-jsf-presentation': { + inputType: 'fieldset', + }, + properties: { + child: { + type: 'number', + 'x-jsf-logic-validations': ['child_greater_than_10'], + }, + other_child: { + type: 'number', + 'x-jsf-logic-validations': ['greater_than_child'], + }, + }, + required: ['child', 'other_child'], + }, + }, + // the issue here is that this should be nested inside `field_a` in order to not fail. + 'x-jsf-logic': { + validations: { + validation_parent: { + errorMessage: 'Must be greater than 10!', + rule: { + '>': [{ var: 'child' }, 10], + }, + }, + greater_than_child: { + errorMessage: 'Must be greater than child', + rule: { + '>': [{ var: 'other_child' }, { var: 'child' }], + }, + }, + }, + }, + required: ['field_a'], +}; + +export const schemaWithBadOperation = { + properties: {}, + 'x-jsf-logic': { + validations: { + badOperator: { + rule: { + '++': [10, 2], + }, + }, + }, + }, +}; + +export const schemaWithInlineRuleForComputedAttributeWithCopy = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: { + value: 'I need this to work using the {{rule}}.', + rule: { + '+': [{ var: 'field_a' }, 10], + }, + }, + }, + }, + }, +}; + +export const schemaWithInlineMultipleRulesForComputedAttributes = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + description: { + value: 'Must be between {{half_a}} and {{double_a}}.', + half_a: { + '/': [{ var: 'field_a' }, 2], + }, + double_a: { + '*': [{ var: 'field_a' }, 2], + }, + }, + }, + }, + }, +}; + +export const schemaInlineComputedAttrForTitle = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: { + value: '{{rule}}', + rule: { + '+': [{ var: 'field_a' }, 10], + }, + }, + }, + }, + }, +}; + +export const schemaInlineComputedAttrForMaximumMinimumValues = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + maximum: { + rule: { + '+': [{ var: 'field_a' }, 10], + }, + }, + minimum: { + rule: { + '-': [{ var: 'field_a' }, 10], + }, + }, + }, + }, + }, +}; + +export const schemaWithJSFLogicAndInlineRule = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: { + value: 'Going to use {{rule}} and {{not_inline}}', + rule: { + '+': [{ var: 'field_a' }, 10], + }, + }, + }, + }, + }, + 'x-jsf-logic': { + computedValues: { + not_inline: { + rule: { + '+': [1, 3], + }, + }, + }, + }, +}; + +export const schemaWithGreaterThanChecksForThreeFields = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + }, + field_c: { + type: 'number', + }, + }, + required: ['field_a', 'field_b'], + 'x-jsf-logic': { + validations: { + require_c: { + rule: { + and: [{ '>': [{ var: 'field_a' }, { var: 'field_b' }] }], + }, + }, + }, + allOf: [ + { + if: { + validations: { + require_c: { + const: true, + }, + }, + }, + then: { + required: ['field_c'], + }, + else: { + properties: { + field_c: false, + }, + }, + }, + ], + }, +}; + +export const schemaWithPropertiesCheckAndValidationsInAIf = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + }, + field_c: { + type: 'number', + }, + }, + required: ['field_a', 'field_b'], + 'x-jsf-logic': { + validations: { + require_c: { + rule: { + and: [{ '>': [{ var: 'field_a' }, { var: 'field_b' }] }], + }, + }, + }, + allOf: [ + { + if: { + validations: { + require_c: { + const: true, + }, + }, + properties: { + field_a: { + const: 10, + }, + }, + }, + then: { + required: ['field_c'], + }, + else: { + properties: { + field_c: false, + }, + }, + }, + ], + }, +}; + +export const schemaWithChecksAndThenValidationsOnThen = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + }, + field_c: { + type: 'number', + }, + }, + required: ['field_a', 'field_b'], + 'x-jsf-logic': { + validations: { + c_must_be_large: { + errorMessage: 'Needs more numbers', + rule: { + '>': [{ var: 'field_c' }, 200], + }, + }, + require_c: { + rule: { + and: [{ '>': [{ var: 'field_a' }, { var: 'field_b' }] }], + }, + }, + }, + allOf: [ + { + if: { + validations: { + require_c: { + const: true, + }, + }, + }, + then: { + required: ['field_c'], + properties: { + field_c: { + description: 'I am a description!', + 'x-jsf-logic-validations': ['c_must_be_large'], + }, + }, + }, + else: { + properties: { + field_c: false, + }, + }, + }, + ], + }, +}; + +export const schemaWithComputedValueChecksInIf = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + }, + field_c: { + type: 'number', + }, + }, + required: ['field_a', 'field_b'], + 'x-jsf-logic': { + computedValues: { + require_c: { + rule: { + and: [{ '>': [{ var: 'field_a' }, { var: 'field_b' }] }], + }, + }, + }, + allOf: [ + { + if: { + computedValues: { + require_c: { + const: true, + }, + }, + }, + then: { + required: ['field_c'], + }, + else: { + properties: { + field_c: false, + }, + }, + }, + ], + }, +}; + +export const schemaWithMultipleComputedValueChecks = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + }, + field_c: { + type: 'number', + }, + }, + required: ['field_a', 'field_b'], + 'x-jsf-logic': { + validations: { + double_b: { + errorMessage: 'Must be two times B', + rule: { + '>': [{ var: 'field_c' }, { '*': [{ var: 'field_b' }, 2] }], + }, + }, + }, + computedValues: { + a_times_two: { + rule: { + '*': [{ var: 'field_a' }, 2], + }, + }, + mod_by_five: { + rule: { + '%': [{ var: 'field_b' }, 5], + }, + }, + }, + allOf: [ + { + if: { + computedValues: { + a_times_two: { + const: 20, + }, + mod_by_five: { + const: 3, + }, + }, + }, + then: { + required: ['field_c'], + properties: { + field_c: { + 'x-jsf-logic-validations': ['double_b'], + title: 'Adding a title.', + }, + }, + }, + else: { + properties: { + field_c: false, + }, + }, + }, + ], + }, +}; + +export const schemaWithIfStatementWithComputedValuesAndValidationChecks = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + }, + field_c: { + type: 'number', + }, + }, + required: ['field_a', 'field_b'], + 'x-jsf-logic': { + validations: { + greater_than_b: { + rule: { + '>': [{ var: 'field_a' }, { var: 'field_b' }], + }, + }, + }, + computedValues: { + a_times_two: { + rule: { + '*': [{ var: 'field_a' }, 2], + }, + }, + }, + allOf: [ + { + if: { + computedValues: { + a_times_two: { + const: 20, + }, + }, + validations: { + greater_than_b: { + const: true, + }, + }, + }, + then: { + required: ['field_c'], + }, + else: { + properties: { + field_c: false, + }, + }, + }, + ], + }, +}; + +export const schemaWhereValidationAndComputedValueIsAppliedOnNormalThenStatement = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + }, + }, + 'x-jsf-logic': { + computedValues: { + a_plus_ten: { + rule: { + '+': [{ var: 'field_a' }, 10], + }, + }, + }, + validations: { + greater_than_a_plus_ten: { + errorMessage: 'Must be greater than Field A + 10', + rule: { + '>': [{ var: 'field_b' }, { '+': [{ var: 'field_a' }, 10] }], + }, + }, + }, + }, + allOf: [ + { + if: { + properties: { + field_a: { + const: 20, + }, + }, + }, + then: { + properties: { + field_b: { + 'x-jsf-logic-computedAttrs': { + title: 'Must be greater than {{a_plus_ten}}.', + }, + 'x-jsf-logic-validations': ['greater_than_a_plus_ten'], + }, + }, + }, + }, + ], +}; + +export const schemaWithTwoValidationsWhereOneOfThemIsAppliedConditionally = { + required: ['field_a', 'field_b'], + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-validations': ['greater_than_field_a'], + }, + }, + 'x-jsf-logic': { + validations: { + greater_than_field_a: { + errorMessage: 'Must be greater than A', + rule: { + '>': [{ var: 'field_b' }, { var: 'field_a' }], + }, + }, + greater_than_two_times_a: { + errorMessage: 'Must be greater than two times A', + rule: { + '>': [{ var: 'field_b' }, { '*': [{ var: 'field_a' }, 2] }], + }, + }, + }, + }, + allOf: [ + { + if: { + properties: { + field_a: { + const: 20, + }, + }, + }, + then: { + properties: { + field_b: { + 'x-jsf-logic-validations': ['greater_than_two_times_a'], + }, + }, + }, + }, + ], +}; + +export const schemaWithReduceAccumulator = { + properties: { + work_days: { + items: { + anyOf: [ + { const: 'monday', title: 'Monday' }, + { const: 'tuesday', title: 'Tuesday' }, + { const: 'wednesday', title: 'Wednesday' }, + { const: 'thursday', title: 'Thursday' }, + { const: 'friday', title: 'Friday' }, + { const: 'saturday', title: 'Saturday' }, + { const: 'sunday', title: 'Sunday' }, + ], + }, + type: 'array', + uniqueItems: true, + 'x-jsf-presentation': { + inputType: 'select', + }, + }, + working_hours_per_day: { + type: 'number', + }, + working_hours_per_week: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + const: 'computed_work_hours_per_week', + defaultValue: 'computed_work_hours_per_week', + title: '{{computed_work_hours_per_week}} hours per week', + }, + }, + }, + 'x-jsf-logic': { + computedValues: { + computed_work_hours_per_week: { + rule: { + '*': [ + { var: 'working_hours_per_day' }, + { + reduce: [{ var: 'work_days' }, { '+': [{ var: ['accumulator', 0] }, 1] }, 0], + }, + ], + }, + }, + }, + }, +}; From cc24bcfc6456bf679aa04a53d9bcf76771d15453 Mon Sep 17 00:00:00 2001 From: Capelo Date: Fri, 23 May 2025 12:50:43 +0100 Subject: [PATCH 13/22] chore: refactor tests --- next/src/validation/json-logic.ts | 16 +- next/test/validation/jsonLogic-v0.test.js | 162 +----- next/test/validation/jsonLogic.fixtures.js | 598 +++++++-------------- 3 files changed, 243 insertions(+), 533 deletions(-) diff --git a/next/src/validation/json-logic.ts b/next/src/validation/json-logic.ts index 269fffc06..4533aeb55 100644 --- a/next/src/validation/json-logic.ts +++ b/next/src/validation/json-logic.ts @@ -80,7 +80,21 @@ export function validateJsonLogicRules( // If the condition is false, we return a validation error if (result === false) { - return [{ path, validation: 'json-logic', customErrorMessage: validationData.errorMessage, schema, value: formValue } as ValidationError] + let errorMessage = validationData.errorMessage + if (errorMessage && containsHandlebars(errorMessage)) { + errorMessage = errorMessage.replace(/\{\{(.*?)\}\}/g, (_, handlebarsVar) => { + const varName = handlebarsVar.trim() + const jsonLogicComputation = jsonLogicContext.schema.computedValues?.[varName] + if (jsonLogicComputation) { + return jsonLogic.apply(jsonLogicComputation.rule, replaceUndefinedAndNullValuesWithNaN(formValue as ObjectValue)) + } + else { + return jsonLogic.apply({ var: varName }, replaceUndefinedAndNullValuesWithNaN(formValue as ObjectValue)) + } + }) + } + + return [{ path, validation: 'json-logic', customErrorMessage: errorMessage, schema, value: formValue } as ValidationError] } return [] diff --git a/next/test/validation/jsonLogic-v0.test.js b/next/test/validation/jsonLogic-v0.test.js index a2af7a311..304aa40ac 100644 --- a/next/test/validation/jsonLogic-v0.test.js +++ b/next/test/validation/jsonLogic-v0.test.js @@ -6,8 +6,9 @@ import { createSchemaWithRulesOnFieldA, createSchemaWithThreePropertiesWithRuleOnFieldA, multiRuleSchema, - schemaInlineComputedAttrForMaximumMinimumValues, schemaInlineComputedAttrForTitle, + schemaValidationForMaximumAndMinimumValues, + schemaValidationForMaximumAndMinimumValuesWithDynamicErrorMessage, schemaWhereValidationAndComputedValueIsAppliedOnNormalThenStatement, schemaWithBadOperation, schemaWithChecksAndThenValidationsOnThen, @@ -421,157 +422,52 @@ describe('jsonLogic: cross-values validations', () => { expect(fieldB.label).toEqual('20') }) - it('use an inline rule for a minimum, maximum value', () => { - const { fields, handleValidation } = createHeadlessForm( - schemaInlineComputedAttrForMaximumMinimumValues, + it('use x-jsf-logic for setting dynamic minimum and maximum values', () => { + const { handleValidation } = createHeadlessForm( + schemaValidationForMaximumAndMinimumValues, { strictInputType: false, }, ) - const [, fieldB] = fields - - // FIXME: We are currently setting NaN here because of how the data clean up works for json-logic - // We should probably set this as undefined when theres no values set? - // tracked in INF-53. - expect(fieldB).toMatchObject({ minimum: Number.NaN, maximum: Number.NaN }) - expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toBeUndefined() - expect(fieldB).toMatchObject({ minimum: 0, maximum: 20 }) - expect(handleValidation({ field_a: 50, field_b: 20 }).formErrors).toEqual({ - field_b: 'Must be greater or equal to 40', - }) - expect(fieldB).toMatchObject({ minimum: 40, maximum: 60 }) - expect(handleValidation({ field_a: 50, field_b: 70 }).formErrors).toEqual({ - field_b: 'Must be smaller or equal to 60', - }) - expect(fieldB).toMatchObject({ minimum: 40, maximum: 60 }) - expect(handleValidation({ field_a: 50, field_b: 50 }).formErrors).toBeUndefined() - expect(fieldB).toMatchObject({ minimum: 40, maximum: 60 }) - }) - - it('mix use of multiple inline rules and an external rule', () => { - const { fields, handleValidation } = createHeadlessForm(schemaWithJSFLogicAndInlineRule, { - strictInputType: false, - }) - handleValidation({ field_a: 10 }) - const [, fieldB] = fields - expect(fieldB.label).toEqual('Going to use 20 and 4') - }) - }) - - describe('conditionals', () => { - it('when field_a > field_b, show field_c', () => { - const { fields, handleValidation } = createHeadlessForm( - schemaWithGreaterThanChecksForThreeFields, - { strictInputType: false }, - ) - const fieldC = fields.find(i => i.name === 'field_c') - expect(fieldC.isVisible).toEqual(false) - expect(handleValidation({ field_a: 1, field_b: 3 }).formErrors).toEqual(undefined) - expect(handleValidation({ field_a: 1 }).formErrors).toEqual({ - field_b: 'Required field', + expect(handleValidation({ field_a: 20, field_b: 17 }).formErrors).toEqual({ + field_b: 'Field B must be greater than or equal to 20 - 2', }) - expect(handleValidation({ field_a: 1, field_b: undefined }).formErrors).toEqual({ - field_b: 'Required field', - }) - expect(handleValidation({ field_a: 10, field_b: 3 }).formErrors).toEqual({ - field_c: 'Required field', + expect(handleValidation({ field_a: 20, field_b: 23 }).formErrors).toEqual({ + field_b: 'Field B must be smaller than or equal to 20 + 2', }) - expect(fieldC.isVisible).toEqual(true) - expect(handleValidation({ field_a: 10, field_b: 3, field_c: 0 }).formErrors).toEqual( - undefined, - ) + expect(handleValidation({ field_a: 20, field_b: 21 }).formErrors).toBeUndefined() + expect(handleValidation({ field_a: 20, field_b: 19 }).formErrors).toBeUndefined() }) - it('a schema with both a `x-jsf-validations` and `properties` check', () => { - const { fields, handleValidation } = createHeadlessForm( - schemaWithPropertiesCheckAndValidationsInAIf, - { strictInputType: false }, - ) - const fieldC = fields.find(i => i.name === 'field_c') - expect(handleValidation({ field_a: 1, field_b: 3 }).formErrors).toEqual(undefined) - expect(fieldC.isVisible).toEqual(false) - expect(handleValidation({ field_a: 10, field_b: 3 }).formErrors).toEqual({ - field_c: 'Required field', - }) - expect(fieldC.isVisible).toEqual(true) - expect(handleValidation({ field_a: 5, field_b: 3 }).formErrors).toEqual(undefined) - }) - - it('conditionally apply a validation on a property depending on values', () => { - const { fields, handleValidation } = createHeadlessForm( - schemaWithChecksAndThenValidationsOnThen, - { strictInputType: false }, - ) - const cField = fields.find(i => i.name === 'field_c') - expect(cField.isVisible).toEqual(false) - expect(cField.description).toEqual(undefined) - expect(handleValidation({ field_a: 10, field_b: 5 }).formErrors).toEqual({ - field_c: 'Required field', - }) - expect(handleValidation({ field_a: 10, field_b: 5, field_c: 0 }).formErrors).toEqual({ - field_c: 'Needs more numbers', - }) - expect(cField.description).toBe('I am a description!') - expect(handleValidation({ field_a: 10, field_b: 5, field_c: 201 }).formErrors).toEqual( - undefined, + it('use x-jsf-logic for setting dynamic minimum and maximum values, with a dynamic error message', () => { + const { handleValidation } = createHeadlessForm( + schemaValidationForMaximumAndMinimumValuesWithDynamicErrorMessage, + { + strictInputType: false, + }, ) - expect(handleValidation({ field_a: 5, field_b: 10 }).formErrors).toBeUndefined() - expect(cField).toMatchObject({ - isVisible: false, - // description: null, the description will currently be `I am a description!`, how do we change it back to null from here? Needs to be fixed by RMT-58 - }) - }) - it('should apply a conditional based on a true computedValue', () => { - const { fields, handleValidation } = createHeadlessForm(schemaWithComputedValueChecksInIf, { - strictInputType: false, + expect(handleValidation({ field_a: 20, field_b: 17 }).formErrors).toEqual({ + field_b: 'Field B must be greater than or equal to 18', }) - const cField = fields.find(i => i.name === 'field_c') - expect(cField.isVisible).toEqual(false) - expect(cField.description).toEqual(undefined) - expect(handleValidation({ field_a: 10, field_b: 5 }).formErrors).toEqual({ - field_c: 'Required field', + expect(handleValidation({ field_a: 20, field_b: 23 }).formErrors).toEqual({ + field_b: 'Field B must be smaller than or equal to 22', }) - expect(handleValidation({ field_a: 10, field_b: 5, field_c: 201 }).formErrors).toEqual( - undefined, - ) + expect(handleValidation({ field_a: 20, field_b: 21 }).formErrors).toBeUndefined() }) - it('handle multiple computedValue checks by ANDing them together', () => { - const { handleValidation } = createHeadlessForm(schemaWithMultipleComputedValueChecks, { + it('mix use of multiple inline rules and an external rule', () => { + const { fields, handleValidation } = createHeadlessForm(schemaWithJSFLogicAndInlineRule, { strictInputType: false, }) - expect(handleValidation({}).formErrors).toEqual({ - field_a: 'Required field', - field_b: 'Required field', - }) - expect(handleValidation({ field_a: 10, field_b: 8 }).formErrors).toEqual({ - field_c: 'Required field', - }) - expect(handleValidation({ field_a: 10, field_b: 8, field_c: 0 }).formErrors).toEqual({ - field_c: 'Must be two times B', - }) - expect(handleValidation({ field_a: 10, field_b: 8, field_c: 17 }).formErrors).toEqual( - undefined, - ) - }) - - it('handle having a true condition with both validations and computedValue checks', () => { - const { handleValidation } = createHeadlessForm( - schemaWithIfStatementWithComputedValuesAndValidationChecks, - { strictInputType: false }, - ) - expect(handleValidation({ field_a: 1, field_b: 1 }).formErrors).toEqual(undefined) - expect(handleValidation({ field_a: 10, field_b: 20 }).formErrors).toEqual(undefined) - expect(handleValidation({ field_a: 10, field_b: 9 }).formErrors).toEqual({ - field_c: 'Required field', - }) - expect(handleValidation({ field_a: 10, field_b: 9, field_c: 10 }).formErrors).toEqual( - undefined, - ) + handleValidation({ field_a: 10 }) + const [, fieldB] = fields + expect(fieldB.label).toEqual('Going to use 20 and 4') }) + }) + describe('conditionals', () => { it('apply validations and computed values on normal if statement.', () => { const { fields, handleValidation } = createHeadlessForm( schemaWhereValidationAndComputedValueIsAppliedOnNormalThenStatement, diff --git a/next/test/validation/jsonLogic.fixtures.js b/next/test/validation/jsonLogic.fixtures.js index e57c8730d..a7bde6ca1 100644 --- a/next/test/validation/jsonLogic.fixtures.js +++ b/next/test/validation/jsonLogic.fixtures.js @@ -1,24 +1,24 @@ export function createSchemaWithRulesOnFieldA(rules) { return { - properties: { + 'properties': { field_a: { - type: 'number', + 'type': 'number', 'x-jsf-logic-validations': Object.keys(rules), }, field_b: { type: 'number', }, }, - required: ['field_a', 'field_b'], + 'required': ['field_a', 'field_b'], 'x-jsf-logic': { validations: rules }, - }; + } } export function createSchemaWithThreePropertiesWithRuleOnFieldA(rules) { return { - properties: { + 'properties': { field_a: { - type: 'number', + 'type': 'number', 'x-jsf-logic-validations': Object.keys(rules), }, field_b: { @@ -29,17 +29,17 @@ export function createSchemaWithThreePropertiesWithRuleOnFieldA(rules) { }, }, 'x-jsf-logic': { validations: rules }, - required: ['field_a', 'field_b', 'field_c'], - }; + 'required': ['field_a', 'field_b', 'field_c'], + } } export const schemaWithNonRequiredField = { - properties: { + 'properties': { field_a: { type: 'number', }, field_b: { - type: 'number', + 'type': 'number', 'x-jsf-logic-validations': ['a_greater_than_field_b'], }, }, @@ -53,14 +53,14 @@ export const schemaWithNonRequiredField = { }, }, }, - required: [], -}; + 'required': [], +} export const schemaWithNativeAndJSONLogicChecks = { - properties: { + 'properties': { field_a: { - type: 'number', - minimum: 100, + 'type': 'number', + 'minimum': 100, 'x-jsf-logic-validations': ['a_multiple_of_ten'], }, }, @@ -74,13 +74,13 @@ export const schemaWithNativeAndJSONLogicChecks = { }, }, }, - required: ['field_a'], -}; + 'required': ['field_a'], +} export const schemaWithMissingRule = { - properties: { + 'properties': { field_a: { - type: 'number', + 'type': 'number', 'x-jsf-logic-validations': ['a_greater_than_ten'], }, }, @@ -92,11 +92,11 @@ export const schemaWithMissingRule = { }, }, }, - required: [], -}; + 'required': [], +} export const schemaWithUnknownVariableInValidations = { - properties: { + 'properties': { // field_a: { type: 'number' }, this missing causes test to fail. }, 'x-jsf-logic': { @@ -107,10 +107,10 @@ export const schemaWithUnknownVariableInValidations = { }, }, }, -}; +} export const schemaWithUnknownVariableInComputedValues = { - properties: { + 'properties': { // field_a: { type: 'number' }, this missing causes test to fail. }, 'x-jsf-logic': { @@ -120,12 +120,12 @@ export const schemaWithUnknownVariableInComputedValues = { }, }, }, -}; +} export const schemaWithMissingComputedValue = { - properties: { + 'properties': { field_a: { - type: 'number', + 'type': 'number', 'x-jsf-logic-computedAttrs': { title: '{{a_plus_ten}}', }, @@ -138,20 +138,20 @@ export const schemaWithMissingComputedValue = { }, }, }, - required: [], -}; + 'required': [], +} export const multiRuleSchema = { - properties: { + 'properties': { field_a: { - type: 'number', + 'type': 'number', 'x-jsf-logic-validations': ['a_bigger_than_b', 'is_even_number'], }, field_b: { type: 'number', }, }, - required: ['field_a', 'field_b'], + 'required': ['field_a', 'field_b'], 'x-jsf-logic': { validations: { a_bigger_than_b: { @@ -168,20 +168,20 @@ export const multiRuleSchema = { }, }, }, -}; +} export const schemaWithTwoRules = { - properties: { + 'properties': { field_a: { - type: 'number', + 'type': 'number', 'x-jsf-logic-validations': ['a_bigger_than_b'], }, field_b: { - type: 'number', + 'type': 'number', 'x-jsf-logic-validations': ['is_even_number'], }, }, - required: ['field_a', 'field_b'], + 'required': ['field_a', 'field_b'], 'x-jsf-logic': { validations: { a_bigger_than_b: { @@ -198,15 +198,15 @@ export const schemaWithTwoRules = { }, }, }, -}; +} export const schemaWithComputedAttributes = { - properties: { + 'properties': { field_a: { type: 'number', }, field_b: { - type: 'number', + 'type': 'number', 'x-jsf-logic-computedAttrs': { title: 'This is {{a_times_two}}!', const: 'a_times_two', @@ -215,7 +215,7 @@ export const schemaWithComputedAttributes = { }, }, }, - required: ['field_a', 'field_b'], + 'required': ['field_a', 'field_b'], 'x-jsf-logic': { computedValues: { a_times_two: { @@ -225,15 +225,15 @@ export const schemaWithComputedAttributes = { }, }, }, -}; +} export const badSchemaThatWillNotSetAForcedValue = { - properties: { + 'properties': { field_a: { type: 'number', }, field_b: { - type: 'number', + 'type': 'number', 'x-jsf-logic-computedAttrs': { const: 'a_times_three', default: 'a_times_two', @@ -254,7 +254,7 @@ export const badSchemaThatWillNotSetAForcedValue = { }, }, }, -}; +} export const schemaWithInlineRuleForComputedAttributeWithoutCopy = { properties: { @@ -262,7 +262,7 @@ export const schemaWithInlineRuleForComputedAttributeWithoutCopy = { type: 'number', }, field_b: { - type: 'number', + 'type': 'number', 'x-jsf-logic-computedAttrs': { title: { rule: { @@ -272,25 +272,25 @@ export const schemaWithInlineRuleForComputedAttributeWithoutCopy = { }, }, }, -}; +} export const schemaWithComputedAttributeThatDoesntExist = { properties: { field_a: { - type: 'number', + 'type': 'number', 'x-jsf-logic-computedAttrs': { minimum: 'iDontExist', }, }, }, // x-jsf-logic: { computedValues: { iDontExist: { rule: 10 }} this missing causes test to fail. -}; +} export const schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar = { properties: { // iDontExist: { type: 'number' } this missing causes test to fail. field_a: { - type: 'number', + 'type': 'number', 'x-jsf-logic-computedAttrs': { title: { rule: { @@ -300,41 +300,41 @@ export const schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar = }, }, }, -}; +} export const schemaWithComputedAttributeThatDoesntExistTitle = { properties: { field_a: { - type: 'number', + 'type': 'number', 'x-jsf-logic-computedAttrs': { title: `this doesn't exist {{iDontExist}}`, }, }, }, -}; +} export const schemaWithComputedAttributeThatDoesntExistDescription = { properties: { // iDontExist: { type: 'number'}, this missing causes test to fail field_a: { - type: 'number', + 'type': 'number', 'x-jsf-logic-computedAttrs': { description: `this doesn't exist {{iDontExist}}`, }, }, }, -}; +} export const schemaWithComputedAttributesAndErrorMessages = { - properties: { + 'properties': { field_a: { type: 'number', }, field_b: { - type: 'number', + 'type': 'number', 'x-jsf-logic-computedAttrs': { - minimum: 'a_times_two', - maximum: 'a_times_four', + 'minimum': 'a_times_two', + 'maximum': 'a_times_four', 'x-jsf-errorMessage': { minimum: 'Must be bigger than {{a_times_two}}', maximum: 'Must be smaller than {{a_times_four}}', @@ -347,7 +347,7 @@ export const schemaWithComputedAttributesAndErrorMessages = { }, }, }, - required: ['field_a', 'field_b'], + 'required': ['field_a', 'field_b'], 'x-jsf-logic': { computedValues: { a_times_two: { @@ -362,10 +362,10 @@ export const schemaWithComputedAttributesAndErrorMessages = { }, }, }, -}; +} export const schemaWithDeepVarThatDoesNotExist = { - properties: { + 'properties': { field_a: { type: 'number', }, @@ -380,14 +380,14 @@ export const schemaWithDeepVarThatDoesNotExist = { }, }, }, - required: [], -}; + 'required': [], +} export const schemaWithDeepVarThatDoesNotExistOnFieldset = { properties: { field_a: { - type: 'object', - properties: { + 'type': 'object', + 'properties': { child: { type: 'number', }, @@ -405,35 +405,35 @@ export const schemaWithDeepVarThatDoesNotExistOnFieldset = { }, }, required: [], -}; +} export const schemaWithValidationThatDoesNotExistOnProperty = { properties: { field_a: { - type: 'number', + 'type': 'number', 'x-jsf-logic-validations': ['iDontExist'], }, }, -}; +} export const schemaWithPropertyThatDoesNotExistInThatLevelButDoesInFieldset = { - properties: { + 'properties': { field_a: { - type: 'object', + 'type': 'object', 'x-jsf-presentation': { inputType: 'fieldset', }, - properties: { + 'properties': { child: { - type: 'number', + 'type': 'number', 'x-jsf-logic-validations': ['child_greater_than_10'], }, other_child: { - type: 'number', + 'type': 'number', 'x-jsf-logic-validations': ['greater_than_child'], }, }, - required: ['child', 'other_child'], + 'required': ['child', 'other_child'], }, }, // the issue here is that this should be nested inside `field_a` in order to not fail. @@ -453,11 +453,11 @@ export const schemaWithPropertyThatDoesNotExistInThatLevelButDoesInFieldset = { }, }, }, - required: ['field_a'], -}; + 'required': ['field_a'], +} export const schemaWithBadOperation = { - properties: {}, + 'properties': {}, 'x-jsf-logic': { validations: { badOperator: { @@ -467,7 +467,7 @@ export const schemaWithBadOperation = { }, }, }, -}; +} export const schemaWithInlineRuleForComputedAttributeWithCopy = { properties: { @@ -475,7 +475,7 @@ export const schemaWithInlineRuleForComputedAttributeWithCopy = { type: 'number', }, field_b: { - type: 'number', + 'type': 'number', 'x-jsf-logic-computedAttrs': { title: { value: 'I need this to work using the {{rule}}.', @@ -486,7 +486,7 @@ export const schemaWithInlineRuleForComputedAttributeWithCopy = { }, }, }, -}; +} export const schemaWithInlineMultipleRulesForComputedAttributes = { properties: { @@ -494,7 +494,7 @@ export const schemaWithInlineMultipleRulesForComputedAttributes = { type: 'number', }, field_b: { - type: 'number', + 'type': 'number', 'x-jsf-logic-computedAttrs': { description: { value: 'Must be between {{half_a}} and {{double_a}}.', @@ -508,7 +508,7 @@ export const schemaWithInlineMultipleRulesForComputedAttributes = { }, }, }, -}; +} export const schemaInlineComputedAttrForTitle = { properties: { @@ -516,7 +516,7 @@ export const schemaInlineComputedAttrForTitle = { type: 'number', }, field_b: { - type: 'number', + 'type': 'number', 'x-jsf-logic-computedAttrs': { title: { value: '{{rule}}', @@ -527,370 +527,170 @@ export const schemaInlineComputedAttrForTitle = { }, }, }, -}; - -export const schemaInlineComputedAttrForMaximumMinimumValues = { - properties: { - field_a: { - type: 'number', - }, - field_b: { - type: 'number', - 'x-jsf-logic-computedAttrs': { - maximum: { - rule: { - '+': [{ var: 'field_a' }, 10], - }, - }, - minimum: { - rule: { - '-': [{ var: 'field_a' }, 10], - }, - }, - }, - }, - }, -}; - -export const schemaWithJSFLogicAndInlineRule = { - properties: { - field_a: { - type: 'number', - }, - field_b: { - type: 'number', - 'x-jsf-logic-computedAttrs': { - title: { - value: 'Going to use {{rule}} and {{not_inline}}', - rule: { - '+': [{ var: 'field_a' }, 10], - }, - }, - }, - }, - }, - 'x-jsf-logic': { - computedValues: { - not_inline: { - rule: { - '+': [1, 3], - }, - }, - }, - }, -}; +} -export const schemaWithGreaterThanChecksForThreeFields = { - properties: { +export const schemaValidationForMaximumAndMinimumValues = { + 'properties': { field_a: { type: 'number', }, field_b: { - type: 'number', - }, - field_c: { - type: 'number', + 'type': 'number', + 'x-jsf-logic-validations': [ + 'max_a', + 'min_a', + ], }, }, - required: ['field_a', 'field_b'], 'x-jsf-logic': { validations: { - require_c: { + max_a: { + errorMessage: 'Field B must be smaller than or equal to {{field_a}} + 2', rule: { - and: [{ '>': [{ var: 'field_a' }, { var: 'field_b' }] }], - }, - }, - }, - allOf: [ - { - if: { - validations: { - require_c: { - const: true, - }, - }, - }, - then: { - required: ['field_c'], - }, - else: { - properties: { - field_c: false, - }, - }, - }, - ], - }, -}; - -export const schemaWithPropertiesCheckAndValidationsInAIf = { - properties: { - field_a: { - type: 'number', - }, - field_b: { - type: 'number', - }, - field_c: { - type: 'number', - }, - }, - required: ['field_a', 'field_b'], - 'x-jsf-logic': { - validations: { - require_c: { - rule: { - and: [{ '>': [{ var: 'field_a' }, { var: 'field_b' }] }], - }, - }, - }, - allOf: [ - { - if: { - validations: { - require_c: { - const: true, + '>=': [ + { + '+': [ + { + var: 'field_a', + }, + 2, + ], }, - }, - properties: { - field_a: { - const: 10, + { + var: 'field_b', }, - }, - }, - then: { - required: ['field_c'], - }, - else: { - properties: { - field_c: false, - }, - }, - }, - ], - }, -}; - -export const schemaWithChecksAndThenValidationsOnThen = { - properties: { - field_a: { - type: 'number', - }, - field_b: { - type: 'number', - }, - field_c: { - type: 'number', - }, - }, - required: ['field_a', 'field_b'], - 'x-jsf-logic': { - validations: { - c_must_be_large: { - errorMessage: 'Needs more numbers', - rule: { - '>': [{ var: 'field_c' }, 200], + ], }, }, - require_c: { + min_a: { + errorMessage: 'Field B must be greater than or equal to {{field_a}} - 2', rule: { - and: [{ '>': [{ var: 'field_a' }, { var: 'field_b' }] }], - }, - }, - }, - allOf: [ - { - if: { - validations: { - require_c: { - const: true, + '<=': [ + { + '-': [ + { + var: 'field_a', + }, + 2, + ], }, - }, - }, - then: { - required: ['field_c'], - properties: { - field_c: { - description: 'I am a description!', - 'x-jsf-logic-validations': ['c_must_be_large'], + { + var: 'field_b', }, - }, - }, - else: { - properties: { - field_c: false, - }, + ], }, }, - ], + }, }, -}; +} -export const schemaWithComputedValueChecksInIf = { - properties: { +export const schemaValidationForMaximumAndMinimumValuesWithDynamicErrorMessage = { + 'properties': { field_a: { type: 'number', }, field_b: { - type: 'number', - }, - field_c: { - type: 'number', + 'type': 'number', + 'x-jsf-logic-validations': [ + 'max_a', + 'min_a', + ], }, }, - required: ['field_a', 'field_b'], 'x-jsf-logic': { computedValues: { - require_c: { + field_a_plus_2: { rule: { - and: [{ '>': [{ var: 'field_a' }, { var: 'field_b' }] }], - }, - }, - }, - allOf: [ - { - if: { - computedValues: { - require_c: { - const: true, + '+': [ + { + var: 'field_a', }, - }, - }, - then: { - required: ['field_c'], - }, - else: { - properties: { - field_c: false, - }, + 2, + ], }, }, - ], - }, -}; - -export const schemaWithMultipleComputedValueChecks = { - properties: { - field_a: { - type: 'number', - }, - field_b: { - type: 'number', - }, - field_c: { - type: 'number', - }, - }, - required: ['field_a', 'field_b'], - 'x-jsf-logic': { - validations: { - double_b: { - errorMessage: 'Must be two times B', + field_a_minus_2: { rule: { - '>': [{ var: 'field_c' }, { '*': [{ var: 'field_b' }, 2] }], + '-': [ + { + var: 'field_a', + }, + 2, + ], }, }, }, - computedValues: { - a_times_two: { + validations: { + max_a: { + errorMessage: 'Field B must be smaller than or equal to {{field_a_plus_2}}', rule: { - '*': [{ var: 'field_a' }, 2], + '>=': [ + { + '+': [ + { + var: 'field_a', + }, + 2, + ], + }, + { + var: 'field_b', + }, + ], }, }, - mod_by_five: { + min_a: { + errorMessage: 'Field B must be greater than or equal to {{field_a_minus_2}}', rule: { - '%': [{ var: 'field_b' }, 5], - }, - }, - }, - allOf: [ - { - if: { - computedValues: { - a_times_two: { - const: 20, - }, - mod_by_five: { - const: 3, + '<=': [ + { + '-': [ + { + var: 'field_a', + }, + 2, + ], }, - }, - }, - then: { - required: ['field_c'], - properties: { - field_c: { - 'x-jsf-logic-validations': ['double_b'], - title: 'Adding a title.', + { + var: 'field_b', }, - }, - }, - else: { - properties: { - field_c: false, - }, + ], }, }, - ], + }, }, -}; +} -export const schemaWithIfStatementWithComputedValuesAndValidationChecks = { - properties: { +export const schemaWithJSFLogicAndInlineRule = { + 'properties': { field_a: { type: 'number', }, field_b: { - type: 'number', - }, - field_c: { - type: 'number', - }, - }, - required: ['field_a', 'field_b'], - 'x-jsf-logic': { - validations: { - greater_than_b: { - rule: { - '>': [{ var: 'field_a' }, { var: 'field_b' }], + 'type': 'number', + 'x-jsf-logic-computedAttrs': { + title: { + value: 'Going to use {{rule}} and {{not_inline}}', + rule: { + '+': [{ var: 'field_a' }, 10], + }, }, }, }, + }, + 'x-jsf-logic': { computedValues: { - a_times_two: { + not_inline: { rule: { - '*': [{ var: 'field_a' }, 2], + '+': [1, 3], }, }, }, - allOf: [ - { - if: { - computedValues: { - a_times_two: { - const: 20, - }, - }, - validations: { - greater_than_b: { - const: true, - }, - }, - }, - then: { - required: ['field_c'], - }, - else: { - properties: { - field_c: false, - }, - }, - }, - ], }, -}; +} export const schemaWhereValidationAndComputedValueIsAppliedOnNormalThenStatement = { - properties: { + 'properties': { field_a: { type: 'number', }, @@ -915,7 +715,7 @@ export const schemaWhereValidationAndComputedValueIsAppliedOnNormalThenStatement }, }, }, - allOf: [ + 'allOf': [ { if: { properties: { @@ -936,16 +736,16 @@ export const schemaWhereValidationAndComputedValueIsAppliedOnNormalThenStatement }, }, ], -}; +} export const schemaWithTwoValidationsWhereOneOfThemIsAppliedConditionally = { - required: ['field_a', 'field_b'], - properties: { + 'required': ['field_a', 'field_b'], + 'properties': { field_a: { type: 'number', }, field_b: { - type: 'number', + 'type': 'number', 'x-jsf-logic-validations': ['greater_than_field_a'], }, }, @@ -965,7 +765,7 @@ export const schemaWithTwoValidationsWhereOneOfThemIsAppliedConditionally = { }, }, }, - allOf: [ + 'allOf': [ { if: { properties: { @@ -983,12 +783,12 @@ export const schemaWithTwoValidationsWhereOneOfThemIsAppliedConditionally = { }, }, ], -}; +} export const schemaWithReduceAccumulator = { - properties: { + 'properties': { work_days: { - items: { + 'items': { anyOf: [ { const: 'monday', title: 'Monday' }, { const: 'tuesday', title: 'Tuesday' }, @@ -999,8 +799,8 @@ export const schemaWithReduceAccumulator = { { const: 'sunday', title: 'Sunday' }, ], }, - type: 'array', - uniqueItems: true, + 'type': 'array', + 'uniqueItems': true, 'x-jsf-presentation': { inputType: 'select', }, @@ -1009,7 +809,7 @@ export const schemaWithReduceAccumulator = { type: 'number', }, working_hours_per_week: { - type: 'number', + 'type': 'number', 'x-jsf-logic-computedAttrs': { const: 'computed_work_hours_per_week', defaultValue: 'computed_work_hours_per_week', @@ -1031,4 +831,4 @@ export const schemaWithReduceAccumulator = { }, }, }, -}; +} From 9fe429fda00462aa766e19f1c1397718e119155e Mon Sep 17 00:00:00 2001 From: Capelo Date: Fri, 23 May 2025 16:18:06 +0100 Subject: [PATCH 14/22] chore: improve recursion --- next/src/validation/json-logic.ts | 38 ++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/next/src/validation/json-logic.ts b/next/src/validation/json-logic.ts index 4533aeb55..e21021ecc 100644 --- a/next/src/validation/json-logic.ts +++ b/next/src/validation/json-logic.ts @@ -187,6 +187,11 @@ function cycleThroughPropertiesAndApplyValues(schemaCopy: JsfObjectSchema, compu * @param computedValues - The computed values to apply */ function cycleThroughAttrsAndApplyValues(propertySchema: JsfObjectSchema, computedValues: Record, computedAttrs: JsfSchema['x-jsf-logic-computedAttrs']) { + /** + * Evaluates a string or a handlebars template, using the computed values mapping, and returns the computed value + * @param message - The string or template to evaluate + * @returns The computed value + */ function evalStringOrTemplate(message: string) { // If it's a string, we can apply it directly by referencing the computed value by key if (!containsHandlebars(message)) { @@ -200,19 +205,40 @@ function cycleThroughAttrsAndApplyValues(propertySchema: JsfObjectSchema, comput }) } + /** + * Recursively applies the computed values to a nested schema + * @param propertySchema - The schema to apply computed values to + * @param attrName - The name of the attribute to apply the computed values to + * @param computationName - The name of the computed value to apply + * @param computedValues - The computed values to apply + */ + function applyNestedComputedValues(propertySchema: JsfObjectSchema, attrName: string, computationName: string | object, computedValues: Record) { + const attributeName = attrName as keyof NonBooleanJsfSchema + if (!propertySchema[attributeName]) { + // Making sure the attribute object is created if it does not exist in the original schema + propertySchema[attributeName] = {} + } + + Object.entries(computationName).forEach(([key, compName]) => { + if (typeof compName === 'string') { + propertySchema[attributeName][key] = evalStringOrTemplate(compName) + } + else { + applyNestedComputedValues(propertySchema[attributeName], key, compName, computedValues) + } + }) + } + for (const key in computedAttrs) { const attributeName = key as keyof NonBooleanJsfSchema const computationName = computedAttrs[key] + // If the computed value is a string, we can apply it directly by referencing the computed value by key if (typeof computationName === 'string') { propertySchema[attributeName] = evalStringOrTemplate(computationName) } else { - if (!propertySchema[attributeName]) { - propertySchema[attributeName] = {} - } - Object.entries(computationName).forEach(([key, value]) => { - propertySchema[attributeName][key] = evalStringOrTemplate(value) - }) + // Otherwise, it's a nested object, so we need to apply the computed values to the nested object + applyNestedComputedValues(propertySchema, attributeName, computationName, computedValues) } } } From d4213b53c270c1f3f3fe05703a9a0e25a972a568 Mon Sep 17 00:00:00 2001 From: Capelo Date: Fri, 23 May 2025 16:28:02 +0100 Subject: [PATCH 15/22] chore: use first error message from validation errors --- next/src/form.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/next/src/form.ts b/next/src/form.ts index da7bed8cf..e46d90f45 100644 --- a/next/src/form.ts +++ b/next/src/form.ts @@ -138,7 +138,9 @@ function validationErrorsToFormErrors(errors: ValidationErrorWithMessage[]): For if (segments.length > 0) { const lastSegment = segments[segments.length - 1] - current[lastSegment] = error.message + if (!current[lastSegment]) { + current[lastSegment] = error.message + } } } From bf75b6343f7a8f9e99869ae3f3ef1aed7b0051c2 Mon Sep 17 00:00:00 2001 From: Capelo Date: Fri, 23 May 2025 16:29:01 +0100 Subject: [PATCH 16/22] chore: add (applicable) v0 tests for json-logic --- ...Logic-v0.test.js => json-logic-v0.test.js} | 54 ++----------------- ...gic.fixtures.js => json-logic.fixtures.js} | 28 ---------- 2 files changed, 5 insertions(+), 77 deletions(-) rename next/test/validation/{jsonLogic-v0.test.js => json-logic-v0.test.js} (90%) rename next/test/validation/{jsonLogic.fixtures.js => json-logic.fixtures.js} (97%) diff --git a/next/test/validation/jsonLogic-v0.test.js b/next/test/validation/json-logic-v0.test.js similarity index 90% rename from next/test/validation/jsonLogic-v0.test.js rename to next/test/validation/json-logic-v0.test.js index 304aa40ac..4b7e5858c 100644 --- a/next/test/validation/jsonLogic-v0.test.js +++ b/next/test/validation/json-logic-v0.test.js @@ -39,7 +39,7 @@ import { schemaWithUnknownVariableInComputedValues, schemaWithUnknownVariableInValidations, schemaWithValidationThatDoesNotExistOnProperty, -} from './jsonLogic.fixtures' +} from './json-logic.fixtures' beforeEach(mockConsole) afterEach(restoreConsoleAndEnsureItWasNotCalled) @@ -350,13 +350,13 @@ describe('jsonLogic: cross-values validations', () => { strictInputType: false, initialValues: { field_a: 2 }, }) - const fieldB = fields.find(i => i.name === 'field_b') + let fieldB = fields.find(i => i.name === 'field_b') expect(fieldB.description).toEqual( 'This field is 2 times bigger than field_a with value of 4.', ) expect(fieldB.default).toEqual(4) - expect(fieldB.forcedValue).toEqual(4) handleValidation({ field_a: 4 }) + fieldB = fields.find(i => i.name === 'field_b') expect(fieldB.default).toEqual(8) expect(fieldB.label).toEqual('This is 8!') }) @@ -375,53 +375,18 @@ describe('jsonLogic: cross-values validations', () => { schemaWithComputedAttributesAndErrorMessages, { strictInputType: false }, ) - const fieldB = fields.find(i => i.name === 'field_b') expect(handleValidation({ field_a: 2, field_b: 0 }).formErrors).toEqual({ field_b: 'Must be bigger than 4', }) expect(handleValidation({ field_a: 2, field_b: 100 }).formErrors).toEqual({ field_b: 'Must be smaller than 8', }) + const fieldB = fields.find(i => i.name === 'field_b') expect(fieldB.minimum).toEqual(4) expect(fieldB.maximum).toEqual(8) expect(fieldB.statement).toEqual({ description: 'Must be bigger than 4 and smaller than 8' }) }) - it('use a inline-rule in a schema for a title attribute', () => { - const { fields, handleValidation } = createHeadlessForm( - schemaWithInlineRuleForComputedAttributeWithCopy, - { - strictInputType: false, - }, - ) - const [, fieldB] = fields - expect(handleValidation({ field_a: 0, field_b: null }).formErrors).toEqual(undefined) - expect(fieldB.label).toEqual('I need this to work using the 10.') - expect(handleValidation({ field_a: 10 }).formErrors).toEqual(undefined) - expect(fieldB.label).toEqual('I need this to work using the 20.') - }) - - it('use multiple inline rules with different identifiers', () => { - const { fields, handleValidation } = createHeadlessForm( - schemaWithInlineMultipleRulesForComputedAttributes, - { - strictInputType: false, - }, - ) - const [, fieldB] = fields - expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toEqual(undefined) - expect(fieldB.description).toEqual('Must be between 5 and 20.') - }) - - it('use an inline rule in a schema for a title but it just uses the value', () => { - const { fields, handleValidation } = createHeadlessForm(schemaInlineComputedAttrForTitle, { - strictInputType: false, - }) - const [, fieldB] = fields - expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toEqual(undefined) - expect(fieldB.label).toEqual('20') - }) - it('use x-jsf-logic for setting dynamic minimum and maximum values', () => { const { handleValidation } = createHeadlessForm( schemaValidationForMaximumAndMinimumValues, @@ -456,15 +421,6 @@ describe('jsonLogic: cross-values validations', () => { }) expect(handleValidation({ field_a: 20, field_b: 21 }).formErrors).toBeUndefined() }) - - it('mix use of multiple inline rules and an external rule', () => { - const { fields, handleValidation } = createHeadlessForm(schemaWithJSFLogicAndInlineRule, { - strictInputType: false, - }) - handleValidation({ field_a: 10 }) - const [, fieldB] = fields - expect(fieldB.label).toEqual('Going to use 20 and 4') - }) }) describe('conditionals', () => { @@ -492,7 +448,7 @@ describe('jsonLogic: cross-values validations', () => { }) expect(handleValidation({ field_a: 10, field_b: 20 }).formErrors).toEqual(undefined) expect(handleValidation({ field_a: 20, field_b: 10 }).formErrors).toEqual({ - field_b: 'Must be greater than two times A', + field_b: 'Must be greater than A', }) expect(handleValidation({ field_a: 20, field_b: 21 }).formErrors).toEqual({ field_b: 'Must be greater than two times A', diff --git a/next/test/validation/jsonLogic.fixtures.js b/next/test/validation/json-logic.fixtures.js similarity index 97% rename from next/test/validation/jsonLogic.fixtures.js rename to next/test/validation/json-logic.fixtures.js index a7bde6ca1..148295c73 100644 --- a/next/test/validation/jsonLogic.fixtures.js +++ b/next/test/validation/json-logic.fixtures.js @@ -661,34 +661,6 @@ export const schemaValidationForMaximumAndMinimumValuesWithDynamicErrorMessage = }, } -export const schemaWithJSFLogicAndInlineRule = { - 'properties': { - field_a: { - type: 'number', - }, - field_b: { - 'type': 'number', - 'x-jsf-logic-computedAttrs': { - title: { - value: 'Going to use {{rule}} and {{not_inline}}', - rule: { - '+': [{ var: 'field_a' }, 10], - }, - }, - }, - }, - }, - 'x-jsf-logic': { - computedValues: { - not_inline: { - rule: { - '+': [1, 3], - }, - }, - }, - }, -} - export const schemaWhereValidationAndComputedValueIsAppliedOnNormalThenStatement = { 'properties': { field_a: { From a75c00244403c18f14294473c23594ef8885500f Mon Sep 17 00:00:00 2001 From: Capelo Date: Fri, 23 May 2025 16:29:42 +0100 Subject: [PATCH 17/22] chore: add comment --- next/test/validation/json-logic-v0.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/next/test/validation/json-logic-v0.test.js b/next/test/validation/json-logic-v0.test.js index 4b7e5858c..302a85ff2 100644 --- a/next/test/validation/json-logic-v0.test.js +++ b/next/test/validation/json-logic-v0.test.js @@ -116,6 +116,7 @@ describe('jsonLogic: cross-values validations', () => { }) }) + // TODO: These suites are skipped for now because v1 does not throw as many errors as v0. describe.skip('incorrectly written schemas', () => { afterEach(() => console.error.mockClear()) From a5a59c223f54f9797dc62c0e751439b8c40cbf71 Mon Sep 17 00:00:00 2001 From: Capelo Date: Fri, 23 May 2025 16:39:26 +0100 Subject: [PATCH 18/22] chore: cleanup --- next/src/mutations.ts | 2 +- next/src/validation/json-logic.ts | 2 +- next/src/validation/schema.ts | 2 +- next/test/validation/json-logic-v0.test.js | 12 +---- next/test/validation/json-logic.fixtures.js | 60 --------------------- 5 files changed, 4 insertions(+), 74 deletions(-) diff --git a/next/src/mutations.ts b/next/src/mutations.ts index b6dddd496..4da6424ba 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 { applyComputedAttrsToSchema, computePropertyValues, getJsonLogicContextFromSchema } from './validation/json-logic' +import { applyComputedAttrsToSchema, getJsonLogicContextFromSchema } from './validation/json-logic' import { validateSchema } from './validation/schema' import { isObjectValue } from './validation/util' diff --git a/next/src/validation/json-logic.ts b/next/src/validation/json-logic.ts index e21021ecc..623adb616 100644 --- a/next/src/validation/json-logic.ts +++ b/next/src/validation/json-logic.ts @@ -121,7 +121,7 @@ export function computePropertyValues( * it creates a deep clone of the schema and applies the computed values to the clone,otherwise it returns the original schema. * * @param schema - The schema to apply computed attributes to - * @param computedValues - The computed values to apply + * @param computedValuesDefinition - The computed values to apply * @param values - The current form values * @returns The schema with computed attributes applied */ diff --git a/next/src/validation/schema.ts b/next/src/validation/schema.ts index efe3439b0..a481ffb5c 100644 --- a/next/src/validation/schema.ts +++ b/next/src/validation/schema.ts @@ -1,5 +1,5 @@ import type { ValidationError, ValidationErrorPath } from '../errors' -import type { JsfSchema, JsfSchemaType, JsonLogicContext, JsonLogicRootSchema, JsonLogicRules, SchemaValue } from '../types' +import type { JsfSchema, JsfSchemaType, JsonLogicContext, JsonLogicRootSchema, SchemaValue } from '../types' import { validateArray } from './array' import { validateAllOf, validateAnyOf, validateNot, validateOneOf } from './composition' import { validateCondition } from './conditions' diff --git a/next/test/validation/json-logic-v0.test.js b/next/test/validation/json-logic-v0.test.js index 302a85ff2..3e692ce5f 100644 --- a/next/test/validation/json-logic-v0.test.js +++ b/next/test/validation/json-logic-v0.test.js @@ -1,37 +1,27 @@ import { createHeadlessForm } from '@/createHeadlessForm' -import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals' +import { afterEach, beforeEach, describe, expect, it } from '@jest/globals' import { mockConsole, restoreConsoleAndEnsureItWasNotCalled } from '../test-utils' import { badSchemaThatWillNotSetAForcedValue, createSchemaWithRulesOnFieldA, createSchemaWithThreePropertiesWithRuleOnFieldA, multiRuleSchema, - schemaInlineComputedAttrForTitle, schemaValidationForMaximumAndMinimumValues, schemaValidationForMaximumAndMinimumValuesWithDynamicErrorMessage, schemaWhereValidationAndComputedValueIsAppliedOnNormalThenStatement, schemaWithBadOperation, - schemaWithChecksAndThenValidationsOnThen, schemaWithComputedAttributes, schemaWithComputedAttributesAndErrorMessages, schemaWithComputedAttributeThatDoesntExist, schemaWithComputedAttributeThatDoesntExistDescription, schemaWithComputedAttributeThatDoesntExistTitle, - schemaWithComputedValueChecksInIf, schemaWithDeepVarThatDoesNotExist, schemaWithDeepVarThatDoesNotExistOnFieldset, - schemaWithGreaterThanChecksForThreeFields, - schemaWithIfStatementWithComputedValuesAndValidationChecks, schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar, - schemaWithInlineMultipleRulesForComputedAttributes, - schemaWithInlineRuleForComputedAttributeWithCopy, - schemaWithJSFLogicAndInlineRule, schemaWithMissingComputedValue, schemaWithMissingRule, - schemaWithMultipleComputedValueChecks, schemaWithNativeAndJSONLogicChecks, schemaWithNonRequiredField, - schemaWithPropertiesCheckAndValidationsInAIf, schemaWithPropertyThatDoesNotExistInThatLevelButDoesInFieldset, schemaWithReduceAccumulator, schemaWithTwoRules, diff --git a/next/test/validation/json-logic.fixtures.js b/next/test/validation/json-logic.fixtures.js index 148295c73..d3a0542dd 100644 --- a/next/test/validation/json-logic.fixtures.js +++ b/next/test/validation/json-logic.fixtures.js @@ -469,66 +469,6 @@ export const schemaWithBadOperation = { }, } -export const schemaWithInlineRuleForComputedAttributeWithCopy = { - properties: { - field_a: { - type: 'number', - }, - field_b: { - 'type': 'number', - 'x-jsf-logic-computedAttrs': { - title: { - value: 'I need this to work using the {{rule}}.', - rule: { - '+': [{ var: 'field_a' }, 10], - }, - }, - }, - }, - }, -} - -export const schemaWithInlineMultipleRulesForComputedAttributes = { - properties: { - field_a: { - type: 'number', - }, - field_b: { - 'type': 'number', - 'x-jsf-logic-computedAttrs': { - description: { - value: 'Must be between {{half_a}} and {{double_a}}.', - half_a: { - '/': [{ var: 'field_a' }, 2], - }, - double_a: { - '*': [{ var: 'field_a' }, 2], - }, - }, - }, - }, - }, -} - -export const schemaInlineComputedAttrForTitle = { - properties: { - field_a: { - type: 'number', - }, - field_b: { - 'type': 'number', - 'x-jsf-logic-computedAttrs': { - title: { - value: '{{rule}}', - rule: { - '+': [{ var: 'field_a' }, 10], - }, - }, - }, - }, - }, -} - export const schemaValidationForMaximumAndMinimumValues = { 'properties': { field_a: { From 24d64584f1c382ed90ec0a57b2b78de9bd771546 Mon Sep 17 00:00:00 2001 From: Capelo Date: Fri, 23 May 2025 16:41:04 +0100 Subject: [PATCH 19/22] chore: cleanup --- next/src/form.ts | 4 +--- next/test/validation/json-logic-v0.test.js | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/next/src/form.ts b/next/src/form.ts index e46d90f45..da7bed8cf 100644 --- a/next/src/form.ts +++ b/next/src/form.ts @@ -138,9 +138,7 @@ function validationErrorsToFormErrors(errors: ValidationErrorWithMessage[]): For if (segments.length > 0) { const lastSegment = segments[segments.length - 1] - if (!current[lastSegment]) { - current[lastSegment] = error.message - } + current[lastSegment] = error.message } } diff --git a/next/test/validation/json-logic-v0.test.js b/next/test/validation/json-logic-v0.test.js index 3e692ce5f..82289396e 100644 --- a/next/test/validation/json-logic-v0.test.js +++ b/next/test/validation/json-logic-v0.test.js @@ -315,7 +315,7 @@ describe('jsonLogic: cross-values validations', () => { field_b: 'Required field', }) expect(handleValidation({ field_a: 1, field_b: 2 }).formErrors).toEqual({ - field_a: 'A must be bigger than B', + field_a: 'A must be even', }) expect(handleValidation({ field_a: 3, field_b: 2 }).formErrors).toEqual({ field_a: 'A must be even', @@ -439,7 +439,7 @@ describe('jsonLogic: cross-values validations', () => { }) expect(handleValidation({ field_a: 10, field_b: 20 }).formErrors).toEqual(undefined) expect(handleValidation({ field_a: 20, field_b: 10 }).formErrors).toEqual({ - field_b: 'Must be greater than A', + field_b: 'Must be greater than two times A', }) expect(handleValidation({ field_a: 20, field_b: 21 }).formErrors).toEqual({ field_b: 'Must be greater than two times A', From 0b3650bab9fe9d56b089b2a78504fe225a9db9a2 Mon Sep 17 00:00:00 2001 From: Capelo Date: Mon, 26 May 2025 07:08:40 +0100 Subject: [PATCH 20/22] chore: rewrite comment --- 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 4da6424ba..637df31f3 100644 --- a/next/src/mutations.ts +++ b/next/src/mutations.ts @@ -138,7 +138,7 @@ function processBranch(fields: Field[], values: SchemaValue, branch: JsfSchema, for (const fieldName in branch.properties) { let fieldSchema = branch.properties[fieldName] - // If the field schema has computed attributes, we need to apply them to the field schema + // 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) } From d29bc6fd8490595e431b0f9b3efe8734028288ba Mon Sep 17 00:00:00 2001 From: Capelo Date: Mon, 26 May 2025 07:17:36 +0100 Subject: [PATCH 21/22] chore: comments --- next/src/validation/json-logic.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/next/src/validation/json-logic.ts b/next/src/validation/json-logic.ts index 623adb616..f8c619da5 100644 --- a/next/src/validation/json-logic.ts +++ b/next/src/validation/json-logic.ts @@ -80,16 +80,22 @@ export function validateJsonLogicRules( // If the condition is false, we return a validation error if (result === false) { + // We default to consider the error message as a string + // However, if it contains handlebars, we need to evaluate it using the computed values let errorMessage = validationData.errorMessage + if (errorMessage && containsHandlebars(errorMessage)) { errorMessage = errorMessage.replace(/\{\{(.*?)\}\}/g, (_, handlebarsVar) => { - const varName = handlebarsVar.trim() - const jsonLogicComputation = jsonLogicContext.schema.computedValues?.[varName] + const computationName = handlebarsVar.trim() + const jsonLogicComputation = jsonLogicContext.schema.computedValues?.[computationName] + + // If the handlebars variable matches the name of a computation, we run it if (jsonLogicComputation) { return jsonLogic.apply(jsonLogicComputation.rule, replaceUndefinedAndNullValuesWithNaN(formValue as ObjectValue)) } else { - return jsonLogic.apply({ var: varName }, replaceUndefinedAndNullValuesWithNaN(formValue as ObjectValue)) + // Otherwise, it's probably referring to a variable in the form, so we use it instead + return jsonLogic.apply({ var: computationName }, replaceUndefinedAndNullValuesWithNaN(formValue as ObjectValue)) } }) } From ac3eaf0d0024fb83a3247e6727ee2af53ca619a8 Mon Sep 17 00:00:00 2001 From: Capelo Date: Mon, 26 May 2025 14:04:26 +0100 Subject: [PATCH 22/22] chore: comments --- next/src/validation/json-logic.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/next/src/validation/json-logic.ts b/next/src/validation/json-logic.ts index f8c619da5..5577df530 100644 --- a/next/src/validation/json-logic.ts +++ b/next/src/validation/json-logic.ts @@ -138,7 +138,7 @@ export function applyComputedAttrsToSchema(schema: JsfObjectSchema, computedValu // - apply the computed values to the cloned schema // Otherwise, we return the original schema if (computedValuesDefinition) { - const computedValues: Record = {} + const computedValues: Record = {} Object.entries(computedValuesDefinition).forEach(([name, definition]) => { const computedValue = computePropertyValues(name, definition.rule, values) @@ -172,6 +172,7 @@ function cycleThroughPropertiesAndApplyValues(schemaCopy: JsfObjectSchema, compu cycleThroughPropertiesAndApplyValues(propertySchema, computedValues) } + // deleting x-jsf-logic-computedAttrs to keep the schema clean delete propertySchema['x-jsf-logic-computedAttrs'] }