From 5102c6f0fbb1efb79348be1872b3c3e5107ee19a Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 29 Jun 2023 16:24:04 +0200 Subject: [PATCH 01/13] feat: JSON Logic skeleton and plumbing setup chore: clean up conditional additions chore: remove const tests chore: remove dupe file chore: remove group array stuff chore: clean up yupschema chore: clean up helpers a small bit chore: remove all error handling for now chore: clean up package-lock chore: more removing stuff chore: clean more --- package-lock.json | 11 ++ package.json | 1 + src/createHeadlessForm.js | 14 ++- src/helpers.js | 60 +++++++--- src/jsonLogic.js | 96 +++++++++++++++ src/tests/jsonLogic.test.js | 213 +++++++++++++++++++++++++++++++++ src/tests/jsonLogicFixtures.js | 178 +++++++++++++++++++++++++++ src/yupSchema.js | 10 +- 8 files changed, 560 insertions(+), 23 deletions(-) create mode 100644 src/jsonLogic.js create mode 100644 src/tests/jsonLogic.test.js create mode 100644 src/tests/jsonLogicFixtures.js diff --git a/package-lock.json b/package-lock.json index 7a64d5962..c0e99e7af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.4.3-beta.0", "license": "MIT", "dependencies": { + "json-logic-js": "^2.0.2", "lodash": "^4.17.21", "randexp": "^0.5.3", "yup": "^0.30.0" @@ -6461,6 +6462,11 @@ "node": ">=4" } }, + "node_modules/json-logic-js": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/json-logic-js/-/json-logic-js-2.0.2.tgz", + "integrity": "sha512-ZBtBdMJieqQcH7IX/LaBsr5pX+Y5JIW+EhejtM3Ffg2jdN9Iwf+Ht6TbHnvAZ/YtwyuhPaCBlnvzrwVeWdvGDQ==" + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -13342,6 +13348,11 @@ "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", "dev": true }, + "json-logic-js": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/json-logic-js/-/json-logic-js-2.0.2.tgz", + "integrity": "sha512-ZBtBdMJieqQcH7IX/LaBsr5pX+Y5JIW+EhejtM3Ffg2jdN9Iwf+Ht6TbHnvAZ/YtwyuhPaCBlnvzrwVeWdvGDQ==" + }, "json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", diff --git a/package.json b/package.json index 9ae29288e..b1151cd36 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ ] }, "dependencies": { + "json-logic-js": "^2.0.2", "lodash": "^4.17.21", "randexp": "^0.5.3", "yup": "^0.30.0" diff --git a/src/createHeadlessForm.js b/src/createHeadlessForm.js index 3bd7e7332..be2f9a403 100644 --- a/src/createHeadlessForm.js +++ b/src/createHeadlessForm.js @@ -24,6 +24,7 @@ import { getInputType, } from './internals/fields'; import { pickXKey } from './internals/helpers'; +import { createValidationChecker } from './jsonLogic'; import { buildYupSchema } from './yupSchema'; // Some type definitions (to be migrated into .d.ts file or TS Interfaces) @@ -222,11 +223,11 @@ function getComposeFunctionForField(fieldParams, hasCustomizations) { * @param {JsfConfig} config - parser config * @returns {Object} field object */ -function buildField(fieldParams, config, scopedJsonSchema) { +function buildField(fieldParams, config, scopedJsonSchema, validations) { const customProperties = getCustomPropertiesForField(fieldParams, config); const composeFn = getComposeFunctionForField(fieldParams, !!customProperties); - const yupSchema = buildYupSchema(fieldParams, config); + const yupSchema = buildYupSchema(fieldParams, config, validations); const calculateConditionalFieldsClosure = fieldParams.isDynamic && calculateConditionalProperties(fieldParams, customProperties); @@ -267,7 +268,7 @@ function buildField(fieldParams, config, scopedJsonSchema) { * @param {JsfConfig} config - JSON-schema-form config * @returns {ParserFields} ParserFields */ -function getFieldsFromJSONSchema(scopedJsonSchema, config) { +function getFieldsFromJSONSchema(scopedJsonSchema, config, validations) { if (!scopedJsonSchema) { // NOTE: other type of verifications might be needed. return []; @@ -303,7 +304,7 @@ function getFieldsFromJSONSchema(scopedJsonSchema, config) { fields.push(groupField); }); } else { - fields.push(buildField(fieldParams, config, scopedJsonSchema)); + fields.push(buildField(fieldParams, config, scopedJsonSchema, validations)); } }); @@ -323,9 +324,10 @@ export function createHeadlessForm(jsonSchema, customConfig = {}) { }; try { - const fields = getFieldsFromJSONSchema(jsonSchema, config); + const validations = createValidationChecker(jsonSchema); + const fields = getFieldsFromJSONSchema(jsonSchema, config, validations); - const handleValidation = handleValuesChange(fields, jsonSchema, config); + const handleValidation = handleValuesChange(fields, jsonSchema, config, validations); updateFieldsProperties(fields, getPrefillValues(fields, config.initialValues), jsonSchema); diff --git a/src/helpers.js b/src/helpers.js index 47cb7b893..01523319e 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -40,8 +40,8 @@ export function getField(fieldName, fields) { * @param {any} value * @returns */ -export function validateFieldSchema(field, value) { - const validator = buildYupSchema(field); +export function validateFieldSchema(field, value, validations) { + const validator = buildYupSchema(field, {}, validations); return validator().isValidSync(value); } @@ -246,7 +246,14 @@ function updateField(field, requiredFields, node, formValues) { * @param {Set} accRequired - set of required field names gathered by traversing the tree * @returns {Object} */ -function processNode(node, formValues, formFields, accRequired = new Set()) { +export function processNode({ + node, + formValues, + formFields, + accRequired = new Set(), + parentID = 'root', + validations, +}) { // Set initial required fields const requiredFields = new Set(accRequired); @@ -263,25 +270,29 @@ function processNode(node, formValues, formFields, accRequired = new Set()) { }); if (node.if) { - const matchesCondition = checkIfConditionMatches(node, formValues, formFields); + const matchesCondition = checkIfConditionMatches(node, formValues, formFields, validations); // BUG HERE (unreleated) - what if it matches but doesn't has a then, // it should do nothing, but instead it jumps to node.else when it shouldn't. if (matchesCondition && node.then) { - const { required: branchRequired } = processNode( - node.then, + const { required: branchRequired } = processNode({ + node: node.then, formValues, formFields, - requiredFields - ); + accRequired: requiredFields, + parentID, + validations, + }); branchRequired.forEach((field) => requiredFields.add(field)); } else if (node.else) { - const { required: branchRequired } = processNode( - node.else, + const { required: branchRequired } = processNode({ + node: node.else, formValues, formFields, - requiredFields - ); + accRequired: requiredFields, + parentID, + validations, + }); branchRequired.forEach((field) => requiredFields.add(field)); } } @@ -302,7 +313,16 @@ function processNode(node, formValues, formFields, accRequired = new Set()) { if (node.allOf) { node.allOf - .map((allOfNode) => processNode(allOfNode, formValues, formFields, requiredFields)) + .map((allOfNode) => + processNode({ + node: allOfNode, + formValues, + formFields, + accRequired: requiredFields, + parentID, + validations, + }) + ) .forEach(({ required: allOfItemRequired }) => { allOfItemRequired.forEach(requiredFields.add, requiredFields); }); @@ -313,7 +333,13 @@ function processNode(node, formValues, formFields, accRequired = new Set()) { const inputType = getInputType(nestedNode); if (inputType === supportedTypes.FIELDSET) { // It's a fieldset, which might contain scoped conditions - processNode(nestedNode, formValues[name] || {}, getField(name, formFields).fields); + processNode({ + node: nestedNode, + formValues: formValues[name] || {}, + formFields: getField(name, formFields).fields, + validations, + parentID: name, + }); } }); } @@ -348,11 +374,11 @@ function clearValuesIfNotVisible(fields, formValues) { * @param {Object} formValues - current values of the form * @param {Object} jsonSchema - JSON schema object */ -export function updateFieldsProperties(fields, formValues, jsonSchema) { +export function updateFieldsProperties(fields, formValues, jsonSchema, validations) { if (!jsonSchema?.properties) { return; } - processNode(jsonSchema, formValues, fields); + processNode({ node: jsonSchema, formValues, formFields: fields, validations }); clearValuesIfNotVisible(fields, formValues); } @@ -415,6 +441,7 @@ export function extractParametersFromNode(schemaNode) { const presentation = pickXKey(schemaNode, 'presentation') ?? {}; const errorMessage = pickXKey(schemaNode, 'errorMessage') ?? {}; + const requiredValidations = schemaNode['x-jsf-logic-validations']; const node = omit(schemaNode, ['x-jsf-presentation', 'presentation']); @@ -460,6 +487,7 @@ export function extractParametersFromNode(schemaNode) { // Handle [name].presentation ...presentation, + requiredValidations, description: containsHTML(description) ? wrapWithSpan(description, { class: 'jsf-description', diff --git a/src/jsonLogic.js b/src/jsonLogic.js new file mode 100644 index 000000000..8d073c044 --- /dev/null +++ b/src/jsonLogic.js @@ -0,0 +1,96 @@ +import jsonLogic from 'json-logic-js'; + +/** + * Parses the JSON schema to extract the advanced validation logic and returns a set of functionality to check the current status of said rules. + * @param {Object} schema - JSON schema node + * @param {Object} initialValues - form state + * @returns {Object} + */ +export function createValidationChecker(schema) { + const scopes = new Map(); + + function createScopes(jsonSchema, key = 'root') { + scopes.set(key, createValidationsScope(jsonSchema)); + Object.entries(jsonSchema?.properties ?? {}) + .filter(([, property]) => property.type === 'object' || property.type === 'array') + .forEach(([key, property]) => { + if (property.type === 'array') { + createScopes(property.items, `${key}[]`); + } + createScopes(property, key); + }); + } + + createScopes(schema); + + return { + scopes, + getScope(name = 'root') { + return scopes.get(name); + }, + }; +} + +function createValidationsScope(schema) { + const validationMap = new Map(); + const computedValuesMap = new Map(); + + const logic = schema?.['x-jsf-logic'] ?? { + validations: {}, + computedValues: {}, + }; + + const validations = Object.entries(logic.validations ?? {}); + const computedValues = Object.entries(logic.computedValues ?? {}); + + validations.forEach(([id, validation]) => { + validationMap.set(id, validation); + }); + + computedValues.forEach(([id, computedValue]) => { + computedValuesMap.set(id, computedValue); + }); + + function evaluateValidation(rule, values) { + return jsonLogic.apply(rule, clean(values)); + } + + return { + validationMap, + computedValuesMap, + evaluateValidation, + evaluateValidationRuleInCondition(id, values) { + const validation = validationMap.get(id); + return evaluateValidation(validation.rule, values); + }, + evaluateComputedValueRuleForField(id, values) { + const validation = computedValuesMap.get(id); + return evaluateValidation(validation.rule, values); + }, + evaluateComputedValueRuleInCondition(id, values) { + const validation = computedValuesMap.get(id); + return evaluateValidation(validation.rule, values); + }, + }; +} + +function clean(values = {}) { + return Object.entries(values).reduce((prev, [key, value]) => { + return { ...prev, [key]: value === undefined ? null : value }; + }, {}); +} + +export function yupSchemaWithCustomJSONLogic({ field, validations, config, id }) { + const { parentID = 'root' } = config; + const validation = validations.getScope(parentID).validationMap.get(id); + + return (yupSchema) => + yupSchema.test( + `${field.name}-validation-${id}`, + validation?.errorMessage ?? 'This field is invalid.', + (value, { parent }) => { + if (value === undefined && !field.required) return true; + return jsonLogic.apply(validation.rule, parent); + } + ); +} diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js new file mode 100644 index 000000000..bf3cda04e --- /dev/null +++ b/src/tests/jsonLogic.test.js @@ -0,0 +1,213 @@ +import { createHeadlessForm } from '../createHeadlessForm'; + +import { + createSchemaWithRulesOnFieldA, + createSchemaWithThreePropertiesWithRuleOnFieldA, + multiRuleSchema, + schemaWithNativeAndJSONLogicChecks, + schemaWithNonRequiredField, + schemaWithTwoRules, +} from './jsonLogicFixtures'; + +describe('cross-value validations', () => { + describe('Does not conflict with native JSON schema', () => { + it('When a field is not required, validations should not block submitting when its an empty value', () => { + const { handleValidation } = createHeadlessForm(schemaWithNonRequiredField, { + strictInputType: false, + }); + expect(handleValidation({}).formErrors).toEqual(undefined); + expect(handleValidation({ field_a: 0 }).formErrors).toEqual({ + field_a: 'Must be greater than 10', + }); + expect(handleValidation({ field_a: 'incorrect value' }).formErrors).toEqual({ + field_a: 'The value must be a number', + }); + expect(handleValidation({ field_a: 11 }).formErrors).toEqual(undefined); + }); + + it('Native validations always appear first', () => { + 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 5', + }); + expect(handleValidation({ field_a: 5 }).formErrors).toEqual({ + field_a: 'Must be greater than 10', + }); + }); + }); + + 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('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 } + ); + const { formErrors } = handleValidation({ field_a: 2, field_b: 4 }); + expect(formErrors.field_a).toEqual('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 + ); + }); + }); + + 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('2 rules where A must be bigger than B and not an even number in another rule', () => { + 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); + }); + }); +}); diff --git a/src/tests/jsonLogicFixtures.js b/src/tests/jsonLogicFixtures.js new file mode 100644 index 000000000..febd9b1d0 --- /dev/null +++ b/src/tests/jsonLogicFixtures.js @@ -0,0 +1,178 @@ +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', + '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], + }, + }, + }, + }, + required: [], +}; + +export const schemaWithNativeAndJSONLogicChecks = { + properties: { + field_a: { + type: 'number', + minimum: 5, + '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], + }, + }, + }, + }, + required: ['field_a'], +}; + +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 schemaWithInlineRuleForComputedAttributeWithoutCopy = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: { + rule: { + '+': [{ var: 'field_a' }, 10], + }, + }, + }, + }, + }, +}; + +export const schemaWithInlineRuleForComputedAttributeWithOnlyTheRule = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + minimum: { + rule: { + '+': [{ var: 'field_a' }, 10], + }, + }, + 'x-jsf-errorMessage': { + minimum: { + value: 'This should be greater than {{rule}}.', + rule: { + '+': [{ var: 'field_a' }, 10], + }, + }, + }, + }, + }, + }, +}; diff --git a/src/yupSchema.js b/src/yupSchema.js index 42f30a30f..0c8bee152 100644 --- a/src/yupSchema.js +++ b/src/yupSchema.js @@ -4,6 +4,7 @@ import { randexp } from 'randexp'; import { string, number, boolean, object, array } from 'yup'; import { supportedTypes } from './internals/fields'; +import { yupSchemaWithCustomJSONLogic } from './jsonLogic'; import { convertDiskSizeFromTo } from './utils'; /** @@ -196,7 +197,7 @@ const getYupSchema = ({ inputType, ...field }) => { * @param {FieldParameters} field Input fields * @returns {Function} Yup schema */ -export function buildYupSchema(field, config) { +export function buildYupSchema(field, config, validations) { const { inputType, jsonType: jsonTypeValue, errorMessage = {}, ...propertyFields } = field; const isCheckboxBoolean = typeof propertyFields.checkboxValue === 'boolean'; let baseSchema; @@ -388,6 +389,13 @@ export function buildYupSchema(field, config) { if (propertyFields.accept) { validators.push(withFileFormat); } + + if (propertyFields.requiredValidations) { + propertyFields.requiredValidations.forEach((id) => + validators.push(yupSchemaWithCustomJSONLogic({ field, id, validations, config })) + ); + } + return flow(validators); } From 05acf5fbac736978eca52b16f0d8f4ffeaf65529 Mon Sep 17 00:00:00 2001 From: brennj Date: Wed, 16 Aug 2023 13:29:30 +0200 Subject: [PATCH 02/13] chore: one last thing to cleanup --- src/helpers.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helpers.js b/src/helpers.js index 01523319e..5313b3990 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -270,7 +270,7 @@ export function processNode({ }); if (node.if) { - const matchesCondition = checkIfConditionMatches(node, formValues, formFields, validations); + const matchesCondition = checkIfConditionMatches(node, formValues, formFields); // BUG HERE (unreleated) - what if it matches but doesn't has a then, // it should do nothing, but instead it jumps to node.else when it shouldn't. if (matchesCondition && node.then) { From da5c220b2017c819323989835e4d4f14ec5d3fc7 Mon Sep 17 00:00:00 2001 From: brennj Date: Wed, 16 Aug 2023 13:36:46 +0200 Subject: [PATCH 03/13] chore: add some jsdocs here --- src/jsonLogic.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/jsonLogic.js b/src/jsonLogic.js index 8d073c044..fd6021828 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -80,6 +80,18 @@ function clean(values = {}) { }, {}); } +/** + * Creates a Yup validation test function with custom JSON Logic for a specific field. + * + * @param {Object} options - The options for creating the validation function. + * @param {Object} options.field - The field configuration object. + * @param {string} options.field.name - The name of the field. + * @param {Object} options.validations - The validations object containing validation scopes and rules. + * @param {Object} options.config - Additional configuration options. + * @param {string} options.config.id - The ID of the validation rule. + * @param {string} [options.config.parentID='root'] - The ID of the validation rule scope. + * @returns {Function} A Yup validation test function. + */ export function yupSchemaWithCustomJSONLogic({ field, validations, config, id }) { const { parentID = 'root' } = config; const validation = validations.getScope(parentID).validationMap.get(id); From f990a57dbcf6775e4633f8b4341d28670bae5370 Mon Sep 17 00:00:00 2001 From: brennj Date: Tue, 22 Aug 2023 12:57:23 +0200 Subject: [PATCH 04/13] chore: support barebones computedAttrs --- src/createHeadlessForm.js | 20 +++++++++++++++++--- src/helpers.js | 32 ++++++++++++++++++++++++++++---- src/jsonLogic.js | 30 ++++++++++++++++++++++++++++++ src/tests/jsonLogic.test.js | 13 +++++++++++++ src/tests/jsonLogicFixtures.js | 27 +++++++++++++++++++++++++++ 5 files changed, 115 insertions(+), 7 deletions(-) diff --git a/src/createHeadlessForm.js b/src/createHeadlessForm.js index be2f9a403..68f58412d 100644 --- a/src/createHeadlessForm.js +++ b/src/createHeadlessForm.js @@ -24,7 +24,7 @@ import { getInputType, } from './internals/fields'; import { pickXKey } from './internals/helpers'; -import { createValidationChecker } from './jsonLogic'; +import { calculateComputedAttributes, createValidationChecker } from './jsonLogic'; import { buildYupSchema } from './yupSchema'; // Some type definitions (to be migrated into .d.ts file or TS Interfaces) @@ -188,6 +188,10 @@ function applyFieldsDependencies(fieldsParameters, node) { applyFieldsDependencies(fieldsParameters, condition); }); } + + if (node?.['x-jsf-logic']) { + applyFieldsDependencies(fieldsParameters, node['x-jsf-logic']); + } } /** @@ -236,6 +240,10 @@ function buildField(fieldParams, config, scopedJsonSchema, validations) { customProperties ); + const getComputedAttributes = + Object.keys(fieldParams.computedAttributes).length > 0 && + calculateComputedAttributes(fieldParams, config); + const hasCustomValidations = !!customProperties && size(pick(customProperties, SUPPORTED_CUSTOM_VALIDATION_FIELD_PARAMS)) > 0; @@ -251,6 +259,7 @@ function buildField(fieldParams, config, scopedJsonSchema, validations) { ...(hasCustomValidations && { calculateCustomValidationProperties: calculateCustomValidationPropertiesClosure, }), + ...(getComputedAttributes && { getComputedAttributes }), // field customization properties ...(customProperties && { fieldCustomization: customProperties }), // base schema @@ -300,7 +309,7 @@ function getFieldsFromJSONSchema(scopedJsonSchema, config, validations) { addFieldText: fieldParams.addFieldText, }; - buildField(fieldParams, config, scopedJsonSchema).forEach((groupField) => { + buildField(fieldParams, config, scopedJsonSchema, validations).forEach((groupField) => { fields.push(groupField); }); } else { @@ -329,7 +338,12 @@ export function createHeadlessForm(jsonSchema, customConfig = {}) { const handleValidation = handleValuesChange(fields, jsonSchema, config, validations); - updateFieldsProperties(fields, getPrefillValues(fields, config.initialValues), jsonSchema); + updateFieldsProperties( + fields, + getPrefillValues(fields, config.initialValues), + jsonSchema, + validations + ); return { fields, diff --git a/src/helpers.js b/src/helpers.js index 5313b3990..1af9a3b96 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -169,7 +169,7 @@ export function getPrefillValues(fields, initialValues = {}) { * @param {Object} node - JSON-schema node * @returns */ -function updateField(field, requiredFields, node, formValues) { +function updateField(field, requiredFields, node, formValues, validations, config) { // If there was an error building the field, it might not exist in the form even though // it can be mentioned in the schema so we return early in that case if (!field) { @@ -216,6 +216,18 @@ function updateField(field, requiredFields, node, formValues) { } }); + if (field.getComputedAttributes) { + const computedFieldValues = field.getComputedAttributes({ + field, + isRequired: fieldIsRequired, + node, + formValues, + config, + validations, + }); + updateValues(computedFieldValues); + } + // If field has a calculateConditionalProperties closure, run it and update the field properties if (field.calculateConditionalProperties) { const newFieldValues = field.calculateConditionalProperties(fieldIsRequired, node); @@ -260,13 +272,15 @@ export function processNode({ // Go through the node properties definition and update each field accordingly Object.keys(node.properties ?? []).forEach((fieldName) => { const field = getField(fieldName, formFields); - updateField(field, requiredFields, node, formValues); + updateField(field, requiredFields, node, formValues, validations, { parentID }); }); // Update required fields based on the `required` property and mutate node if needed node.required?.forEach((fieldName) => { requiredFields.add(fieldName); - updateField(getField(fieldName, formFields), requiredFields, node, formValues); + updateField(getField(fieldName, formFields), requiredFields, node, formValues, validations, { + parentID, + }); }); if (node.if) { @@ -306,7 +320,7 @@ export function processNode({ node.anyOf.forEach(({ required = [] }) => { required.forEach((fieldName) => { const field = getField(fieldName, formFields); - updateField(field, requiredFields, node, formValues); + updateField(field, requiredFields, node, formValues, validations, { parentID }); }); }); } @@ -442,6 +456,15 @@ export function extractParametersFromNode(schemaNode) { const presentation = pickXKey(schemaNode, 'presentation') ?? {}; const errorMessage = pickXKey(schemaNode, 'errorMessage') ?? {}; const requiredValidations = schemaNode['x-jsf-logic-validations']; + const computedAttributes = schemaNode['x-jsf-logic-computedAttrs']; + + // This is when a forced value is computed. + const decoratedComputedAttributes = { + ...(computedAttributes ?? {}), + ...(computedAttributes?.const && computedAttributes?.default + ? { value: computedAttributes.const } + : {}), + }; const node = omit(schemaNode, ['x-jsf-presentation', 'presentation']); @@ -488,6 +511,7 @@ export function extractParametersFromNode(schemaNode) { // Handle [name].presentation ...presentation, requiredValidations, + computedAttributes: decoratedComputedAttributes, description: containsHTML(description) ? wrapWithSpan(description, { class: 'jsf-description', diff --git a/src/jsonLogic.js b/src/jsonLogic.js index fd6021828..df9665475 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -106,3 +106,33 @@ export function yupSchemaWithCustomJSONLogic({ field, validations, config, id }) } ); } + +export function calculateComputedAttributes(fieldParams, { parentID = 'root' } = {}) { + return ({ validations, formValues }) => { + const { name, computedAttributes } = fieldParams; + const attributes = Object.fromEntries( + Object.entries(computedAttributes) + .map(handleComputedAttribute(validations, formValues, parentID, name)) + .filter(([, value]) => value !== null) + ); + + return attributes; + }; +} + +function handleComputedAttribute(validations, formValues, parentID, name) { + return ([key, value]) => { + if (key === 'const') + return [ + key, + validations.getScope(parentID).evaluateComputedValueRuleForField(value, formValues, name), + ]; + + if (typeof value === 'string') { + return [ + key, + validations.getScope(parentID).evaluateComputedValueRuleForField(value, formValues, name), + ]; + } + }; +} diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index bf3cda04e..f589a669e 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -4,6 +4,7 @@ import { createSchemaWithRulesOnFieldA, createSchemaWithThreePropertiesWithRuleOnFieldA, multiRuleSchema, + schemaWithComputedAttributes, schemaWithNativeAndJSONLogicChecks, schemaWithNonRequiredField, schemaWithTwoRules, @@ -210,4 +211,16 @@ describe('cross-value validations', () => { expect(handleValidation({ field_a: 4, field_b: 2 }).formErrors).toEqual(undefined); }); }); + + describe('Derive values', () => { + it('field_b is field_a * 2', () => { + const { fields } = createHeadlessForm(schemaWithComputedAttributes, { + strictInputType: false, + initialValues: { field_a: 2 }, + }); + const fieldB = fields.find((i) => i.name === 'field_b'); + expect(fieldB.default).toEqual(4); + expect(fieldB.value).toEqual(4); + }); + }); }); diff --git a/src/tests/jsonLogicFixtures.js b/src/tests/jsonLogicFixtures.js index febd9b1d0..d34ddf563 100644 --- a/src/tests/jsonLogicFixtures.js +++ b/src/tests/jsonLogicFixtures.js @@ -133,6 +133,33 @@ export const schemaWithTwoRules = { }, }; +export const schemaWithComputedAttributes = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + // title: 'This is {{a_times_two}}!', // Will be added in next part. + const: 'a_times_two', + default: 'a_times_two', + // description: 'This field is 2 times bigger than field_a with value of {{a_times_two}}.', // Will be added in next part. + }, + }, + }, + required: ['field_a', 'field_b'], + 'x-jsf-logic': { + computedValues: { + a_times_two: { + rule: { + '*': [{ var: 'field_a' }, 2], + }, + }, + }, + }, +}; + export const schemaWithInlineRuleForComputedAttributeWithoutCopy = { properties: { field_a: { From be9e919bfca9f7381c1eab145cd5c16c7191eae3 Mon Sep 17 00:00:00 2001 From: brennj Date: Wed, 23 Aug 2023 14:14:39 +0200 Subject: [PATCH 05/13] chore: computed string attributes --- src/helpers.js | 4 +- src/jsonLogic.js | 80 +++++++++++++++++++++++++++++++++- src/tests/jsonLogic.test.js | 22 ++++++++++ src/tests/jsonLogicFixtures.js | 43 +++++++++++++++++- 4 files changed, 143 insertions(+), 6 deletions(-) diff --git a/src/helpers.js b/src/helpers.js index 1af9a3b96..2a3933a47 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -569,8 +569,8 @@ export function yupToFormErrors(yupError) { * @param {JsfConfig} config - jsf config * @returns {Function(values: Object): { YupError: YupObject, formErrors: Object }} Callback that returns Yup errors */ -export const handleValuesChange = (fields, jsonSchema, config) => (values) => { - updateFieldsProperties(fields, values, jsonSchema); +export const handleValuesChange = (fields, jsonSchema, config, validations) => (values) => { + updateFieldsProperties(fields, values, jsonSchema, validations); const lazySchema = lazy(() => buildCompleteYupSchema(fields, config)); let errors; diff --git a/src/jsonLogic.js b/src/jsonLogic.js index df9665475..08f47a6aa 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 advanced validation logic and returns a set of functionality to check the current status of said rules. * @param {Object} schema - JSON schema node @@ -107,8 +109,37 @@ export function yupSchemaWithCustomJSONLogic({ field, validations, config, id }) ); } +function replaceHandlebarsTemplates({ + value: toReplace, + validations, + formValues, + parentID, + name: fieldName, +}) { + if (typeof toReplace === 'string') { + return toReplace.replace(/\{\{([^{}]+)\}\}/g, (match, key) => { + return validations + .getScope(parentID) + .evaluateComputedValueRuleForField(key.trim(), formValues, fieldName); + }); + } else if (typeof toReplace === 'object') { + const { value, ...rules } = toReplace; + + const computedTemplateValue = Object.entries(rules).reduce((prev, [key, rule]) => { + const computedValue = validations.getScope(parentID).evaluateValidation(rule, formValues); + return prev.replaceAll(`{{${key}}}`, computedValue); + }, value); + + return computedTemplateValue.replace(/\{\{([^{}]+)\}\}/g, (match, key) => { + return validations + .getScope(parentID) + .evaluateComputedValueRuleForField(key.trim(), formValues, fieldName); + }); + } +} + export function calculateComputedAttributes(fieldParams, { parentID = 'root' } = {}) { - return ({ validations, formValues }) => { + return ({ validations, isRequired, config, formValues }) => { const { name, computedAttributes } = fieldParams; const attributes = Object.fromEntries( Object.entries(computedAttributes) @@ -116,23 +147,68 @@ export function calculateComputedAttributes(fieldParams, { parentID = 'root' } = .filter(([, value]) => value !== null) ); - return attributes; + return { + ...attributes, + schema: buildYupSchema( + { ...fieldParams, ...attributes, required: isRequired }, + config, + validations + ), + }; }; } function handleComputedAttribute(validations, formValues, parentID, name) { return ([key, value]) => { + if (key === 'description') + return [key, replaceHandlebarsTemplates({ value, validations, formValues, parentID, name })]; + + if (key === 'title') { + return [ + 'label', + replaceHandlebarsTemplates({ value, validations, formValues, parentID, name }), + ]; + } + if (key === 'const') return [ key, validations.getScope(parentID).evaluateComputedValueRuleForField(value, formValues, name), ]; + if (key === 'x-jsf-errorMessage') { + return [ + 'errorMessage', + handleNestedObjectForComputedValues(value, formValues, parentID, validations, name), + ]; + } + if (typeof value === 'string') { return [ key, validations.getScope(parentID).evaluateComputedValueRuleForField(value, formValues, name), ]; } + + if (key === 'x-jsf-presentation' && value.statement) { + return [ + 'statement', + handleNestedObjectForComputedValues( + value.statement, + formValues, + parentID, + validations, + name + ), + ]; + } }; } + +function handleNestedObjectForComputedValues(values, formValues, parentID, validations, name) { + return Object.fromEntries( + Object.entries(values).map(([key, value]) => { + return [key, replaceHandlebarsTemplates({ value, validations, formValues, parentID, name })]; + }) + ); +} diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index f589a669e..59c43bb9b 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -5,6 +5,7 @@ import { createSchemaWithThreePropertiesWithRuleOnFieldA, multiRuleSchema, schemaWithComputedAttributes, + schemaWithComputedAttributesAndErrorMessages, schemaWithNativeAndJSONLogicChecks, schemaWithNonRequiredField, schemaWithTwoRules, @@ -219,8 +220,29 @@ describe('cross-value 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); + expect(fieldB.label).toEqual('This is 4!'); + }); + + 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/jsonLogicFixtures.js b/src/tests/jsonLogicFixtures.js index d34ddf563..6c135b6f8 100644 --- a/src/tests/jsonLogicFixtures.js +++ b/src/tests/jsonLogicFixtures.js @@ -141,10 +141,10 @@ export const schemaWithComputedAttributes = { field_b: { type: 'number', 'x-jsf-logic-computedAttrs': { - // title: 'This is {{a_times_two}}!', // Will be added in next part. + 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}}.', // Will be added in next part. + description: 'This field is 2 times bigger than field_a with value of {{a_times_two}}.', }, }, }, @@ -203,3 +203,42 @@ export const schemaWithInlineRuleForComputedAttributeWithOnlyTheRule = { }, }, }; + +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], + }, + }, + }, + }, +}; From e2d90b7d3fb9debc221435d0ab4049b1397b98ab Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 24 Aug 2023 09:20:01 +0200 Subject: [PATCH 06/13] chore: error handling --- src/jsonLogic.js | 92 +++++++++++++++++++++++++++++- src/tests/jsonLogic.test.js | 83 +++++++++++++++++++++++++++ src/tests/jsonLogicFixtures.js | 101 +++++++++++++++++++++++++++++++++ 3 files changed, 275 insertions(+), 1 deletion(-) diff --git a/src/jsonLogic.js b/src/jsonLogic.js index 08f47a6aa..e4c1544e5 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -12,6 +12,7 @@ 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') @@ -21,6 +22,8 @@ export function createValidationChecker(schema) { } createScopes(property, key); }); + + validateInlineRules(jsonSchema, sampleEmptyObject); } createScopes(schema); @@ -44,12 +47,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(`Missing rule for validation with id of: "${id}".`); + } + + checkRuleIntegrity(validation.rule, id, sampleEmptyObject); + validationMap.set(id, validation); }); computedValues.forEach(([id, computedValue]) => { + if (!computedValue.rule) { + throw Error(`Missing rule for computedValue with id of: "${id}".`); + } + + checkRuleIntegrity(computedValue.rule, id, sampleEmptyObject); + computedValuesMap.set(id, computedValue); }); @@ -65,8 +81,11 @@ function createValidationsScope(schema) { const validation = validationMap.get(id); return evaluateValidation(validation.rule, values); }, - evaluateComputedValueRuleForField(id, values) { + evaluateComputedValueRuleForField(id, values, fieldName) { const validation = computedValuesMap.get(id); + if (validation === undefined) + throw Error(`"${id}" computedValue in field "${fieldName}" doesn't exist.`); + return evaluateValidation(validation.rule, values); }, evaluateComputedValueRuleInCondition(id, values) { @@ -125,6 +144,9 @@ function replaceHandlebarsTemplates({ } 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 = validations.getScope(parentID).evaluateValidation(rule, formValues); return prev.replaceAll(`{{${key}}}`, computedValue); @@ -212,3 +234,71 @@ function handleNestedObjectForComputedValues(values, formValues, parentID, valid }) ); } + +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; +} + +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) => + `"${item.var}" in inline rule in property "${fieldName}.x-jsf-logic-computedAttrs.${key}" does not exist as a JSON schema property.` + ); + }); + }); + }); +} + +function checkRuleIntegrity( + rule, + id, + data, + errorMessage = (item) => `"${item.var}" in rule "${id}" does not exist as a JSON schema property.` +) { + Object.values(rule ?? {}).map((subRule) => { + if (!Array.isArray(subRule) && subRule !== null && subRule !== undefined) return; + 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 removeIndicesFromPath(path) { + const intermediatePath = path.replace(/\.\d+\./g, '.'); + return intermediatePath.replace(/\.\d+$/, ''); +} diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index 59c43bb9b..fe89fc3a4 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -4,8 +4,15 @@ import { createSchemaWithRulesOnFieldA, createSchemaWithThreePropertiesWithRuleOnFieldA, multiRuleSchema, + schemaWithComputedAttributeThatDoesntExist, + schemaWithComputedAttributeThatDoesntExistDescription, + schemaWithComputedAttributeThatDoesntExistTitle, schemaWithComputedAttributes, schemaWithComputedAttributesAndErrorMessages, + schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar, + schemaWithMissingComputedValue, + schemaWithMissingRule, + schemaWithMissingValueInlineRule, schemaWithNativeAndJSONLogicChecks, schemaWithNonRequiredField, schemaWithTwoRules, @@ -82,6 +89,82 @@ describe('cross-value validations', () => { }); }); + describe('Incorrectly written schemas', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + console.error.mockRestore(); + }); + + it('Should throw when theres a missing rule', () => { + createHeadlessForm(schemaWithMissingRule, { strictInputType: false }); + expect(console.error).toHaveBeenCalledWith( + 'JSON Schema invalid!', + Error('Missing rule for validation with id of: "a_greater_than_ten".') + ); + }); + + it('Should throw when theres a missing computed value', () => { + createHeadlessForm(schemaWithMissingComputedValue, { strictInputType: false }); + expect(console.error).toHaveBeenCalledWith( + 'JSON Schema invalid!', + Error('Missing rule for computedValue with id of: "a_plus_ten".') + ); + }); + + it('Should throw when theres an inline computed ruleset with no value.', () => { + createHeadlessForm(schemaWithMissingValueInlineRule, { strictInputType: false }); + expect(console.error).toHaveBeenCalledWith( + 'JSON Schema invalid!', + Error('Cannot define multiple rules without a template string with key `value`.') + ); + }); + + it('On x-jsf-logic-computedAttrs, error if theres a value that does not exist.', () => { + createHeadlessForm(schemaWithComputedAttributeThatDoesntExist, { + strictInputType: false, + }); + expect(console.error).toHaveBeenCalledWith( + 'JSON Schema invalid!', + Error(`"iDontExist" computedValue in field "field_a" doesn't exist.`) + ); + }); + + it('On x-jsf-logic-computedAttrs, error if theres a value that does not exist on a title.', () => { + createHeadlessForm(schemaWithComputedAttributeThatDoesntExistTitle, { + strictInputType: false, + }); + expect(console.error).toHaveBeenCalledWith( + 'JSON Schema invalid!', + Error(`"iDontExist" computedValue in field "field_a" doesn't exist.`) + ); + }); + + it('On x-jsf-logic-computedAttrs, error if theres a value that does not exist on a description.', () => { + createHeadlessForm(schemaWithComputedAttributeThatDoesntExistDescription, { + strictInputType: false, + }); + expect(console.error).toHaveBeenCalledWith( + 'JSON Schema invalid!', + Error(`"iDontExist" computedValue in field "field_a" doesn't exist.`) + ); + }); + + it('On an inline rule for a computedAttribute, error if theres a value referenced that does not exist', () => { + createHeadlessForm(schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar, { + strictInputType: false, + }); + expect(console.error).toHaveBeenCalledWith( + 'JSON Schema invalid!', + Error( + '"IdontExist" in inline rule in property "field_a.x-jsf-logic-computedAttrs.title" does not exist as a JSON schema property.' + ) + ); + }); + }); + describe('Arithmetic: +, -, *, /', () => { it('multiple: field_a > field_b * 2', () => { const schema = createSchemaWithRulesOnFieldA({ diff --git a/src/tests/jsonLogicFixtures.js b/src/tests/jsonLogicFixtures.js index 6c135b6f8..dd31a4421 100644 --- a/src/tests/jsonLogicFixtures.js +++ b/src/tests/jsonLogicFixtures.js @@ -74,6 +74,40 @@ 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', + }, + }, + }, + required: [], +}; + +export const schemaWithMissingComputedValue = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: '{{a_plus_ten}}', + }, + }, + }, + 'x-jsf-logic': { + computedValues: { + a_plus_ten: {}, + }, + }, + required: [], +}; + export const multiRuleSchema = { properties: { field_a: { @@ -103,6 +137,25 @@ export const multiRuleSchema = { }, }; +export const schemaWithMissingValueInlineRule = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: { + ruleOne: { + '+': [1, 2], + }, + ruleTwo: { + '+': [3, 4], + }, + }, + }, + }, + }, + required: [], +}; + export const schemaWithTwoRules = { properties: { field_a: { @@ -178,6 +231,54 @@ export const schemaWithInlineRuleForComputedAttributeWithoutCopy = { }, }; +export const schemaWithComputedAttributeThatDoesntExist = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + default: 'iDontExist', + }, + }, + }, +}; + +export const schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: { + rule: { + '+': [{ var: 'IdontExist' }], + }, + }, + }, + }, + }, +}; + +export const schemaWithComputedAttributeThatDoesntExistTitle = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: `this doesn't exist {{iDontExist}}`, + }, + }, + }, +}; + +export const schemaWithComputedAttributeThatDoesntExistDescription = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + description: `this doesn't exist {{iDontExist}}`, + }, + }, + }, +}; + export const schemaWithInlineRuleForComputedAttributeWithOnlyTheRule = { properties: { field_a: { From 3eb38a299b13b8c66c4c9623e16ead1b58025371 Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 24 Aug 2023 10:06:08 +0200 Subject: [PATCH 07/13] feat: add more error handling --- src/jsonLogic.js | 10 +++ src/tests/jsonLogic.test.js | 49 +++++++++++++++ src/tests/jsonLogicFixtures.js | 110 +++++++++++++++++++++++++++++++++ 3 files changed, 169 insertions(+) diff --git a/src/jsonLogic.js b/src/jsonLogic.js index e4c1544e5..25eaa8c8a 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -79,6 +79,9 @@ function createValidationsScope(schema) { evaluateValidation, evaluateValidationRuleInCondition(id, values) { const validation = validationMap.get(id); + if (validation === undefined) + throw Error(`"${id}" validation in if condition doesn't exist.`); + return evaluateValidation(validation.rule, values); }, evaluateComputedValueRuleForField(id, values, fieldName) { @@ -90,6 +93,9 @@ function createValidationsScope(schema) { }, evaluateComputedValueRuleInCondition(id, values) { const validation = computedValuesMap.get(id); + if (validation === undefined) + throw Error(`"${id}" computedValue in if condition doesn't exist.`); + return evaluateValidation(validation.rule, values); }, }; @@ -117,6 +123,10 @@ export function yupSchemaWithCustomJSONLogic({ field, validations, config, id }) const { parentID = 'root' } = config; const validation = validations.getScope(parentID).validationMap.get(id); + if (validation === undefined) { + throw Error(`Validation "${id}" required for "${field.name}" doesn't exist.`); + } + return (yupSchema) => yupSchema.test( `${field.name}-validation-${id}`, diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index fe89fc3a4..569534604 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -9,13 +9,18 @@ import { schemaWithComputedAttributeThatDoesntExistTitle, schemaWithComputedAttributes, schemaWithComputedAttributesAndErrorMessages, + schemaWithDeepVarThatDoesNotExist, + schemaWithDeepVarThatDoesNotExistOnFieldset, schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar, schemaWithMissingComputedValue, schemaWithMissingRule, schemaWithMissingValueInlineRule, schemaWithNativeAndJSONLogicChecks, schemaWithNonRequiredField, + schemaWithPropertyThatDoesNotExistInThatLevelButDoesInFieldset, schemaWithTwoRules, + schemaWithValidationThatDoesNotExistOnProperty, + schemaWithVarThatDoesNotExist, } from './jsonLogicFixtures'; describe('cross-value validations', () => { @@ -98,6 +103,50 @@ describe('cross-value validations', () => { console.error.mockRestore(); }); + it('Should throw when a var does not exist in a rule.', () => { + createHeadlessForm(schemaWithVarThatDoesNotExist, { strictInputType: false }); + expect(console.error).toHaveBeenCalledWith( + 'JSON Schema invalid!', + Error('"field_b" in rule "a_greater_than_ten" does not exist as a JSON schema property.') + ); + }); + + it('Should throw when a var does not exist in a deeply nested rule', () => { + createHeadlessForm(schemaWithDeepVarThatDoesNotExist, { strictInputType: false }); + expect(console.error).toHaveBeenCalledWith( + 'JSON Schema invalid!', + Error('"field_b" in rule "a_greater_than_ten" does not exist as a JSON schema property.') + ); + }); + + it('Should throw when a var does not exist in a fieldset.', () => { + createHeadlessForm(schemaWithDeepVarThatDoesNotExistOnFieldset, { strictInputType: false }); + expect(console.error).toHaveBeenCalledWith( + 'JSON Schema invalid!', + Error('"field_a" in rule "a_greater_than_ten" does not exist as a JSON schema property.') + ); + }); + + it('On a property, it should throw an error for a requiredValidation that does not exist', () => { + createHeadlessForm(schemaWithValidationThatDoesNotExistOnProperty, { + strictInputType: false, + }); + expect(console.error).toHaveBeenCalledWith( + 'JSON Schema invalid!', + Error(`Validation "iDontExist" required for "field_a" doesn't exist.`) + ); + }); + + it('A top level logic keyword will not be able to reference fieldset properties', () => { + createHeadlessForm(schemaWithPropertyThatDoesNotExistInThatLevelButDoesInFieldset, { + strictInputType: false, + }); + expect(console.error).toHaveBeenCalledWith( + 'JSON Schema invalid!', + Error('"child" in rule "validation_parent" does not exist as a JSON schema property.') + ); + }); + it('Should throw when theres a missing rule', () => { createHeadlessForm(schemaWithMissingRule, { strictInputType: false }); expect(console.error).toHaveBeenCalledWith( diff --git a/src/tests/jsonLogicFixtures.js b/src/tests/jsonLogicFixtures.js index dd31a4421..30148b346 100644 --- a/src/tests/jsonLogicFixtures.js +++ b/src/tests/jsonLogicFixtures.js @@ -108,6 +108,77 @@ export const schemaWithMissingComputedValue = { required: [], }; +export const schemaWithVarThatDoesNotExist = { + properties: { + field_a: { + type: 'number', + }, + }, + 'x-jsf-logic': { + validations: { + a_greater_than_ten: { + errorMessage: 'Must be greater than 10', + rule: { + '>': [{ var: 'field_b' }, 10], + }, + }, + }, + }, + required: [], +}; + +export const schemaWithDeepVarThatDoesNotExist = { + properties: { + field_a: { + type: 'number', + }, + }, + 'x-jsf-logic': { + validations: { + a_greater_than_ten: { + errorMessage: 'Must be greater than 10', + 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: { + a_greater_than_ten: { + 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 multiRuleSchema = { properties: { field_a: { @@ -343,3 +414,42 @@ export const schemaWithComputedAttributesAndErrorMessages = { }, }, }; + +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'], + }, + }, + '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'], +}; From 43e961fb3293b743836a43e0df7f8c702cae3ee9 Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 24 Aug 2023 10:51:27 +0200 Subject: [PATCH 08/13] chore: add inline handler --- src/jsonLogic.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/jsonLogic.js b/src/jsonLogic.js index 25eaa8c8a..d7bc81d2b 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -234,6 +234,10 @@ function handleComputedAttribute(validations, formValues, parentID, name) { ), ]; } + + if (typeof value === 'object' && value.rule) { + return [key, validations.getScope(parentID).evaluateValidation(value.rule, formValues)]; + } }; } From f050bb841d7e17662f6cc6209cb97cce5ec59062 Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 24 Aug 2023 13:22:26 +0200 Subject: [PATCH 09/13] chore: add tests --- src/tests/jsonLogic.test.js | 69 ++++++++++++++++ src/tests/jsonLogicFixtures.js | 147 +++++++++++++++++++++++++++++---- 2 files changed, 198 insertions(+), 18 deletions(-) diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index 569534604..f241080ca 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -4,6 +4,8 @@ import { createSchemaWithRulesOnFieldA, createSchemaWithThreePropertiesWithRuleOnFieldA, multiRuleSchema, + schemaSelfContainedValueForMaximumMinimumValues, + schemaSelfContainedValueForTitleWithNoTemplate, schemaWithComputedAttributeThatDoesntExist, schemaWithComputedAttributeThatDoesntExistDescription, schemaWithComputedAttributeThatDoesntExistTitle, @@ -11,7 +13,10 @@ import { schemaWithComputedAttributesAndErrorMessages, schemaWithDeepVarThatDoesNotExist, schemaWithDeepVarThatDoesNotExistOnFieldset, + schemaWithInlineMultipleRulesForComputedAttributes, + schemaWithInlineRuleForComputedAttributeWithCopy, schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar, + schemaWithJSFLogicAndInlineRule, schemaWithMissingComputedValue, schemaWithMissingRule, schemaWithMissingValueInlineRule, @@ -376,5 +381,69 @@ describe('cross-value validations', () => { expect(fieldB.maximum).toEqual(8); expect(fieldB.statement).toEqual({ description: 'Must be bigger than 4 and smaller than 8' }); }); + + it('Use a self contained 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 a self contained rule in a schema for a title but it just uses the value', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaSelfContainedValueForTitleWithNoTemplate, + { + strictInputType: false, + } + ); + const [, fieldB] = fields; + expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toEqual(undefined); + expect(fieldB.label).toEqual('20'); + }); + + it('Use a self contained rule for a minimum, maximum value', () => { + const { handleValidation } = createHeadlessForm( + schemaSelfContainedValueForMaximumMinimumValues, + { + strictInputType: false, + } + ); + expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toEqual(undefined); + expect(handleValidation({ field_a: 50, field_b: 20 }).formErrors).toEqual({ + field_b: 'Must be greater or equal to 40', + }); + expect(handleValidation({ field_a: 50, field_b: 70 }).formErrors).toEqual({ + field_b: 'Must be smaller or equal to 60', + }); + expect(handleValidation({ field_a: 50, field_b: 50 }).formErrors).toEqual(undefined); + }); + + 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'); + }); }); }); diff --git a/src/tests/jsonLogicFixtures.js b/src/tests/jsonLogicFixtures.js index 30148b346..3861f0a5a 100644 --- a/src/tests/jsonLogicFixtures.js +++ b/src/tests/jsonLogicFixtures.js @@ -284,24 +284,6 @@ export const schemaWithComputedAttributes = { }, }; -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: { @@ -453,3 +435,132 @@ export const schemaWithPropertyThatDoesNotExistInThatLevelButDoesInFieldset = { }, required: ['field_a'], }; + +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 schemaWithInlineRuleForComputedAttributeWithoutCopy = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: { + 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 schemaSelfContainedValueForTitleWithNoTemplate = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: { + value: '{{rule}}', + rule: { + '+': [{ var: 'field_a' }, 10], + }, + }, + }, + }, + }, +}; + +export const schemaSelfContainedValueForMaximumMinimumValues = { + 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], + }, + }, + }, + }, +}; From 52618b275849bef85c527949d69a457621fcbc2e Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 24 Aug 2023 14:39:55 +0200 Subject: [PATCH 10/13] chore: conditionals --- src/calculateConditionalProperties.js | 39 +- src/checkIfConditionMatches.js | 34 +- src/createHeadlessForm.js | 27 +- src/helpers.js | 28 +- src/jsonLogic.js | 66 +++ src/tests/jsonLogic.test.js | 142 +++++++ src/tests/jsonLogicFixtures.js | 553 ++++++++++++++++++++++---- 7 files changed, 787 insertions(+), 102 deletions(-) diff --git a/src/calculateConditionalProperties.js b/src/calculateConditionalProperties.js index 385ca3484..0a4825b89 100644 --- a/src/calculateConditionalProperties.js +++ b/src/calculateConditionalProperties.js @@ -4,6 +4,7 @@ import omit from 'lodash/omit'; import { extractParametersFromNode } from './helpers'; import { supportedTypes } from './internals/fields'; import { getFieldDescription, pickXKey } from './internals/helpers'; +import { calculateComputedAttributes } from './jsonLogic'; import { buildYupSchema } from './yupSchema'; /** * @typedef {import('./createHeadlessForm').FieldParameters} FieldParameters @@ -69,14 +70,14 @@ function rebuildFieldset(fields, property) { * @param {FieldParameters} fieldParams - field parameters * @returns {Function} */ -export function calculateConditionalProperties(fieldParams, customProperties) { +export function calculateConditionalProperties(fieldParams, customProperties, validations, config) { /** * Runs dynamic property calculation on a field based on a conditional that has been calculated * @param {Boolean} isRequired - if the field is required * @param {Object} conditionBranch - condition branch being applied * @returns {Object} updated field parameters */ - return (isRequired, conditionBranch) => { + return (isRequired, conditionBranch, __, _, formValues) => { // Check if the current field is conditionally declared in the schema const conditionalProperty = conditionBranch?.properties?.[fieldParams.name]; @@ -98,17 +99,37 @@ export function calculateConditionalProperties(fieldParams, customProperties) { newFieldParams.fields = fieldSetFields; } + const { computedAttributes, ...restNewFieldParams } = newFieldParams; + const calculatedComputedAttributes = computedAttributes + ? calculateComputedAttributes(newFieldParams, config)({ validations, formValues }) + : {}; + + const requiredValidations = [ + ...(fieldParams.requiredValidations ?? []), + ...(restNewFieldParams.requiredValidations ?? []), + ]; + const base = { isVisible: true, required: isRequired, ...(presentation?.inputType && { type: presentation.inputType }), - schema: buildYupSchema({ - ...fieldParams, - ...newFieldParams, - // If there are inner fields (case of fieldset) they need to be updated based on the condition - fields: fieldSetFields, - required: isRequired, - }), + ...calculatedComputedAttributes, + ...(calculatedComputedAttributes.value + ? { value: calculatedComputedAttributes.value } + : { value: undefined }), + schema: buildYupSchema( + { + ...fieldParams, + ...restNewFieldParams, + ...calculatedComputedAttributes, + requiredValidations, + // If there are inner fields (case of fieldset) they need to be updated based on the condition + fields: fieldSetFields, + required: isRequired, + }, + config, + validations + ), }; return omit(merge(base, presentation, newFieldParams), ['inputType']); diff --git a/src/checkIfConditionMatches.js b/src/checkIfConditionMatches.js index 6108b60a8..235e37d94 100644 --- a/src/checkIfConditionMatches.js +++ b/src/checkIfConditionMatches.js @@ -7,8 +7,8 @@ import { hasProperty } from './utils'; * @param {Object} formValues - form state * @returns {Boolean} */ -export function checkIfConditionMatches(node, formValues, formFields) { - return Object.keys(node.if.properties).every((name) => { +export function checkIfConditionMatches(node, formValues, formFields, validations) { + return Object.keys(node.if.properties ?? {}).every((name) => { const currentProperty = node.if.properties[name]; const value = formValues[name]; const hasEmptyValue = @@ -50,7 +50,8 @@ export function checkIfConditionMatches(node, formValues, formFields) { return checkIfConditionMatches( { if: currentProperty }, formValues[name], - getField(name, formFields).fields + getField(name, formFields).fields, + validations ); } @@ -68,3 +69,30 @@ export function checkIfConditionMatches(node, formValues, formFields) { ); }); } + +export function checkIfMatchesValidationsAndComputedValues( + node, + formValues, + validations, + parentID +) { + const validationsMatch = Object.entries(node.if.validations ?? {}).every(([name, property]) => { + const currentValue = validations + .getScope(parentID) + .evaluateValidationRuleInCondition(name, formValues); + if (Object.hasOwn(property, 'const') && currentValue === property.const) return true; + return false; + }); + + const computedValuesMatch = Object.entries(node.if.computedValues ?? {}).every( + ([name, property]) => { + const currentValue = validations + .getScope(parentID) + .evaluateComputedValueRuleInCondition(name, formValues); + if (Object.hasOwn(property, 'const') && currentValue === property.const) return true; + return false; + } + ); + + return computedValuesMatch && validationsMatch; +} diff --git a/src/createHeadlessForm.js b/src/createHeadlessForm.js index 68f58412d..f979c3def 100644 --- a/src/createHeadlessForm.js +++ b/src/createHeadlessForm.js @@ -100,7 +100,7 @@ function removeInvalidAttributes(fields) { * * @returns {FieldParameters} */ -function buildFieldParameters(name, fieldProperties, required = [], config = {}) { +function buildFieldParameters(name, fieldProperties, required = [], config = {}, validations) { const { position } = pickXKey(fieldProperties, 'presentation') ?? {}; let fields; @@ -108,9 +108,14 @@ function buildFieldParameters(name, fieldProperties, required = [], config = {}) if (inputType === supportedTypes.FIELDSET) { // eslint-disable-next-line no-use-before-define - fields = getFieldsFromJSONSchema(fieldProperties, { - customProperties: get(config, `customProperties.${name}`, {}), - }); + fields = getFieldsFromJSONSchema( + fieldProperties, + { + customProperties: get(config, `customProperties.${name}`, {}), + parentID: name, + }, + validations + ); } const result = { @@ -137,7 +142,8 @@ function buildFieldParameters(name, fieldProperties, required = [], config = {}) */ function convertJSONSchemaPropertiesToFieldParameters( { properties, required, 'x-jsf-order': order }, - config = {} + config = {}, + validations ) { const sortFields = (a, b) => sortByOrderOrPosition(a, b, order); @@ -145,7 +151,7 @@ function convertJSONSchemaPropertiesToFieldParameters( // their position and then remove the position property (since it's no longer needed) return Object.entries(properties) .filter(([, value]) => typeof value === 'object') - .map(([key, value]) => buildFieldParameters(key, value, required, config)) + .map(([key, value]) => buildFieldParameters(key, value, required, config, validations)) .sort(sortFields) .map(({ position, ...fieldParams }) => fieldParams); } @@ -233,7 +239,8 @@ function buildField(fieldParams, config, scopedJsonSchema, validations) { const yupSchema = buildYupSchema(fieldParams, config, validations); const calculateConditionalFieldsClosure = - fieldParams.isDynamic && calculateConditionalProperties(fieldParams, customProperties); + fieldParams.isDynamic && + calculateConditionalProperties(fieldParams, customProperties, validations, config); const calculateCustomValidationPropertiesClosure = calculateCustomValidationProperties( fieldParams, @@ -283,7 +290,11 @@ function getFieldsFromJSONSchema(scopedJsonSchema, config, validations) { return []; } - const fieldParamsList = convertJSONSchemaPropertiesToFieldParameters(scopedJsonSchema, config); + const fieldParamsList = convertJSONSchemaPropertiesToFieldParameters( + scopedJsonSchema, + config, + validations + ); applyFieldsDependencies(fieldParamsList, scopedJsonSchema); diff --git a/src/helpers.js b/src/helpers.js index 2a3933a47..15f336647 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -8,6 +8,7 @@ import { lazy } from 'yup'; import { checkIfConditionMatches } from './checkIfConditionMatches'; import { supportedTypes, getInputType } from './internals/fields'; import { pickXKey } from './internals/helpers'; +import { processJSONLogicNode } from './jsonLogic'; import { containsHTML, hasProperty, wrapWithSpan } from './utils'; import { buildCompleteYupSchema, buildYupSchema } from './yupSchema'; @@ -230,7 +231,13 @@ function updateField(field, requiredFields, node, formValues, validations, confi // If field has a calculateConditionalProperties closure, run it and update the field properties if (field.calculateConditionalProperties) { - const newFieldValues = field.calculateConditionalProperties(fieldIsRequired, node); + const newFieldValues = field.calculateConditionalProperties( + fieldIsRequired, + node, + validations, + config, + formValues + ); updateValues(newFieldValues); } @@ -284,7 +291,7 @@ export function processNode({ }); if (node.if) { - const matchesCondition = checkIfConditionMatches(node, formValues, formFields); + const matchesCondition = checkIfConditionMatches(node, formValues, formFields, validations); // BUG HERE (unreleated) - what if it matches but doesn't has a then, // it should do nothing, but instead it jumps to node.else when it shouldn't. if (matchesCondition && node.then) { @@ -358,6 +365,18 @@ export function processNode({ }); } + if (node['x-jsf-logic']) { + const { required: requiredFromLogic } = processJSONLogicNode({ + node: node['x-jsf-logic'], + formValues, + formFields, + accRequired: requiredFields, + parentID, + validations, + }); + requiredFromLogic.forEach((field) => requiredFields.add(field)); + } + return { required: requiredFields, }; @@ -475,6 +494,9 @@ export function extractParametersFromNode(schemaNode) { return omitBy( { + const: node.const, + // This is a "forced value" when both const and default are present. + ...(node.const && node.default ? { value: node.const } : {}), label: node.title, readOnly: node.readOnly, ...(node.deprecated && { @@ -572,7 +594,7 @@ export function yupToFormErrors(yupError) { export const handleValuesChange = (fields, jsonSchema, config, validations) => (values) => { updateFieldsProperties(fields, values, jsonSchema, validations); - const lazySchema = lazy(() => buildCompleteYupSchema(fields, config)); + const lazySchema = lazy(() => buildCompleteYupSchema(fields, config, validations)); let errors; try { diff --git a/src/jsonLogic.js b/src/jsonLogic.js index d7bc81d2b..49d7470cc 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -1,5 +1,10 @@ import jsonLogic from 'json-logic-js'; +import { + checkIfConditionMatches, + checkIfMatchesValidationsAndComputedValues, +} from './checkIfConditionMatches'; +import { processNode } from './helpers'; import { buildYupSchema } from './yupSchema'; /** @@ -249,6 +254,67 @@ function handleNestedObjectForComputedValues(values, formValues, parentID, valid ); } +export function processJSONLogicNode({ + node, + formFields, + formValues, + accRequired, + parentID, + validations, +}) { + const requiredFields = new Set(accRequired); + + if (node.allOf) { + node.allOf + .map((allOfNode) => + processJSONLogicNode({ node: allOfNode, formValues, formFields, validations, parentID }) + ) + .forEach(({ required: allOfItemRequired }) => { + allOfItemRequired.forEach(requiredFields.add, requiredFields); + }); + } + + if (node.if) { + const matchesPropertyCondition = checkIfConditionMatches( + node, + formValues, + formFields, + validations + ); + const matchesValidationsAndComputedValues = checkIfMatchesValidationsAndComputedValues( + node, + formValues, + validations, + parentID + ); + + const isConditionMatch = matchesPropertyCondition && matchesValidationsAndComputedValues; + + if (isConditionMatch && node.then) { + const { required: branchRequired } = processNode({ + node: node.then, + formValues, + formFields, + accRequired, + validations, + }); + branchRequired.forEach((field) => requiredFields.add(field)); + } + if (!isConditionMatch && node.else) { + const { required: branchRequired } = processNode({ + node: node.else, + formValues, + formFields, + accRequired: requiredFields, + validations, + }); + branchRequired.forEach((field) => requiredFields.add(field)); + } + } + + return { required: requiredFields }; +} + function buildSampleEmptyObject(schema = {}) { const sample = {}; if (typeof schema !== 'object' || !schema.properties) { diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index f241080ca..a57ca4dd1 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -6,13 +6,18 @@ import { multiRuleSchema, schemaSelfContainedValueForMaximumMinimumValues, schemaSelfContainedValueForTitleWithNoTemplate, + schemaWhereValidationAndComputedValueIsAppliedOnNormalThenStatement, + schemaWithChecksAndThenValidationsOnThen, schemaWithComputedAttributeThatDoesntExist, schemaWithComputedAttributeThatDoesntExistDescription, schemaWithComputedAttributeThatDoesntExistTitle, schemaWithComputedAttributes, schemaWithComputedAttributesAndErrorMessages, + schemaWithComputedValueChecksInIf, schemaWithDeepVarThatDoesNotExist, schemaWithDeepVarThatDoesNotExistOnFieldset, + schemaWithGreaterThanChecksForThreeFields, + schemaWithIfStatementWithComputedValuesAndValidationChecks, schemaWithInlineMultipleRulesForComputedAttributes, schemaWithInlineRuleForComputedAttributeWithCopy, schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar, @@ -20,10 +25,13 @@ import { schemaWithMissingComputedValue, schemaWithMissingRule, schemaWithMissingValueInlineRule, + schemaWithMultipleComputedValueChecks, schemaWithNativeAndJSONLogicChecks, schemaWithNonRequiredField, + schemaWithPropertiesCheckAndValidationsInAIf, schemaWithPropertyThatDoesNotExistInThatLevelButDoesInFieldset, schemaWithTwoRules, + schemaWithTwoValidationsWhereOneOfThemIsAppliedConditionally, schemaWithValidationThatDoesNotExistOnProperty, schemaWithVarThatDoesNotExist, } from './jsonLogicFixtures'; @@ -322,6 +330,140 @@ describe('cross-value validations', () => { }); }); + describe('Conditionals', () => { + it('when field_a > field_b, show field_c', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaWithGreaterThanChecksForThreeFields, + { strictInputType: false } + ); + expect(fields.find((i) => i.name === 'field_c').isVisible).toEqual(false); + + expect(handleValidation({ field_a: 1, field_b: 3 }).formErrors).toEqual(undefined); + expect(handleValidation({ field_a: 1, field_b: null }).formErrors).toEqual({ + field_b: 'Required field', + }); + expect(handleValidation({ field_a: 10, field_b: 3 }).formErrors).toEqual({ + field_c: 'Required field', + }); + 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 { handleValidation } = createHeadlessForm( + schemaWithPropertiesCheckAndValidationsInAIf, + { strictInputType: false } + ); + expect(handleValidation({ field_a: 1, field_b: 3 }).formErrors).toEqual(undefined); + expect(handleValidation({ field_a: 10, field_b: 3 }).formErrors).toEqual({ + field_c: 'Required field', + }); + 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('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 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(); + }); + }); + describe('Multiple validations', () => { it('2 rules where A must be bigger than B and not an even number in another rule', () => { const { handleValidation } = createHeadlessForm(multiRuleSchema, { strictInputType: false }); diff --git a/src/tests/jsonLogicFixtures.js b/src/tests/jsonLogicFixtures.js index 3861f0a5a..b501b67e3 100644 --- a/src/tests/jsonLogicFixtures.js +++ b/src/tests/jsonLogicFixtures.js @@ -108,6 +108,25 @@ export const schemaWithMissingComputedValue = { required: [], }; +export const schemaWithMissingValueInlineRule = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: { + ruleOne: { + '+': [1, 2], + }, + ruleTwo: { + '+': [3, 4], + }, + }, + }, + }, + }, + required: [], +}; + export const schemaWithVarThatDoesNotExist = { properties: { field_a: { @@ -179,186 +198,535 @@ export const schemaWithValidationThatDoesNotExistOnProperty = { }, }; -export const multiRuleSchema = { +export const schemaWithComputedAttributeThatDoesntExist = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + default: 'iDontExist', + }, + }, + }, +}; + +export const schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: { + rule: { + '+': [{ var: 'IdontExist' }], + }, + }, + }, + }, + }, +}; + +export const schemaWithComputedAttributeThatDoesntExistTitle = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: `this doesn't exist {{iDontExist}}`, + }, + }, + }, +}; + +export const schemaWithComputedAttributeThatDoesntExistDescription = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + description: `this doesn't exist {{iDontExist}}`, + }, + }, + }, +}; + +export const schemaWithGreaterThanChecksForThreeFields = { properties: { field_a: { type: 'number', - 'x-jsf-logic-validations': ['a_bigger_than_b', 'is_even_number'], }, field_b: { type: 'number', }, + field_c: { + type: 'number', + }, }, required: ['field_a', 'field_b'], 'x-jsf-logic': { validations: { - a_bigger_than_b: { - errorMessage: 'A must be bigger than B', + require_c: { rule: { - '>': [{ var: 'field_a' }, { var: 'field_b' }], + and: [ + { '!==': [{ var: 'field_b' }, null] }, + { '!==': [{ var: 'field_a' }, null] }, + { '>': [{ var: 'field_a' }, { var: 'field_b' }] }, + ], }, }, - is_even_number: { - errorMessage: 'A must be even', - rule: { - '===': [{ '%': [{ var: 'field_a' }, 2] }, 0], + }, + allOf: [ + { + if: { + validations: { + require_c: { + const: true, + }, + }, + }, + then: { + required: ['field_c'], + }, + else: { + properties: { + field_c: false, + }, }, }, - }, + ], }, }; -export const schemaWithMissingValueInlineRule = { +export const schemaWithPropertiesCheckAndValidationsInAIf = { properties: { field_a: { type: 'number', - 'x-jsf-logic-computedAttrs': { - title: { - ruleOne: { - '+': [1, 2], + }, + field_b: { + type: 'number', + }, + field_c: { + type: 'number', + }, + }, + required: ['field_a', 'field_b'], + 'x-jsf-logic': { + validations: { + require_c: { + rule: { + and: [ + { '!==': [{ var: 'field_b' }, null] }, + { '!==': [{ var: 'field_a' }, null] }, + { '>': [{ var: 'field_a' }, { var: 'field_b' }] }, + ], + }, + }, + }, + allOf: [ + { + if: { + validations: { + require_c: { + const: true, + }, }, - ruleTwo: { - '+': [3, 4], + properties: { + field_a: { + const: 10, + }, + }, + }, + then: { + required: ['field_c'], + }, + else: { + properties: { + field_c: false, }, }, }, - }, + ], }, - required: [], }; -export const schemaWithTwoRules = { +export const schemaWithChecksAndThenValidationsOnThen = { properties: { field_a: { type: 'number', - 'x-jsf-logic-validations': ['a_bigger_than_b'], }, field_b: { type: 'number', - 'x-jsf-logic-validations': ['is_even_number'], + }, + field_c: { + type: 'number', }, }, required: ['field_a', 'field_b'], 'x-jsf-logic': { validations: { - a_bigger_than_b: { - errorMessage: 'A must be bigger than B', + c_must_be_large: { + errorMessage: 'Needs more numbers', rule: { - '>': [{ var: 'field_a' }, { var: 'field_b' }], + '>': [{ var: 'field_c' }, 200], }, }, - is_even_number: { - errorMessage: 'B must be even', + require_c: { rule: { - '===': [{ '%': [{ var: 'field_b' }, 2] }, 0], + and: [ + { '!==': [{ var: 'field_b' }, null] }, + { '!==': [{ var: 'field_a' }, null] }, + { '>': [{ 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 schemaWithComputedAttributes = { +export const schemaWithComputedValueChecksInIf = { 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}}.', + }, + field_c: { + type: 'number', + }, + }, + required: ['field_a', 'field_b'], + 'x-jsf-logic': { + computedValues: { + require_c: { + rule: { + and: [ + { '!==': [{ var: 'field_b' }, null] }, + { '!==': [{ var: 'field_a' }, null] }, + { '>': [{ 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 schemaWithComputedAttributeThatDoesntExist = { +export const schemaWithIfStatementWithComputedValuesAndValidationChecks = { properties: { field_a: { type: 'number', - 'x-jsf-logic-computedAttrs': { - default: 'iDontExist', + }, + 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 schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar = { +export const schemaWhereValidationAndComputedValueIsAppliedOnNormalThenStatement = { properties: { field_a: { type: 'number', - 'x-jsf-logic-computedAttrs': { - title: { - rule: { - '+': [{ var: 'IdontExist' }], - }, + }, + 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 schemaWithComputedAttributeThatDoesntExistTitle = { +export const schemaWithTwoValidationsWhereOneOfThemIsAppliedConditionally = { + required: ['field_a', 'field_b'], properties: { field_a: { type: 'number', - 'x-jsf-logic-computedAttrs': { - title: `this doesn't exist {{iDontExist}}`, + }, + 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 schemaWithComputedAttributeThatDoesntExistDescription = { +export const multiRuleSchema = { properties: { field_a: { type: 'number', - 'x-jsf-logic-computedAttrs': { - description: `this doesn't exist {{iDontExist}}`, + '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 schemaWithInlineRuleForComputedAttributeWithOnlyTheRule = { +export const schemaWithTwoRules = { properties: { field_a: { type: 'number', + 'x-jsf-logic-validations': ['a_bigger_than_b'], }, field_b: { type: 'number', - 'x-jsf-logic-computedAttrs': { - minimum: { - rule: { - '+': [{ var: 'field_a' }, 10], - }, + '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' }], }, - 'x-jsf-errorMessage': { - minimum: { - value: 'This should be greater than {{rule}}.', - rule: { - '+': [{ var: 'field_a' }, 10], - }, - }, + }, + is_even_number: { + errorMessage: 'B must be even', + rule: { + '===': [{ '%': [{ var: 'field_b' }, 2] }, 0], }, }, }, }, }; -export const schemaWithComputedAttributesAndErrorMessages = { +export const schemaWithComputedAttributes = { properties: { field_a: { type: 'number', @@ -366,17 +734,10 @@ export const schemaWithComputedAttributesAndErrorMessages = { 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}}', - }, - }, + 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}}.', }, }, }, @@ -388,11 +749,6 @@ export const schemaWithComputedAttributesAndErrorMessages = { '*': [{ var: 'field_a' }, 2], }, }, - a_times_four: { - rule: { - '*': [{ var: 'field_a' }, 4], - }, - }, }, }, }; @@ -564,3 +920,42 @@ export const schemaWithJSFLogicAndInlineRule = { }, }, }; + +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], + }, + }, + }, + }, +}; From 93fb420c329f7fc239609453c017f41d6282fc6c Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 24 Aug 2023 14:57:12 +0200 Subject: [PATCH 11/13] chore: remove half of the tests, too big a PR --- src/tests/jsonLogic.test.js | 70 ----------- src/tests/jsonLogicFixtures.js | 216 --------------------------------- 2 files changed, 286 deletions(-) diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index a57ca4dd1..e54bba56c 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -6,7 +6,6 @@ import { multiRuleSchema, schemaSelfContainedValueForMaximumMinimumValues, schemaSelfContainedValueForTitleWithNoTemplate, - schemaWhereValidationAndComputedValueIsAppliedOnNormalThenStatement, schemaWithChecksAndThenValidationsOnThen, schemaWithComputedAttributeThatDoesntExist, schemaWithComputedAttributeThatDoesntExistDescription, @@ -17,7 +16,6 @@ import { schemaWithDeepVarThatDoesNotExist, schemaWithDeepVarThatDoesNotExistOnFieldset, schemaWithGreaterThanChecksForThreeFields, - schemaWithIfStatementWithComputedValuesAndValidationChecks, schemaWithInlineMultipleRulesForComputedAttributes, schemaWithInlineRuleForComputedAttributeWithCopy, schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar, @@ -25,13 +23,11 @@ import { schemaWithMissingComputedValue, schemaWithMissingRule, schemaWithMissingValueInlineRule, - schemaWithMultipleComputedValueChecks, schemaWithNativeAndJSONLogicChecks, schemaWithNonRequiredField, schemaWithPropertiesCheckAndValidationsInAIf, schemaWithPropertyThatDoesNotExistInThatLevelButDoesInFieldset, schemaWithTwoRules, - schemaWithTwoValidationsWhereOneOfThemIsAppliedConditionally, schemaWithValidationThatDoesNotExistOnProperty, schemaWithVarThatDoesNotExist, } from './jsonLogicFixtures'; @@ -396,72 +392,6 @@ describe('cross-value validations', () => { 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 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(); - }); }); describe('Multiple validations', () => { diff --git a/src/tests/jsonLogicFixtures.js b/src/tests/jsonLogicFixtures.js index b501b67e3..b3f6ac8c7 100644 --- a/src/tests/jsonLogicFixtures.js +++ b/src/tests/jsonLogicFixtures.js @@ -451,222 +451,6 @@ export const schemaWithComputedValueChecksInIf = { }, }; -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 multiRuleSchema = { properties: { field_a: { From d037d27cb5565553196590489eb9a6d0eff36652 Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 24 Aug 2023 15:04:14 +0200 Subject: [PATCH 12/13] chore: dont do fieldset code --- src/createHeadlessForm.js | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/src/createHeadlessForm.js b/src/createHeadlessForm.js index f979c3def..48b5728a2 100644 --- a/src/createHeadlessForm.js +++ b/src/createHeadlessForm.js @@ -100,7 +100,7 @@ function removeInvalidAttributes(fields) { * * @returns {FieldParameters} */ -function buildFieldParameters(name, fieldProperties, required = [], config = {}, validations) { +function buildFieldParameters(name, fieldProperties, required = [], config = {}) { const { position } = pickXKey(fieldProperties, 'presentation') ?? {}; let fields; @@ -108,14 +108,9 @@ function buildFieldParameters(name, fieldProperties, required = [], config = {}, if (inputType === supportedTypes.FIELDSET) { // eslint-disable-next-line no-use-before-define - fields = getFieldsFromJSONSchema( - fieldProperties, - { - customProperties: get(config, `customProperties.${name}`, {}), - parentID: name, - }, - validations - ); + fields = getFieldsFromJSONSchema(fieldProperties, { + customProperties: get(config, `customProperties.${name}`, {}), + }); } const result = { @@ -290,11 +285,7 @@ function getFieldsFromJSONSchema(scopedJsonSchema, config, validations) { return []; } - const fieldParamsList = convertJSONSchemaPropertiesToFieldParameters( - scopedJsonSchema, - config, - validations - ); + const fieldParamsList = convertJSONSchemaPropertiesToFieldParameters(scopedJsonSchema, config); applyFieldsDependencies(fieldParamsList, scopedJsonSchema); From b43bfb884478fd234c2333bb32a015ffc40f8f69 Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 24 Aug 2023 15:08:14 +0200 Subject: [PATCH 13/13] Revert "chore: remove half of the tests, too big a PR" This reverts commit 93fb420c329f7fc239609453c017f41d6282fc6c. --- src/tests/jsonLogic.test.js | 70 +++++++++++ src/tests/jsonLogicFixtures.js | 216 +++++++++++++++++++++++++++++++++ 2 files changed, 286 insertions(+) diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index e54bba56c..a57ca4dd1 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -6,6 +6,7 @@ import { multiRuleSchema, schemaSelfContainedValueForMaximumMinimumValues, schemaSelfContainedValueForTitleWithNoTemplate, + schemaWhereValidationAndComputedValueIsAppliedOnNormalThenStatement, schemaWithChecksAndThenValidationsOnThen, schemaWithComputedAttributeThatDoesntExist, schemaWithComputedAttributeThatDoesntExistDescription, @@ -16,6 +17,7 @@ import { schemaWithDeepVarThatDoesNotExist, schemaWithDeepVarThatDoesNotExistOnFieldset, schemaWithGreaterThanChecksForThreeFields, + schemaWithIfStatementWithComputedValuesAndValidationChecks, schemaWithInlineMultipleRulesForComputedAttributes, schemaWithInlineRuleForComputedAttributeWithCopy, schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar, @@ -23,11 +25,13 @@ import { schemaWithMissingComputedValue, schemaWithMissingRule, schemaWithMissingValueInlineRule, + schemaWithMultipleComputedValueChecks, schemaWithNativeAndJSONLogicChecks, schemaWithNonRequiredField, schemaWithPropertiesCheckAndValidationsInAIf, schemaWithPropertyThatDoesNotExistInThatLevelButDoesInFieldset, schemaWithTwoRules, + schemaWithTwoValidationsWhereOneOfThemIsAppliedConditionally, schemaWithValidationThatDoesNotExistOnProperty, schemaWithVarThatDoesNotExist, } from './jsonLogicFixtures'; @@ -392,6 +396,72 @@ describe('cross-value validations', () => { 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 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(); + }); }); describe('Multiple validations', () => { diff --git a/src/tests/jsonLogicFixtures.js b/src/tests/jsonLogicFixtures.js index b3f6ac8c7..b501b67e3 100644 --- a/src/tests/jsonLogicFixtures.js +++ b/src/tests/jsonLogicFixtures.js @@ -451,6 +451,222 @@ export const schemaWithComputedValueChecksInIf = { }, }; +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 multiRuleSchema = { properties: { field_a: {