diff --git a/.eslintrc b/.eslintrc index 91703edfe..98c85cc09 100644 --- a/.eslintrc +++ b/.eslintrc @@ -11,6 +11,7 @@ }, "rules": { "jest/no-focused-tests": "error", + "curly": ["error", "multi-line"], "arrow-body-style": 0, "default-case": 0, "import/order": [ diff --git a/src/jsonLogic.js b/src/jsonLogic.js index d2d80d775..2729d1142 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -1,5 +1,7 @@ import jsonLogic from 'json-logic-js'; +import { buildYupSchema } from './yupSchema'; + /** * Parses the JSON schema to extract the json-logic rules and returns an object * containing the validation scopes, functions to retrieve the scopes, and evaluate the @@ -9,15 +11,12 @@ import jsonLogic from 'json-logic-js'; * @returns {Object} An object containing: * - scopes {Map} - A Map of the validation scopes (with IDs as keys) * - getScope {Function} - Function to retrieve a scope by name/ID - * - validate {Function} - Function to evaluate a validation rule - * - applyValidationRuleInCondition {Function} - Evaluate a validation rule used in a condition - * - applyComputedValueInField {Function} - Evaluate a computed value rule for a field - * - applyComputedValueRuleInCondition {Function} - Evaluate a computed value rule used in a condition */ export function createValidationChecker(schema) { const scopes = new Map(); function createScopes(jsonSchema, key = 'root') { + const sampleEmptyObject = buildSampleEmptyObject(schema); scopes.set(key, createValidationsScope(jsonSchema)); Object.entries(jsonSchema?.properties ?? {}) .filter(([, property]) => property.type === 'object' || property.type === 'array') @@ -28,6 +27,8 @@ export function createValidationChecker(schema) { createScopes(property, key); } }); + + validateInlineRules(jsonSchema, sampleEmptyObject); } createScopes(schema); @@ -40,6 +41,21 @@ export function createValidationChecker(schema) { }; } +/** + * Creates a validation scope object for a schema. + * + * Builds maps of validations and computed values defined in the schema's + * x-jsf-logic section. Includes functions to evaluate the rules. + * + * @param {Object} schema - The JSON schema + * @returns {Object} The validation scope object containing: + * - validationMap - Map of validation rules + * - computedValuesMap - Map of computed value rules + * - validate {Function} - Function to evaluate a validation rule + * - applyValidationRuleInCondition {Function} - Evaluate a validation rule used in a condition + * - applyComputedValueInField {Function} - Evaluate a computed value rule for a field + * - applyComputedValueRuleInCondition {Function} - Evaluate a computed value rule used in a condition + */ function createValidationsScope(schema) { const validationMap = new Map(); const computedValuesMap = new Map(); @@ -51,12 +67,25 @@ function createValidationsScope(schema) { const validations = Object.entries(logic.validations ?? {}); const computedValues = Object.entries(logic.computedValues ?? {}); + const sampleEmptyObject = buildSampleEmptyObject(schema); validations.forEach(([id, validation]) => { + if (!validation.rule) { + throw Error(`[json-schema-form] json-logic error: Validation "${id}" has missing rule.`); + } + + checkRuleIntegrity(validation.rule, id, sampleEmptyObject); + validationMap.set(id, validation); }); computedValues.forEach(([id, computedValue]) => { + if (!computedValue.rule) { + throw Error(`[json-schema-form] json-logic error: Computed value "${id}" has missing rule.`); + } + + checkRuleIntegrity(computedValue.rule, id, sampleEmptyObject); + computedValuesMap.set(id, computedValue); }); @@ -72,8 +101,13 @@ function createValidationsScope(schema) { const validation = validationMap.get(id); return validate(validation.rule, values); }, - applyComputedValueInField(id, values) { + applyComputedValueInField(id, values, fieldName) { const validation = computedValuesMap.get(id); + if (validation === undefined) { + throw Error( + `[json-schema-form] json-logic error: Computed value "${id}" doesn't exist in field "${fieldName}".` + ); + } return validate(validation.rule, values); }, applyComputedValueRuleInCondition(id, values) { @@ -112,6 +146,12 @@ export function yupSchemaWithCustomJSONLogic({ field, logic, config, id }) { const { parentID = 'root' } = config; const validation = logic.getScope(parentID).validationMap.get(id); + if (validation === undefined) { + throw Error( + `[json-schema-form] json-logic error: "${field.name}" required validation "${id}" doesn't exist.` + ); + } + return (yupSchema) => yupSchema.test( `${field.name}-validation-${id}`, @@ -123,26 +163,264 @@ export function yupSchemaWithCustomJSONLogic({ field, logic, config, id }) { ); } +const HANDLEBARS_REGEX = /\{\{([^{}]+)\}\}/g; + +/** + * Replaces Handlebars templates in a value with computed values. + * + * Handles recursively replacing Handlebars templates "{{var}}" in strings + * with computed values looked up from the validation logic. + * + * @param {Object} options - Options object + * @param {*} options.value - The value to replace templates in + * @param {Object} options.logic - The validation logic object + * @param {Object} options.formValues - The current form values + * @param {string} options.parentID - The ID of the validation scope + * @param {string} options.name - The name of the field + * @returns {*} The value with templates replaced with computed values + */ +function replaceHandlebarsTemplates({ + value: toReplace, + logic, + formValues, + parentID, + name: fieldName, +}) { + if (typeof toReplace === 'string') { + return toReplace.replace(HANDLEBARS_REGEX, (match, key) => { + return logic.getScope(parentID).applyComputedValueInField(key.trim(), formValues, fieldName); + }); + } else if (typeof toReplace === 'object') { + const { value, ...rules } = toReplace; + + if (Object.keys(rules).length > 1 && !value) { + throw Error('Cannot define multiple rules without a template string with key `value`.'); + } + + const computedTemplateValue = Object.entries(rules).reduce((prev, [key, rule]) => { + const computedValue = logic.getScope(parentID).evaluateValidation(rule, formValues); + return prev.replaceAll(`{{${key}}}`, computedValue); + }, value); + + return computedTemplateValue.replace(/\{\{([^{}]+)\}\}/g, (match, key) => { + return logic.getScope(parentID).applyComputedValueInField(key.trim(), formValues, fieldName); + }); + } + return toReplace; +} + +/** + * Builds computed attributes for a field based on jsonLogic rules. + * + * Processes rules defined in the schema's x-jsf-logic section to build + * computed attributes like label, description, etc. + * + * Handles replacing handlebars templates in strings with computed values. + * + * @param {Object} fieldParams - The field configuration parameters + * @param {Object} options - Options + * @param {string} [options.parentID='root'] - ID of the validation scope + * @returns {Function} A function to build the computed attributes + */ export function calculateComputedAttributes(fieldParams, { parentID = 'root' } = {}) { - return ({ logic, formValues }) => { - const { computedAttributes } = fieldParams; + return ({ logic, isRequired, config, formValues }) => { + const { name, computedAttributes } = fieldParams; const attributes = Object.fromEntries( Object.entries(computedAttributes) - .map(handleComputedAttribute(logic, formValues, parentID)) + .map(handleComputedAttribute(logic, formValues, parentID, name)) .filter(([, value]) => value !== null) ); - return attributes; + return { + ...attributes, + schema: buildYupSchema( + { ...fieldParams, ...attributes, required: isRequired }, + config, + logic + ), + }; }; } -function handleComputedAttribute(logic, formValues, parentID) { +/** + * Handles computing a single attribute value. + * + * Evaluates jsonLogic rules to build the computed value. + * + * @param {Object} logic - Validation logic + * @param {Object} formValues - Current form values + * @param {string} parentID - ID of the validation scope + * @param {string} name - Name of the field + * @returns {Function} Function to compute the attribute value + */ +function handleComputedAttribute(logic, formValues, parentID, name) { return ([key, value]) => { - if (key === 'const') - return [key, logic.getScope(parentID).applyComputedValueInField(value, formValues)]; - - if (typeof value === 'string') { - return [key, logic.getScope(parentID).applyComputedValueInField(value, formValues)]; + switch (key) { + case 'description': + return [key, replaceHandlebarsTemplates({ value, logic, formValues, parentID, name })]; + case 'title': + return ['label', replaceHandlebarsTemplates({ value, logic, formValues, parentID, name })]; + case 'x-jsf-errorMessage': + return [ + 'errorMessage', + handleNestedObjectForComputedValues(value, formValues, parentID, logic, name), + ]; + case 'x-jsf-presentation': { + if (value.statement) { + return [ + 'statement', + handleNestedObjectForComputedValues(value.statement, formValues, parentID, logic, name), + ]; + } + return [ + key, + handleNestedObjectForComputedValues(value.statement, formValues, parentID, logic, name), + ]; + } + case 'const': + default: + return [key, logic.getScope(parentID).applyComputedValueInField(value, formValues, name)]; } }; } + +function handleNestedObjectForComputedValues(values, formValues, parentID, logic, name) { + return Object.fromEntries( + Object.entries(values).map(([key, value]) => { + return [key, replaceHandlebarsTemplates({ value, logic, formValues, parentID, name })]; + }) + ); +} + +/** + * Builds a sample empty object for the given schema. + * + * Recursively builds an object with empty values for each property in the schema. + * Used to provide a valid data structure to test jsonLogic validation rules against. + * + * Handles objects, arrays, and nested schemas. + * + * @param {Object} schema - The JSON schema + * @returns {Object} Sample empty object based on the schema + */ +function buildSampleEmptyObject(schema = {}) { + const sample = {}; + if (typeof schema !== 'object' || !schema.properties) { + return schema; + } + + for (const key in schema.properties) { + if (schema.properties[key].type === 'object') { + sample[key] = buildSampleEmptyObject(schema.properties[key]); + } else if (schema.properties[key].type === 'array') { + const itemSchema = schema.properties[key].items; + sample[key] = buildSampleEmptyObject(itemSchema); + } else { + sample[key] = true; + } + } + + return sample; +} + +/** + * Validates inline jsonLogic rules defined in the schema's x-jsf-logic-computedAttrs. + * + * For each field with computed attributes, checks that the variables + * referenced in the rules exist in the schema. + * + * Throws if any variable in a computed attribute rule does not exist. + * + * @param {Object} jsonSchema - The JSON schema object + * @param {Object} sampleEmptyObject - Sample empty object based on the schema + */ +function validateInlineRules(jsonSchema, sampleEmptyObject) { + const properties = (jsonSchema?.properties || jsonSchema?.items?.properties) ?? {}; + Object.entries(properties) + .filter(([, property]) => property['x-jsf-logic-computedAttrs'] !== undefined) + .forEach(([fieldName, property]) => { + Object.entries(property['x-jsf-logic-computedAttrs']) + .filter(([, value]) => typeof value === 'object') + .forEach(([key, item]) => { + Object.values(item).forEach((rule) => { + checkRuleIntegrity( + rule, + fieldName, + sampleEmptyObject, + (item) => + `[json-schema-form] json-logic error: fieldName "${item.var}" doesn't exist in field "${fieldName}.x-jsf-logic-computedAttrs.${key}".` + ); + }); + }); + }); +} + +/** + * Checks the integrity of a jsonLogic rule by validating that all referenced variables exist in the provided data object. + * Throws an error if any variable in the rule does not exist in the data. + * + * @example + * + * const rule = { "+": [{ "var": "iDontExist"}, 10 ]} + * const badData = { a: 1 } + * checkRuleIntegrity(rule, "add_ten_to_field", badData) + * // throws Error(`"iDontExist" in rule "add_ten_to_field" does not exist as a JSON schema property.`) + * + * + * @param {Object|Array} rule - The jsonLogic rule object or array to validate + * @param {string} id - The ID of the rule (used in error messages) + * @param {Object} data - The data object to check the rule variables against + * @param {Function} errorMessage - Function to generate custom error message. + * Receives the invalid rule part and should throw an error message string. + */ +function checkRuleIntegrity( + rule, + id, + data, + errorMessage = (item) => + `[json-schema-form] json-logic error: rule "${id}" has no variable "${item.var}".` +) { + Object.entries(rule ?? {}).map(([operator, subRule]) => { + if (!Array.isArray(subRule) && subRule !== null && subRule !== undefined) return; + throwIfUnknownOperator(operator, subRule, id); + + subRule.map((item) => { + const isVar = item !== null && typeof item === 'object' && Object.hasOwn(item, 'var'); + if (isVar) { + const exists = jsonLogic.apply({ var: removeIndicesFromPath(item.var) }, data); + if (exists === null) { + throw Error(errorMessage(item)); + } + } else { + checkRuleIntegrity(item, id, data); + } + }); + }); +} + +function throwIfUnknownOperator(operator, subRule, id) { + try { + jsonLogic.apply({ [operator]: subRule }); + } catch (e) { + if (e.message === `Unrecognized operation ${operator}`) { + throw Error( + `[json-schema-form] json-logic error: in "${id}" rule there is an unknown operator "${operator}".` + ); + } + } +} + +const regexToGetIndices = /\.\d+\./g; // eg. .0., .10. + +/** + * Removes array indices from a json schema path string. + * Converts paths like "foo.0.bar" to "foo.bar". + * This allows checking if a variable exists in an array item schema without needing the specific index. + * + * @param {string} path - The json schema path potentially containing array indices + * @returns {string} The path with array indices removed + */ +function removeIndicesFromPath(path) { + const intermediatePath = path.replace(regexToGetIndices, '.'); + return intermediatePath.replace(/\.\d+$/, ''); +} diff --git a/src/tests/createHeadlessForm.test.js b/src/tests/createHeadlessForm.test.js index 784e5e395..09e57e9a8 100644 --- a/src/tests/createHeadlessForm.test.js +++ b/src/tests/createHeadlessForm.test.js @@ -57,6 +57,7 @@ import { schemaForErrorMessageSpecificity, jsfConfigForErrorMessageSpecificity, } from './helpers'; +import { mockConsole, restoreConsoleAndEnsureItWasNotCalled } from './testUtils'; function buildJSONSchemaInput({ presentationFields, inputFields = {}, required }) { return { @@ -92,17 +93,8 @@ const getField = (fields, name, ...subNames) => { return field; }; -beforeEach(() => { - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); -}); - -afterEach(() => { - expect(console.error).not.toHaveBeenCalled(); - console.error.mockRestore(); - expect(console.warn).not.toHaveBeenCalled(); - console.warn.mockRestore(); -}); +beforeEach(mockConsole); +afterEach(restoreConsoleAndEnsureItWasNotCalled); describe('createHeadlessForm', () => { it('returns empty result given no schema', () => { diff --git a/src/tests/jsonLogic.fixtures.js b/src/tests/jsonLogic.fixtures.js index 1cc9f9aa7..1d8b16cd0 100644 --- a/src/tests/jsonLogic.fixtures.js +++ b/src/tests/jsonLogic.fixtures.js @@ -77,6 +77,70 @@ export const schemaWithNativeAndJSONLogicChecks = { 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: { @@ -144,8 +208,113 @@ export const schemaWithComputedAttributes = { 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 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': { + default: '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}}', + }, + }, }, }, }, @@ -157,6 +326,116 @@ export const schemaWithComputedAttributes = { '*': [{ 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], + }, + }, }, }, }; diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index b40a06b80..a3ef30cff 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -4,11 +4,29 @@ import { createSchemaWithRulesOnFieldA, createSchemaWithThreePropertiesWithRuleOnFieldA, multiRuleSchema, + schemaWithBadOperation, + schemaWithComputedAttributeThatDoesntExist, + schemaWithComputedAttributeThatDoesntExistDescription, + schemaWithComputedAttributeThatDoesntExistTitle, schemaWithComputedAttributes, + schemaWithComputedAttributesAndErrorMessages, + schemaWithDeepVarThatDoesNotExist, + schemaWithDeepVarThatDoesNotExistOnFieldset, + schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar, + schemaWithMissingComputedValue, + schemaWithMissingRule, schemaWithNativeAndJSONLogicChecks, schemaWithNonRequiredField, + schemaWithPropertyThatDoesNotExistInThatLevelButDoesInFieldset, schemaWithTwoRules, + schemaWithUnknownVariableInComputedValues, + schemaWithUnknownVariableInValidations, + schemaWithValidationThatDoesNotExistOnProperty, } from './jsonLogic.fixtures'; +import { mockConsole, restoreConsoleAndEnsureItWasNotCalled } from './testUtils'; + +beforeEach(mockConsole); +afterEach(restoreConsoleAndEnsureItWasNotCalled); describe('jsonLogic: cross-values validations', () => { describe('Does not conflict with native JSON schema', () => { @@ -82,6 +100,85 @@ describe('jsonLogic: cross-values validations', () => { }); }); + describe('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 "++".', + ], + ]; + + test.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({ @@ -221,10 +318,31 @@ describe('jsonLogic: cross-values validations', () => { 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.value).toEqual(4); handleValidation({ field_a: 4 }); expect(fieldB.default).toEqual(8); + expect(fieldB.label).toEqual('This is 8!'); + }); + + 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' }); }); }); }); diff --git a/src/tests/testUtils.js b/src/tests/testUtils.js new file mode 100644 index 000000000..d0231426d --- /dev/null +++ b/src/tests/testUtils.js @@ -0,0 +1,11 @@ +export function mockConsole() { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); +} + +export function restoreConsoleAndEnsureItWasNotCalled() { + expect(console.error).not.toHaveBeenCalled(); + console.error.mockRestore(); + expect(console.warn).not.toHaveBeenCalled(); + console.warn.mockRestore(); +}