From 443395babdf937eb01c29ffa27d0eb8dc1f9c346 Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 29 Jun 2023 16:24:04 +0200 Subject: [PATCH 01/16] 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 | 9 +- 8 files changed, 559 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 3b5cb5634..0d5f1d99b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.4.5-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 042444a7b..f95bff62c 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 5d7aca4d1..2bd48b43d 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); } @@ -256,7 +256,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); @@ -273,25 +280,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)); } } @@ -312,7 +323,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); }); @@ -323,7 +343,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, + }); } }); } @@ -358,11 +384,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); } @@ -425,6 +451,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']); @@ -471,6 +498,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 45db9812e..d31da0e53 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; @@ -406,6 +407,12 @@ export function buildYupSchema(field, config) { validators.push(withConst); } + if (propertyFields.requiredValidations) { + propertyFields.requiredValidations.forEach((id) => + validators.push(yupSchemaWithCustomJSONLogic({ field, id, validations, config })) + ); + } + return flow(validators); } From fab9eb9ece4312009929ec35953b405a6958b3b8 Mon Sep 17 00:00:00 2001 From: brennj Date: Wed, 16 Aug 2023 13:29:30 +0200 Subject: [PATCH 02/16] 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 2bd48b43d..18afc717e 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -280,7 +280,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 7adee33be75a5d44e83d5e905d4f5546bd2be4f5 Mon Sep 17 00:00:00 2001 From: brennj Date: Wed, 16 Aug 2023 13:36:46 +0200 Subject: [PATCH 03/16] 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 eac562895aaa1ab58d9be76f603ab7ec4cb244a6 Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 31 Aug 2023 12:17:45 +0200 Subject: [PATCH 04/16] chore: clean ups from suggestions --- src/helpers.js | 2 +- src/jsonLogic.js | 21 ++++++++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/helpers.js b/src/helpers.js index 18afc717e..8dd1383c2 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -347,8 +347,8 @@ export function processNode({ node: nestedNode, formValues: formValues[name] || {}, formFields: getField(name, formFields).fields, - validations, parentID: name, + validations, }); } }); diff --git a/src/jsonLogic.js b/src/jsonLogic.js index fd6021828..f78828cf4 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -1,10 +1,18 @@ 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. + * Parses the JSON schema to extract the advanced validation logic and returns an object + * containing the validation scopes, functions to retrieve the scopes, and evaluate the + * validation rules. + * * @param {Object} schema - JSON schema node - * @param {Object} initialValues - form state - * @returns {Object} + * @returns {Object} An object containing: + * - scopes {Map} - A Map of the validation scopes (with IDs as keys) + * - getScope {Function} - Function to retrieve a scope by name/ID + * - evaluateValidation {Function} - Function to evaluate a validation rule + * - evaluateValidationRuleInCondition {Function} - Evaluate a validation rule used in a condition + * - evaluateComputedValueRuleForField {Function} - Evaluate a computed value rule for a field + * - evaluateComputedValueRuleInCondition {Function} - Evaluate a computed value rule used in a condition */ export function createValidationChecker(schema) { const scopes = new Map(); @@ -74,6 +82,13 @@ function createValidationsScope(schema) { }; } +/** + * We removed undefined values in this function as `json-logic` ignores them. + * Means we will always check against a value for validations. + * + * @param {Object} values - a set of values from a form + * @returns {Object} values object without any undefined + */ function clean(values = {}) { return Object.entries(values).reduce((prev, [key, value]) => { return { ...prev, [key]: value === undefined ? null : value }; From 63e379a7e0ffcc35a27a2e1be3840c50d63ee2e0 Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 31 Aug 2023 13:25:57 +0200 Subject: [PATCH 05/16] chore: better fn name --- src/jsonLogic.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/jsonLogic.js b/src/jsonLogic.js index f78828cf4..259c226c5 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -60,7 +60,7 @@ function createValidationsScope(schema) { }); function evaluateValidation(rule, values) { - return jsonLogic.apply(rule, clean(values)); + return jsonLogic.apply(rule, replaceUndefinedValuesWithNulls(values)); } return { @@ -89,7 +89,7 @@ function createValidationsScope(schema) { * @param {Object} values - a set of values from a form * @returns {Object} values object without any undefined */ -function clean(values = {}) { +function replaceUndefinedValuesWithNulls(values = {}) { return Object.entries(values).reduce((prev, [key, value]) => { return { ...prev, [key]: value === undefined ? null : value }; }, {}); From 1aaa39e682a71dda7a829a350c6eb7f0eb4a8063 Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 31 Aug 2023 13:44:48 +0200 Subject: [PATCH 06/16] chore: update docs + var name --- src/createHeadlessForm.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/createHeadlessForm.js b/src/createHeadlessForm.js index be2f9a403..4b089edc1 100644 --- a/src/createHeadlessForm.js +++ b/src/createHeadlessForm.js @@ -221,13 +221,15 @@ function getComposeFunctionForField(fieldParams, hasCustomizations) { * Create field object using a compose function * @param {FieldParameters} fieldParams - field parameters * @param {JsfConfig} config - parser config + * @param {Object} scopedJsonSchema - the matching JSON schema + * @param {Object} logic - logic used for validation json-logic * @returns {Object} field object */ -function buildField(fieldParams, config, scopedJsonSchema, validations) { +function buildField(fieldParams, config, scopedJsonSchema, logic) { const customProperties = getCustomPropertiesForField(fieldParams, config); const composeFn = getComposeFunctionForField(fieldParams, !!customProperties); - const yupSchema = buildYupSchema(fieldParams, config, validations); + const yupSchema = buildYupSchema(fieldParams, config, logic); const calculateConditionalFieldsClosure = fieldParams.isDynamic && calculateConditionalProperties(fieldParams, customProperties); @@ -268,7 +270,7 @@ function buildField(fieldParams, config, scopedJsonSchema, validations) { * @param {JsfConfig} config - JSON-schema-form config * @returns {ParserFields} ParserFields */ -function getFieldsFromJSONSchema(scopedJsonSchema, config, validations) { +function getFieldsFromJSONSchema(scopedJsonSchema, config, logic) { if (!scopedJsonSchema) { // NOTE: other type of verifications might be needed. return []; @@ -304,7 +306,7 @@ function getFieldsFromJSONSchema(scopedJsonSchema, config, validations) { fields.push(groupField); }); } else { - fields.push(buildField(fieldParams, config, scopedJsonSchema, validations)); + fields.push(buildField(fieldParams, config, scopedJsonSchema, logic)); } }); @@ -324,10 +326,10 @@ export function createHeadlessForm(jsonSchema, customConfig = {}) { }; try { - const validations = createValidationChecker(jsonSchema); - const fields = getFieldsFromJSONSchema(jsonSchema, config, validations); + const logic = createValidationChecker(jsonSchema); + const fields = getFieldsFromJSONSchema(jsonSchema, config, logic); - const handleValidation = handleValuesChange(fields, jsonSchema, config, validations); + const handleValidation = handleValuesChange(fields, jsonSchema, config, logic); updateFieldsProperties(fields, getPrefillValues(fields, config.initialValues), jsonSchema); From 17e8a35becc2e17d36ec13e2725ac51c85a8be9d Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 31 Aug 2023 13:50:34 +0200 Subject: [PATCH 07/16] chore: dont create two scopes for an array --- src/jsonLogic.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/jsonLogic.js b/src/jsonLogic.js index 259c226c5..f3fd7530e 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -24,8 +24,9 @@ export function createValidationChecker(schema) { .forEach(([key, property]) => { if (property.type === 'array') { createScopes(property.items, `${key}[]`); + } else { + createScopes(property, key); } - createScopes(property, key); }); } From e89145e0ef7f0835c9a176079d2d2530b9d3b9a8 Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 31 Aug 2023 14:09:30 +0200 Subject: [PATCH 08/16] chore: rename validations to logic --- src/helpers.js | 18 +++++++++--------- src/jsonLogic.js | 6 +++--- src/yupSchema.js | 4 ++-- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/helpers.js b/src/helpers.js index 8dd1383c2..50495d015 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, validations) { - const validator = buildYupSchema(field, {}, validations); +export function validateFieldSchema(field, value, logic) { + const validator = buildYupSchema(field, {}, logic); return validator().isValidSync(value); } @@ -262,7 +262,7 @@ export function processNode({ formFields, accRequired = new Set(), parentID = 'root', - validations, + logic, }) { // Set initial required fields const requiredFields = new Set(accRequired); @@ -290,7 +290,7 @@ export function processNode({ formFields, accRequired: requiredFields, parentID, - validations, + logic, }); branchRequired.forEach((field) => requiredFields.add(field)); @@ -301,7 +301,7 @@ export function processNode({ formFields, accRequired: requiredFields, parentID, - validations, + logic, }); branchRequired.forEach((field) => requiredFields.add(field)); } @@ -330,7 +330,7 @@ export function processNode({ formFields, accRequired: requiredFields, parentID, - validations, + logic, }) ) .forEach(({ required: allOfItemRequired }) => { @@ -348,7 +348,7 @@ export function processNode({ formValues: formValues[name] || {}, formFields: getField(name, formFields).fields, parentID: name, - validations, + logic, }); } }); @@ -384,11 +384,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, validations) { +export function updateFieldsProperties(fields, formValues, jsonSchema, logic) { if (!jsonSchema?.properties) { return; } - processNode({ node: jsonSchema, formValues, formFields: fields, validations }); + processNode({ node: jsonSchema, formValues, formFields: fields, logic }); clearValuesIfNotVisible(fields, formValues); } diff --git a/src/jsonLogic.js b/src/jsonLogic.js index f3fd7530e..8061929a7 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -102,15 +102,15 @@ function replaceUndefinedValuesWithNulls(values = {}) { * @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.logic - The logic 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 }) { +export function yupSchemaWithCustomJSONLogic({ field, logic, config, id }) { const { parentID = 'root' } = config; - const validation = validations.getScope(parentID).validationMap.get(id); + const validation = logic.getScope(parentID).validationMap.get(id); return (yupSchema) => yupSchema.test( diff --git a/src/yupSchema.js b/src/yupSchema.js index d31da0e53..c2659f36c 100644 --- a/src/yupSchema.js +++ b/src/yupSchema.js @@ -197,7 +197,7 @@ const getYupSchema = ({ inputType, ...field }) => { * @param {FieldParameters} field Input fields * @returns {Function} Yup schema */ -export function buildYupSchema(field, config, validations) { +export function buildYupSchema(field, config, logic) { const { inputType, jsonType: jsonTypeValue, errorMessage = {}, ...propertyFields } = field; const isCheckboxBoolean = typeof propertyFields.checkboxValue === 'boolean'; let baseSchema; @@ -409,7 +409,7 @@ export function buildYupSchema(field, config, validations) { if (propertyFields.requiredValidations) { propertyFields.requiredValidations.forEach((id) => - validators.push(yupSchemaWithCustomJSONLogic({ field, id, validations, config })) + validators.push(yupSchemaWithCustomJSONLogic({ field, id, logic, config })) ); } From e5fa90ad84c458a1966aaa82e4118d0aaa83693b Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 31 Aug 2023 14:18:37 +0200 Subject: [PATCH 09/16] chore: wrong jsondoc --- src/jsonLogic.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jsonLogic.js b/src/jsonLogic.js index 8061929a7..ebe0629a1 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -104,7 +104,7 @@ function replaceUndefinedValuesWithNulls(values = {}) { * @param {string} options.field.name - The name of the field. * @param {Object} options.logic - The logic 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.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. */ From 096e86aa3ff1e40a5690ecf04cfa872c60525db8 Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 31 Aug 2023 14:21:37 +0200 Subject: [PATCH 10/16] chore: doc nit --- src/jsonLogic.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jsonLogic.js b/src/jsonLogic.js index ebe0629a1..cf2f54467 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -1,7 +1,7 @@ import jsonLogic from 'json-logic-js'; /** - * Parses the JSON schema to extract the advanced validation logic and returns an object + * Parses the JSON schema to extract the json-logic rules and returns an object * containing the validation scopes, functions to retrieve the scopes, and evaluate the * validation rules. * From 6f4a7b96be2bae44cd3ea44a2bc82bf86d7ecf37 Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 31 Aug 2023 14:37:02 +0200 Subject: [PATCH 11/16] chore: naming suggestions --- src/jsonLogic.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/jsonLogic.js b/src/jsonLogic.js index cf2f54467..22115b47e 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -9,9 +9,9 @@ import jsonLogic from 'json-logic-js'; * @returns {Object} An object containing: * - scopes {Map} - A Map of the validation scopes (with IDs as keys) * - getScope {Function} - Function to retrieve a scope by name/ID - * - evaluateValidation {Function} - Function to evaluate a validation rule + * - validate {Function} - Function to evaluate a validation rule * - evaluateValidationRuleInCondition {Function} - Evaluate a validation rule used in a condition - * - evaluateComputedValueRuleForField {Function} - Evaluate a computed value rule for a field + * - applyComputedValueInField {Function} - Evaluate a computed value rule for a field * - evaluateComputedValueRuleInCondition {Function} - Evaluate a computed value rule used in a condition */ export function createValidationChecker(schema) { @@ -60,25 +60,25 @@ function createValidationsScope(schema) { computedValuesMap.set(id, computedValue); }); - function evaluateValidation(rule, values) { + function validate(rule, values) { return jsonLogic.apply(rule, replaceUndefinedValuesWithNulls(values)); } return { validationMap, computedValuesMap, - evaluateValidation, + validate, evaluateValidationRuleInCondition(id, values) { const validation = validationMap.get(id); - return evaluateValidation(validation.rule, values); + return validate(validation.rule, values); }, - evaluateComputedValueRuleForField(id, values) { + applyComputedValueInField(id, values) { const validation = computedValuesMap.get(id); - return evaluateValidation(validation.rule, values); + return validate(validation.rule, values); }, evaluateComputedValueRuleInCondition(id, values) { const validation = computedValuesMap.get(id); - return evaluateValidation(validation.rule, values); + return validate(validation.rule, values); }, }; } From 82db2009226755173d6cfa7fccf83755d50fa007 Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 31 Aug 2023 14:49:40 +0200 Subject: [PATCH 12/16] chore: more clarification in test names --- src/tests/jsonLogic.test.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index bf3cda04e..86522be34 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -25,7 +25,7 @@ describe('cross-value validations', () => { expect(handleValidation({ field_a: 11 }).formErrors).toEqual(undefined); }); - it('Native validations always appear first', () => { + it('Native validations have higher precedence than jsonLogic validations', () => { const { handleValidation } = createHeadlessForm(schemaWithNativeAndJSONLogicChecks, { strictInputType: false, }); @@ -105,8 +105,9 @@ describe('cross-value validations', () => { }), { 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: 2, field_b: 4 }).formErrors).toEqual({ + field_a: 'Field A must be greater than field_b / 2', + }); expect(handleValidation({ field_a: 3, field_b: 5 }).formErrors).toEqual(undefined); }); @@ -184,7 +185,7 @@ describe('cross-value validations', () => { }); describe('Multiple validations', () => { - it('2 rules where A must be bigger than B and not an even number in another rule', () => { + it('two rules: A > B; A is even', () => { const { handleValidation } = createHeadlessForm(multiRuleSchema, { strictInputType: false }); expect(handleValidation({ field_a: 1 }).formErrors).toEqual({ field_a: 'A must be even', From fe7d0c63cb5b99af1e62f5f47cdd5b0d173e61bc Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 31 Aug 2023 15:04:54 +0200 Subject: [PATCH 13/16] chore: rework some tests --- src/tests/jsonLogic.test.js | 15 ++++++++------- src/tests/jsonLogicFixtures.js | 17 ++++++++++------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index 86522be34..fcbcad148 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -15,14 +15,14 @@ describe('cross-value validations', () => { 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({}).formErrors).toBeUndefined(); + expect(handleValidation({ field_a: 0, field_b: 10 }).formErrors).toEqual({ + field_b: 'Must be greater than field_a', }); expect(handleValidation({ field_a: 'incorrect value' }).formErrors).toEqual({ field_a: 'The value must be a number', }); - expect(handleValidation({ field_a: 11 }).formErrors).toEqual(undefined); + expect(handleValidation({ field_a: 11 }).formErrors).toBeUndefined(); }); it('Native validations have higher precedence than jsonLogic validations', () => { @@ -31,11 +31,12 @@ describe('cross-value validations', () => { }); expect(handleValidation({}).formErrors).toEqual({ field_a: 'Required field' }); expect(handleValidation({ field_a: 0 }).formErrors).toEqual({ - field_a: 'Must be greater or equal to 5', + field_a: 'Must be greater or equal to 100', }); - expect(handleValidation({ field_a: 5 }).formErrors).toEqual({ - field_a: 'Must be greater than 10', + expect(handleValidation({ field_a: 101 }).formErrors).toEqual({ + field_a: 'Must be a multiple of 10', }); + expect(handleValidation({ field_a: 110 }).formErrors).toBeUndefined(); }); }); diff --git a/src/tests/jsonLogicFixtures.js b/src/tests/jsonLogicFixtures.js index febd9b1d0..86dea47b0 100644 --- a/src/tests/jsonLogicFixtures.js +++ b/src/tests/jsonLogicFixtures.js @@ -37,15 +37,18 @@ export const schemaWithNonRequiredField = { properties: { field_a: { type: 'number', - 'x-jsf-logic-validations': ['a_greater_than_ten'], + }, + field_b: { + type: 'number', + 'x-jsf-logic-validations': ['a_greater_than_field_b'], }, }, 'x-jsf-logic': { validations: { - a_greater_than_ten: { - errorMessage: 'Must be greater than 10', + a_greater_than_field_b: { + errorMessage: 'Must be greater than field_a', rule: { - '>': [{ var: 'field_a' }, 10], + '>': [{ var: 'field_a' }, { var: 'field_b' }], }, }, }, @@ -57,16 +60,16 @@ export const schemaWithNativeAndJSONLogicChecks = { properties: { field_a: { type: 'number', - minimum: 5, + minimum: 100, 'x-jsf-logic-validations': ['a_greater_than_ten'], }, }, 'x-jsf-logic': { validations: { a_greater_than_ten: { - errorMessage: 'Must be greater than 10', + errorMessage: 'Must be a multiple of 10', rule: { - '>': [{ var: 'field_a' }, 10], + '===': [{ '%': [{ var: 'field_a' }, 10] }, 0], }, }, }, From c96c723fd79b3daf6486482d15aa0b3fef8af738 Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 31 Aug 2023 15:08:16 +0200 Subject: [PATCH 14/16] chore: more fixes --- src/tests/jsonLogic.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index fcbcad148..72659d19e 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -9,9 +9,9 @@ import { schemaWithTwoRules, } from './jsonLogicFixtures'; -describe('cross-value validations', () => { +describe('jsonLogic: cross-values 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', () => { + it('Given an optional field and empty value, jsonLogic validations are ignored', () => { const { handleValidation } = createHeadlessForm(schemaWithNonRequiredField, { strictInputType: false, }); From 637551dc72b2de62f8013085b42852639bb1a809 Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 31 Aug 2023 15:49:15 +0200 Subject: [PATCH 15/16] chore: everything resolved? --- ...LogicFixtures.js => jsonLogic.fixtures.js} | 48 +------------------ src/tests/jsonLogic.test.js | 2 +- 2 files changed, 3 insertions(+), 47 deletions(-) rename src/tests/{jsonLogicFixtures.js => jsonLogic.fixtures.js} (74%) diff --git a/src/tests/jsonLogicFixtures.js b/src/tests/jsonLogic.fixtures.js similarity index 74% rename from src/tests/jsonLogicFixtures.js rename to src/tests/jsonLogic.fixtures.js index 86dea47b0..e35a410a0 100644 --- a/src/tests/jsonLogicFixtures.js +++ b/src/tests/jsonLogic.fixtures.js @@ -61,12 +61,12 @@ export const schemaWithNativeAndJSONLogicChecks = { field_a: { type: 'number', minimum: 100, - 'x-jsf-logic-validations': ['a_greater_than_ten'], + 'x-jsf-logic-validations': ['a_multiple_of_ten'], }, }, 'x-jsf-logic': { validations: { - a_greater_than_ten: { + a_multiple_of_ten: { errorMessage: 'Must be a multiple of 10', rule: { '===': [{ '%': [{ var: 'field_a' }, 10] }, 0], @@ -135,47 +135,3 @@ export const schemaWithTwoRules = { }, }, }; - -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/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index 72659d19e..e1cd0513f 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -7,7 +7,7 @@ import { schemaWithNativeAndJSONLogicChecks, schemaWithNonRequiredField, schemaWithTwoRules, -} from './jsonLogicFixtures'; +} from './jsonLogic.fixtures'; describe('jsonLogic: cross-values validations', () => { describe('Does not conflict with native JSON schema', () => { From 0d7a4aeaf69d92214432171ac310060c95b7d267 Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 31 Aug 2023 16:20:10 +0200 Subject: [PATCH 16/16] chore: one last fix --- src/jsonLogic.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/jsonLogic.js b/src/jsonLogic.js index 22115b47e..86f5ebab8 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -10,9 +10,9 @@ import jsonLogic from 'json-logic-js'; * - scopes {Map} - A Map of the validation scopes (with IDs as keys) * - getScope {Function} - Function to retrieve a scope by name/ID * - validate {Function} - Function to evaluate a validation rule - * - evaluateValidationRuleInCondition {Function} - Evaluate a validation rule used in a condition + * - applyValidationRuleInCondition {Function} - Evaluate a validation rule used in a condition * - applyComputedValueInField {Function} - Evaluate a computed value rule for a field - * - evaluateComputedValueRuleInCondition {Function} - Evaluate a computed value rule used in a condition + * - applyComputedValueRuleInCondition {Function} - Evaluate a computed value rule used in a condition */ export function createValidationChecker(schema) { const scopes = new Map(); @@ -68,7 +68,7 @@ function createValidationsScope(schema) { validationMap, computedValuesMap, validate, - evaluateValidationRuleInCondition(id, values) { + applyValidationRuleInCondition(id, values) { const validation = validationMap.get(id); return validate(validation.rule, values); }, @@ -76,7 +76,7 @@ function createValidationsScope(schema) { const validation = computedValuesMap.get(id); return validate(validation.rule, values); }, - evaluateComputedValueRuleInCondition(id, values) { + applyComputedValueRuleInCondition(id, values) { const validation = computedValuesMap.get(id); return validate(validation.rule, values); },