From 87d2d9b544d851eb9d03676491e29465e39c6777 Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 29 Jun 2023 16:24:04 +0200 Subject: [PATCH 01/26] 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 --- src/helpers.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helpers.js b/src/helpers.js index 50495d015..3aec7c5f0 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); + const matchesCondition = checkIfConditionMatches(node, formValues, formFields, logic); // 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 d2cc213296fd4e8406627258440342f07e1e3cbb Mon Sep 17 00:00:00 2001 From: brennj Date: Tue, 22 Aug 2023 12:57:23 +0200 Subject: [PATCH 02/26] chore: support barebones computedAttrs --- src/createHeadlessForm.js | 20 +++++++++++++++++--- src/helpers.js | 32 ++++++++++++++++++++++++++++---- src/jsonLogic.js | 30 ++++++++++++++++++++++++++++++ src/tests/jsonLogic.test.js | 13 +++++++++++++ 4 files changed, 88 insertions(+), 7 deletions(-) diff --git a/src/createHeadlessForm.js b/src/createHeadlessForm.js index 4b089edc1..484b2be2e 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']); + } } /** @@ -238,6 +242,10 @@ function buildField(fieldParams, config, scopedJsonSchema, logic) { customProperties ); + const getComputedAttributes = + Object.keys(fieldParams.computedAttributes).length > 0 && + calculateComputedAttributes(fieldParams, config); + const hasCustomValidations = !!customProperties && size(pick(customProperties, SUPPORTED_CUSTOM_VALIDATION_FIELD_PARAMS)) > 0; @@ -253,6 +261,7 @@ function buildField(fieldParams, config, scopedJsonSchema, logic) { ...(hasCustomValidations && { calculateCustomValidationProperties: calculateCustomValidationPropertiesClosure, }), + ...(getComputedAttributes && { getComputedAttributes }), // field customization properties ...(customProperties && { fieldCustomization: customProperties }), // base schema @@ -302,7 +311,7 @@ function getFieldsFromJSONSchema(scopedJsonSchema, config, logic) { addFieldText: fieldParams.addFieldText, }; - buildField(fieldParams, config, scopedJsonSchema).forEach((groupField) => { + buildField(fieldParams, config, scopedJsonSchema, validations).forEach((groupField) => { fields.push(groupField); }); } else { @@ -331,7 +340,12 @@ export function createHeadlessForm(jsonSchema, customConfig = {}) { const handleValidation = handleValuesChange(fields, jsonSchema, config, logic); - 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 3aec7c5f0..3595a2018 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -179,7 +179,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) { @@ -226,6 +226,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); @@ -270,13 +282,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) { @@ -316,7 +330,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 }); }); }); } @@ -452,6 +466,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']); @@ -499,6 +522,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 86f5ebab8..ba3247b93 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -122,3 +122,33 @@ export function yupSchemaWithCustomJSONLogic({ field, logic, 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 e1cd0513f..b6a7e9575 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, @@ -212,4 +213,16 @@ describe('jsonLogic: cross-values 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); + }); + }); }); From cf8f9bdbb72d6a492f2c61f0997613c60270f47d Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 31 Aug 2023 16:48:53 +0200 Subject: [PATCH 03/26] chore: fix errors --- src/createHeadlessForm.js | 4 ++-- src/helpers.js | 10 +++++----- src/jsonLogic.js | 10 +++++----- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/createHeadlessForm.js b/src/createHeadlessForm.js index 484b2be2e..d97643085 100644 --- a/src/createHeadlessForm.js +++ b/src/createHeadlessForm.js @@ -311,7 +311,7 @@ function getFieldsFromJSONSchema(scopedJsonSchema, config, logic) { addFieldText: fieldParams.addFieldText, }; - buildField(fieldParams, config, scopedJsonSchema, validations).forEach((groupField) => { + buildField(fieldParams, config, scopedJsonSchema, logic).forEach((groupField) => { fields.push(groupField); }); } else { @@ -344,7 +344,7 @@ export function createHeadlessForm(jsonSchema, customConfig = {}) { fields, getPrefillValues(fields, config.initialValues), jsonSchema, - validations + logic ); return { diff --git a/src/helpers.js b/src/helpers.js index 3595a2018..b4d355e37 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -179,7 +179,7 @@ export function getPrefillValues(fields, initialValues = {}) { * @param {Object} node - JSON-schema node * @returns */ -function updateField(field, requiredFields, node, formValues, validations, config) { +function updateField(field, requiredFields, node, formValues, logic, 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) { @@ -233,7 +233,7 @@ function updateField(field, requiredFields, node, formValues, validations, confi node, formValues, config, - validations, + logic, }); updateValues(computedFieldValues); } @@ -282,13 +282,13 @@ 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, validations, { parentID }); + updateField(field, requiredFields, node, formValues, logic, { 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, validations, { + updateField(getField(fieldName, formFields), requiredFields, node, formValues, logic, { parentID, }); }); @@ -330,7 +330,7 @@ export function processNode({ node.anyOf.forEach(({ required = [] }) => { required.forEach((fieldName) => { const field = getField(fieldName, formFields); - updateField(field, requiredFields, node, formValues, validations, { parentID }); + updateField(field, requiredFields, node, formValues, logic, { parentID }); }); }); } diff --git a/src/jsonLogic.js b/src/jsonLogic.js index ba3247b93..63a802dba 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -124,11 +124,11 @@ export function yupSchemaWithCustomJSONLogic({ field, logic, config, id }) { } export function calculateComputedAttributes(fieldParams, { parentID = 'root' } = {}) { - return ({ validations, formValues }) => { + return ({ logic, formValues }) => { const { name, computedAttributes } = fieldParams; const attributes = Object.fromEntries( Object.entries(computedAttributes) - .map(handleComputedAttribute(validations, formValues, parentID, name)) + .map(handleComputedAttribute(logic, formValues, parentID, name)) .filter(([, value]) => value !== null) ); @@ -136,18 +136,18 @@ export function calculateComputedAttributes(fieldParams, { parentID = 'root' } = }; } -function handleComputedAttribute(validations, formValues, parentID, name) { +function handleComputedAttribute(logic, formValues, parentID, name) { return ([key, value]) => { if (key === 'const') return [ key, - validations.getScope(parentID).evaluateComputedValueRuleForField(value, formValues, name), + logic.getScope(parentID).evaluateComputedValueRuleForField(value, formValues, name), ]; if (typeof value === 'string') { return [ key, - validations.getScope(parentID).evaluateComputedValueRuleForField(value, formValues, name), + logic.getScope(parentID).evaluateComputedValueRuleForField(value, formValues, name), ]; } }; From 2eca83f8b36b314bb2279dc59a26b7a62cb2f7b7 Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 31 Aug 2023 16:59:19 +0200 Subject: [PATCH 04/26] chore: fix mess ups from rebase --- src/jsonLogic.js | 10 ++-------- src/tests/jsonLogic.fixtures.js | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/jsonLogic.js b/src/jsonLogic.js index 63a802dba..a4a8ff08b 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -139,16 +139,10 @@ export function calculateComputedAttributes(fieldParams, { parentID = 'root' } = function handleComputedAttribute(logic, formValues, parentID, name) { return ([key, value]) => { if (key === 'const') - return [ - key, - logic.getScope(parentID).evaluateComputedValueRuleForField(value, formValues, name), - ]; + return [key, logic.getScope(parentID).applyComputedValueInField(value, formValues, name)]; if (typeof value === 'string') { - return [ - key, - logic.getScope(parentID).evaluateComputedValueRuleForField(value, formValues, name), - ]; + return [key, logic.getScope(parentID).applyComputedValueInField(value, formValues, name)]; } }; } diff --git a/src/tests/jsonLogic.fixtures.js b/src/tests/jsonLogic.fixtures.js index e35a410a0..1cc9f9aa7 100644 --- a/src/tests/jsonLogic.fixtures.js +++ b/src/tests/jsonLogic.fixtures.js @@ -135,3 +135,28 @@ export const schemaWithTwoRules = { }, }, }; + +export const schemaWithComputedAttributes = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + const: 'a_times_two', + default: 'a_times_two', + }, + }, + }, + required: ['field_a', 'field_b'], + 'x-jsf-logic': { + computedValues: { + a_times_two: { + rule: { + '*': [{ var: 'field_a' }, 2], + }, + }, + }, + }, +}; From b30504754889090e8aee20a479bb47f2478c2476 Mon Sep 17 00:00:00 2001 From: brennj Date: Fri, 1 Sep 2023 12:06:48 +0200 Subject: [PATCH 05/26] chore: feedback from PR --- src/helpers.js | 17 ++++++++++------- src/jsonLogic.js | 10 +++++----- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/helpers.js b/src/helpers.js index b4d355e37..d51839e1d 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -469,13 +469,7 @@ export function extractParametersFromNode(schemaNode) { 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 decoratedComputedAttributes = getDecoratedComputedAttributes(computedAttributes); const node = omit(schemaNode, ['x-jsf-presentation', 'presentation']); const description = presentation?.description || node.description; @@ -604,3 +598,12 @@ export const handleValuesChange = (fields, jsonSchema, config) => (values) => { formErrors: yupToFormErrors(errors), }; }; + +function getDecoratedComputedAttributes(computedAttributes) { + return { + ...(computedAttributes ?? {}), + ...(computedAttributes?.const && computedAttributes?.default + ? { value: computedAttributes.const } + : {}), + }; +} diff --git a/src/jsonLogic.js b/src/jsonLogic.js index a4a8ff08b..d2d80d775 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -125,10 +125,10 @@ export function yupSchemaWithCustomJSONLogic({ field, logic, config, id }) { export function calculateComputedAttributes(fieldParams, { parentID = 'root' } = {}) { return ({ logic, formValues }) => { - const { name, computedAttributes } = fieldParams; + const { computedAttributes } = fieldParams; const attributes = Object.fromEntries( Object.entries(computedAttributes) - .map(handleComputedAttribute(logic, formValues, parentID, name)) + .map(handleComputedAttribute(logic, formValues, parentID)) .filter(([, value]) => value !== null) ); @@ -136,13 +136,13 @@ export function calculateComputedAttributes(fieldParams, { parentID = 'root' } = }; } -function handleComputedAttribute(logic, formValues, parentID, name) { +function handleComputedAttribute(logic, formValues, parentID) { return ([key, value]) => { if (key === 'const') - return [key, logic.getScope(parentID).applyComputedValueInField(value, formValues, name)]; + return [key, logic.getScope(parentID).applyComputedValueInField(value, formValues)]; if (typeof value === 'string') { - return [key, logic.getScope(parentID).applyComputedValueInField(value, formValues, name)]; + return [key, logic.getScope(parentID).applyComputedValueInField(value, formValues)]; } }; } From edbc56f309b4f3e5ccec9cec68c43befaa40dfee Mon Sep 17 00:00:00 2001 From: brennj Date: Fri, 1 Sep 2023 12:11:40 +0200 Subject: [PATCH 06/26] chore: pass logic down at updateFieldsProperties to prevent bugs --- src/helpers.js | 4 ++-- src/tests/jsonLogic.test.js | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/helpers.js b/src/helpers.js index d51839e1d..e01a1c99d 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -574,8 +574,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, logic) => (values) => { + updateFieldsProperties(fields, values, jsonSchema, logic); const lazySchema = lazy(() => buildCompleteYupSchema(fields, config)); let errors; diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index b6a7e9575..b40a06b80 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -216,13 +216,15 @@ describe('jsonLogic: cross-values validations', () => { describe('Derive values', () => { it('field_b is field_a * 2', () => { - const { fields } = createHeadlessForm(schemaWithComputedAttributes, { + const { fields, handleValidation } = 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); + handleValidation({ field_a: 4 }); + expect(fieldB.default).toEqual(8); }); }); }); From 55ed2966685f5574c41bb0667d72207b777622fd Mon Sep 17 00:00:00 2001 From: brennj Date: Fri, 1 Sep 2023 15:03:04 +0200 Subject: [PATCH 07/26] Release 0.5.0-dev.20230901130231 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0d5f1d99b..de69c9585 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@remoteoss/json-schema-form", - "version": "0.4.5-beta.0", + "version": "0.5.0-dev.20230901130231", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@remoteoss/json-schema-form", - "version": "0.4.5-beta.0", + "version": "0.5.0-dev.20230901130231", "license": "MIT", "dependencies": { "json-logic-js": "^2.0.2", diff --git a/package.json b/package.json index f95bff62c..bff1218b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@remoteoss/json-schema-form", - "version": "0.4.5-beta.0", + "version": "0.5.0-dev.20230901130231", "description": "Headless UI form powered by JSON Schemas", "author": "Remote.com (https://remote.com/)", "license": "MIT", From 54f7c4255abd5f20ede5687455af79b893be8155 Mon Sep 17 00:00:00 2001 From: brennj Date: Mon, 4 Sep 2023 15:08:09 +0200 Subject: [PATCH 08/26] Revert "Release 0.5.0-dev.20230901130231" This reverts commit 55ed2966685f5574c41bb0667d72207b777622fd. --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index de69c9585..0d5f1d99b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@remoteoss/json-schema-form", - "version": "0.5.0-dev.20230901130231", + "version": "0.4.5-beta.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@remoteoss/json-schema-form", - "version": "0.5.0-dev.20230901130231", + "version": "0.4.5-beta.0", "license": "MIT", "dependencies": { "json-logic-js": "^2.0.2", diff --git a/package.json b/package.json index bff1218b1..f95bff62c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@remoteoss/json-schema-form", - "version": "0.5.0-dev.20230901130231", + "version": "0.4.5-beta.0", "description": "Headless UI form powered by JSON Schemas", "author": "Remote.com (https://remote.com/)", "license": "MIT", From 2710f51ea965c9e9d3d5af7ca4536404c5c4f58d Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 29 Jun 2023 16:24:04 +0200 Subject: [PATCH 09/26] 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 --- src/tests/jsonLogicFixtures.js | 178 +++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 src/tests/jsonLogicFixtures.js 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], + }, + }, + }, + }, + }, + }, +}; From da20a935025ab3dc496f5d9aac4d1d21cc568d43 Mon Sep 17 00:00:00 2001 From: brennj Date: Tue, 22 Aug 2023 12:57:23 +0200 Subject: [PATCH 10/26] chore: support barebones computedAttrs --- src/tests/jsonLogicFixtures.js | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) 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 195825d5a77f6f1c56d08dd115d0dc5c4069384d Mon Sep 17 00:00:00 2001 From: brennj Date: Wed, 23 Aug 2023 14:14:39 +0200 Subject: [PATCH 11/26] chore: computed string attributes --- src/jsonLogic.js | 80 +++++++++++++++++++++++++++++++++- src/tests/jsonLogic.test.js | 22 ++++++++++ src/tests/jsonLogicFixtures.js | 43 +++++++++++++++++- 3 files changed, 141 insertions(+), 4 deletions(-) diff --git a/src/jsonLogic.js b/src/jsonLogic.js index d2d80d775..9c0ed4279 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -1,5 +1,7 @@ import jsonLogic from 'json-logic-js'; +import { buildYupSchema } from './yupSchema'; + /** * Parses the JSON schema to extract the json-logic rules and returns an object * containing the validation scopes, functions to retrieve the scopes, and evaluate the @@ -123,8 +125,37 @@ export function yupSchemaWithCustomJSONLogic({ field, logic, 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 ({ logic, formValues }) => { + return ({ logic, isRequired, config, formValues }) => { const { computedAttributes } = fieldParams; const attributes = Object.fromEntries( Object.entries(computedAttributes) @@ -132,17 +163,62 @@ export function calculateComputedAttributes(fieldParams, { parentID = 'root' } = .filter(([, value]) => value !== null) ); - return attributes; + return { + ...attributes, + schema: buildYupSchema( + { ...fieldParams, ...attributes, required: isRequired }, + config, + validations + ), + }; }; } function handleComputedAttribute(logic, formValues, parentID) { 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, logic.getScope(parentID).applyComputedValueInField(value, formValues)]; + if (key === 'x-jsf-errorMessage') { + return [ + 'errorMessage', + handleNestedObjectForComputedValues(value, formValues, parentID, validations, name), + ]; + } + if (typeof value === 'string') { return [key, logic.getScope(parentID).applyComputedValueInField(value, formValues)]; } + + 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 b40a06b80..1bb870efa 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, @@ -221,10 +222,31 @@ describe('jsonLogic: cross-values validations', () => { initialValues: { field_a: 2 }, }); const fieldB = fields.find((i) => i.name === 'field_b'); + expect(fieldB.description).toEqual( + 'This field is 2 times bigger than field_a with value of 4.' + ); expect(fieldB.default).toEqual(4); expect(fieldB.value).toEqual(4); handleValidation({ field_a: 4 }); expect(fieldB.default).toEqual(8); + expect(fieldB.label).toEqual('This is 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 9118b09c16fefb7a4799a423238b6ab70cf36f15 Mon Sep 17 00:00:00 2001 From: brennj Date: Mon, 4 Sep 2023 16:40:50 +0200 Subject: [PATCH 12/26] chore: fix tests --- src/jsonLogic.js | 39 ++--- src/tests/jsonLogic.fixtures.js | 41 ++++++ src/tests/jsonLogic.test.js | 2 +- src/tests/jsonLogicFixtures.js | 244 -------------------------------- 4 files changed, 56 insertions(+), 270 deletions(-) delete mode 100644 src/tests/jsonLogicFixtures.js diff --git a/src/jsonLogic.js b/src/jsonLogic.js index 9c0ed4279..cc146f376 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -125,31 +125,29 @@ export function yupSchemaWithCustomJSONLogic({ field, logic, config, id }) { ); } +const HANDLEBARS_REGEX = /\{\{([^{}]+)\}\}/g; + function replaceHandlebarsTemplates({ value: toReplace, - validations, + logic, formValues, parentID, name: fieldName, }) { if (typeof toReplace === 'string') { - return toReplace.replace(/\{\{([^{}]+)\}\}/g, (match, key) => { - return validations - .getScope(parentID) - .evaluateComputedValueRuleForField(key.trim(), formValues, fieldName); + return toReplace.replace(HANDLEBARS_REGEX, (match, key) => { + return logic.getScope(parentID).applyComputedValueInField(key.trim(), formValues, fieldName); }); } else if (typeof toReplace === 'object') { const { value, ...rules } = toReplace; const computedTemplateValue = Object.entries(rules).reduce((prev, [key, rule]) => { - const computedValue = validations.getScope(parentID).evaluateValidation(rule, formValues); + const computedValue = logic.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); + return logic.getScope(parentID).applyComputedValueInField(key.trim(), formValues, fieldName); }); } } @@ -168,7 +166,7 @@ export function calculateComputedAttributes(fieldParams, { parentID = 'root' } = schema: buildYupSchema( { ...fieldParams, ...attributes, required: isRequired }, config, - validations + logic ), }; }; @@ -177,13 +175,10 @@ export function calculateComputedAttributes(fieldParams, { parentID = 'root' } = function handleComputedAttribute(logic, formValues, parentID) { return ([key, value]) => { if (key === 'description') - return [key, replaceHandlebarsTemplates({ value, validations, formValues, parentID, name })]; + return [key, replaceHandlebarsTemplates({ value, logic, formValues, parentID, name })]; if (key === 'title') { - return [ - 'label', - replaceHandlebarsTemplates({ value, validations, formValues, parentID, name }), - ]; + return ['label', replaceHandlebarsTemplates({ value, logic, formValues, parentID, name })]; } if (key === 'const') @@ -192,7 +187,7 @@ function handleComputedAttribute(logic, formValues, parentID) { if (key === 'x-jsf-errorMessage') { return [ 'errorMessage', - handleNestedObjectForComputedValues(value, formValues, parentID, validations, name), + handleNestedObjectForComputedValues(value, formValues, parentID, logic, name), ]; } @@ -203,22 +198,16 @@ function handleComputedAttribute(logic, formValues, parentID) { if (key === 'x-jsf-presentation' && value.statement) { return [ 'statement', - handleNestedObjectForComputedValues( - value.statement, - formValues, - parentID, - validations, - name - ), + handleNestedObjectForComputedValues(value.statement, formValues, parentID, logic, name), ]; } }; } -function handleNestedObjectForComputedValues(values, formValues, parentID, validations, name) { +function handleNestedObjectForComputedValues(values, formValues, parentID, logic, name) { return Object.fromEntries( Object.entries(values).map(([key, value]) => { - return [key, replaceHandlebarsTemplates({ value, validations, formValues, parentID, name })]; + return [key, replaceHandlebarsTemplates({ value, logic, formValues, parentID, name })]; }) ); } diff --git a/src/tests/jsonLogic.fixtures.js b/src/tests/jsonLogic.fixtures.js index 1cc9f9aa7..f1583beff 100644 --- a/src/tests/jsonLogic.fixtures.js +++ b/src/tests/jsonLogic.fixtures.js @@ -144,8 +144,10 @@ export const schemaWithComputedAttributes = { field_b: { type: 'number', 'x-jsf-logic-computedAttrs': { + title: 'This is {{a_times_two}}!', const: 'a_times_two', default: 'a_times_two', + description: 'This field is 2 times bigger than field_a with value of {{a_times_two}}.', }, }, }, @@ -160,3 +162,42 @@ export const schemaWithComputedAttributes = { }, }, }; + +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], + }, + }, + }, + }, +}; diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index 1bb870efa..4a0f19567 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -229,7 +229,7 @@ describe('jsonLogic: cross-values validations', () => { expect(fieldB.value).toEqual(4); handleValidation({ field_a: 4 }); expect(fieldB.default).toEqual(8); - expect(fieldB.label).toEqual('This is 4!'); + expect(fieldB.label).toEqual('This is 8!'); }); it('Derived errorMessages and statements work', () => { diff --git a/src/tests/jsonLogicFixtures.js b/src/tests/jsonLogicFixtures.js deleted file mode 100644 index 6c135b6f8..000000000 --- a/src/tests/jsonLogicFixtures.js +++ /dev/null @@ -1,244 +0,0 @@ -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 schemaWithComputedAttributes = { - properties: { - field_a: { - type: 'number', - }, - field_b: { - type: 'number', - 'x-jsf-logic-computedAttrs': { - title: 'This is {{a_times_two}}!', - const: 'a_times_two', - default: 'a_times_two', - description: 'This field is 2 times bigger than field_a with value of {{a_times_two}}.', - }, - }, - }, - required: ['field_a', 'field_b'], - 'x-jsf-logic': { - computedValues: { - a_times_two: { - rule: { - '*': [{ var: 'field_a' }, 2], - }, - }, - }, - }, -}; - -export const 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], - }, - }, - }, - }, - }, - }, -}; - -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 5f32f2133af9358d2553a4d3d8da50ba2c6863f4 Mon Sep 17 00:00:00 2001 From: brennj Date: Mon, 4 Sep 2023 16:51:46 +0200 Subject: [PATCH 13/26] chore: consistency for curly braces --- .eslintrc | 1 + src/jsonLogic.js | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.eslintrc b/.eslintrc index 91703edfe..98c85cc09 100644 --- a/.eslintrc +++ b/.eslintrc @@ -11,6 +11,7 @@ }, "rules": { "jest/no-focused-tests": "error", + "curly": ["error", "multi-line"], "arrow-body-style": 0, "default-case": 0, "import/order": [ diff --git a/src/jsonLogic.js b/src/jsonLogic.js index cc146f376..6a6770a0a 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -150,6 +150,7 @@ function replaceHandlebarsTemplates({ return logic.getScope(parentID).applyComputedValueInField(key.trim(), formValues, fieldName); }); } + return toReplace; } export function calculateComputedAttributes(fieldParams, { parentID = 'root' } = {}) { @@ -174,15 +175,17 @@ export function calculateComputedAttributes(fieldParams, { parentID = 'root' } = function handleComputedAttribute(logic, formValues, parentID) { return ([key, value]) => { - if (key === 'description') + if (key === 'description') { return [key, replaceHandlebarsTemplates({ value, logic, formValues, parentID, name })]; + } if (key === 'title') { return ['label', replaceHandlebarsTemplates({ value, logic, formValues, parentID, name })]; } - if (key === 'const') + if (key === 'const') { return [key, logic.getScope(parentID).applyComputedValueInField(value, formValues)]; + } if (key === 'x-jsf-errorMessage') { return [ From a40da7b016ffced558f5b3c22442b6ad0069312b Mon Sep 17 00:00:00 2001 From: brennj Date: Mon, 4 Sep 2023 17:09:05 +0200 Subject: [PATCH 14/26] chore: remove unneeded code for now --- src/jsonLogic.js | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/jsonLogic.js b/src/jsonLogic.js index 6a6770a0a..cd21ff5a3 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -138,17 +138,6 @@ function replaceHandlebarsTemplates({ return toReplace.replace(HANDLEBARS_REGEX, (match, key) => { return logic.getScope(parentID).applyComputedValueInField(key.trim(), formValues, fieldName); }); - } else if (typeof toReplace === 'object') { - const { value, ...rules } = toReplace; - - const computedTemplateValue = Object.entries(rules).reduce((prev, [key, rule]) => { - const computedValue = logic.getScope(parentID).evaluateValidation(rule, formValues); - return prev.replaceAll(`{{${key}}}`, computedValue); - }, value); - - return computedTemplateValue.replace(/\{\{([^{}]+)\}\}/g, (match, key) => { - return logic.getScope(parentID).applyComputedValueInField(key.trim(), formValues, fieldName); - }); } return toReplace; } From 35d0fd1db54ae7028393906fde858693722c8f03 Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 29 Jun 2023 16:24:04 +0200 Subject: [PATCH 15/26] 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 chore: support barebones computedAttrs chore: computed string attributes chore: error handling --- src/jsonLogic.js | 107 ++++++++++ src/tests/jsonLogic.test.js | 83 ++++++++ src/tests/jsonLogicFixtures.js | 345 +++++++++++++++++++++++++++++++++ 3 files changed, 535 insertions(+) create mode 100644 src/tests/jsonLogicFixtures.js diff --git a/src/jsonLogic.js b/src/jsonLogic.js index cd21ff5a3..d8d21ad0c 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -20,6 +20,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') @@ -30,6 +31,8 @@ export function createValidationChecker(schema) { createScopes(property, key); } }); + + validateInlineRules(jsonSchema, sampleEmptyObject); } createScopes(schema); @@ -53,12 +56,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); }); @@ -76,6 +92,9 @@ function createValidationsScope(schema) { }, applyComputedValueInField(id, values) { const validation = computedValuesMap.get(id); + if (validation === undefined) { + throw Error(`"${id}" computedValue in field "${fieldName}" doesn't exist.`); + } return validate(validation.rule, values); }, applyComputedValueRuleInCondition(id, values) { @@ -183,6 +202,13 @@ function handleComputedAttribute(logic, formValues, parentID) { ]; } + if (key === 'x-jsf-errorMessage') { + return [ + 'errorMessage', + handleNestedObjectForComputedValues(value, formValues, parentID, validations, name), + ]; + } + if (typeof value === 'string') { return [key, logic.getScope(parentID).applyComputedValueInField(value, formValues)]; } @@ -193,6 +219,19 @@ function handleComputedAttribute(logic, formValues, parentID) { handleNestedObjectForComputedValues(value.statement, formValues, parentID, logic, name), ]; } + + if (key === 'x-jsf-presentation' && value.statement) { + return [ + 'statement', + handleNestedObjectForComputedValues( + value.statement, + formValues, + parentID, + validations, + name + ), + ]; + } }; } @@ -203,3 +242,71 @@ function handleNestedObjectForComputedValues(values, formValues, parentID, logic }) ); } + +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 4a0f19567..1cb4e0ece 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, @@ -83,6 +90,82 @@ describe('jsonLogic: cross-values 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 new file mode 100644 index 000000000..dd31a4421 --- /dev/null +++ b/src/tests/jsonLogicFixtures.js @@ -0,0 +1,345 @@ +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 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: { + 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 schemaWithMissingValueInlineRule = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: { + ruleOne: { + '+': [1, 2], + }, + ruleTwo: { + '+': [3, 4], + }, + }, + }, + }, + }, + required: [], +}; + +export const schemaWithTwoRules = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-validations': ['a_bigger_than_b'], + }, + field_b: { + type: 'number', + 'x-jsf-logic-validations': ['is_even_number'], + }, + }, + required: ['field_a', 'field_b'], + 'x-jsf-logic': { + validations: { + a_bigger_than_b: { + errorMessage: 'A must be bigger than B', + rule: { + '>': [{ var: 'field_a' }, { var: 'field_b' }], + }, + }, + is_even_number: { + errorMessage: 'B must be even', + rule: { + '===': [{ '%': [{ var: 'field_b' }, 2] }, 0], + }, + }, + }, + }, +}; + +export const schemaWithComputedAttributes = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: 'This is {{a_times_two}}!', + const: 'a_times_two', + default: 'a_times_two', + description: 'This field is 2 times bigger than field_a with value of {{a_times_two}}.', + }, + }, + }, + required: ['field_a', 'field_b'], + 'x-jsf-logic': { + computedValues: { + a_times_two: { + rule: { + '*': [{ var: 'field_a' }, 2], + }, + }, + }, + }, +}; + +export const schemaWithInlineRuleForComputedAttributeWithoutCopy = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: { + rule: { + '+': [{ var: 'field_a' }, 10], + }, + }, + }, + }, + }, +}; + +export const schemaWithComputedAttributeThatDoesntExist = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + default: 'iDontExist', + }, + }, + }, +}; + +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: { + 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], + }, + }, + }, + }, + }, + }, +}; + +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 57b90af2a5e7e5fc92cf8e898e951301c793b3a0 Mon Sep 17 00:00:00 2001 From: brennj Date: Mon, 4 Sep 2023 19:13:21 +0200 Subject: [PATCH 16/26] chore: fix tests --- src/jsonLogic.js | 27 ++----- src/tests/jsonLogic.fixtures.js | 126 ++++++++++++++++++++++++++++++++ src/tests/jsonLogic.test.js | 9 --- 3 files changed, 133 insertions(+), 29 deletions(-) diff --git a/src/jsonLogic.js b/src/jsonLogic.js index d8d21ad0c..c1b9aafd5 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -90,7 +90,7 @@ function createValidationsScope(schema) { const validation = validationMap.get(id); return validate(validation.rule, values); }, - applyComputedValueInField(id, values) { + applyComputedValueInField(id, values, fieldName) { const validation = computedValuesMap.get(id); if (validation === undefined) { throw Error(`"${id}" computedValue in field "${fieldName}" doesn't exist.`); @@ -163,10 +163,10 @@ function replaceHandlebarsTemplates({ export function calculateComputedAttributes(fieldParams, { parentID = 'root' } = {}) { return ({ logic, isRequired, config, formValues }) => { - const { computedAttributes } = fieldParams; + const { name, computedAttributes } = fieldParams; const attributes = Object.fromEntries( Object.entries(computedAttributes) - .map(handleComputedAttribute(logic, formValues, parentID)) + .map(handleComputedAttribute(logic, formValues, parentID, name)) .filter(([, value]) => value !== null) ); @@ -181,7 +181,7 @@ export function calculateComputedAttributes(fieldParams, { parentID = 'root' } = }; } -function handleComputedAttribute(logic, formValues, parentID) { +function handleComputedAttribute(logic, formValues, parentID, name) { return ([key, value]) => { if (key === 'description') { return [key, replaceHandlebarsTemplates({ value, logic, formValues, parentID, name })]; @@ -192,7 +192,7 @@ function handleComputedAttribute(logic, formValues, parentID) { } if (key === 'const') { - return [key, logic.getScope(parentID).applyComputedValueInField(value, formValues)]; + return [key, logic.getScope(parentID).applyComputedValueInField(value, formValues, name)]; } if (key === 'x-jsf-errorMessage') { @@ -205,12 +205,12 @@ function handleComputedAttribute(logic, formValues, parentID) { if (key === 'x-jsf-errorMessage') { return [ 'errorMessage', - handleNestedObjectForComputedValues(value, formValues, parentID, validations, name), + handleNestedObjectForComputedValues(value, formValues, parentID, logic, name), ]; } if (typeof value === 'string') { - return [key, logic.getScope(parentID).applyComputedValueInField(value, formValues)]; + return [key, logic.getScope(parentID).applyComputedValueInField(value, formValues, name)]; } if (key === 'x-jsf-presentation' && value.statement) { @@ -219,19 +219,6 @@ function handleComputedAttribute(logic, formValues, parentID) { handleNestedObjectForComputedValues(value.statement, formValues, parentID, logic, name), ]; } - - if (key === 'x-jsf-presentation' && value.statement) { - return [ - 'statement', - handleNestedObjectForComputedValues( - value.statement, - formValues, - parentID, - validations, - name - ), - ]; - } }; } diff --git a/src/tests/jsonLogic.fixtures.js b/src/tests/jsonLogic.fixtures.js index f1583beff..e667f2490 100644 --- a/src/tests/jsonLogic.fixtures.js +++ b/src/tests/jsonLogic.fixtures.js @@ -77,6 +77,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: { @@ -163,6 +197,98 @@ 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: { + 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: { + 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], + }, + }, + }, + }, + }, + }, +}; + export const schemaWithComputedAttributesAndErrorMessages = { properties: { field_a: { diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index 1cb4e0ece..c269224cd 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -12,7 +12,6 @@ import { schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar, schemaWithMissingComputedValue, schemaWithMissingRule, - schemaWithMissingValueInlineRule, schemaWithNativeAndJSONLogicChecks, schemaWithNonRequiredField, schemaWithTwoRules, @@ -115,14 +114,6 @@ describe('jsonLogic: cross-values validations', () => { ); }); - 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, From a186d139f70402f948657b4e2fa93ffdda9a5a20 Mon Sep 17 00:00:00 2001 From: brennj Date: Mon, 4 Sep 2023 19:33:06 +0200 Subject: [PATCH 17/26] chore: review errors --- src/jsonLogic.js | 10 ++++++---- src/tests/jsonLogic.test.js | 20 ++++++++++++++------ 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/jsonLogic.js b/src/jsonLogic.js index c1b9aafd5..26eb5c54c 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -60,7 +60,7 @@ function createValidationsScope(schema) { validations.forEach(([id, validation]) => { if (!validation.rule) { - throw Error(`Missing rule for validation with id of: "${id}".`); + throw Error(`[json-schema-form] json-logic error: Validation "${id}" has missing rule.`); } checkRuleIntegrity(validation.rule, id, sampleEmptyObject); @@ -70,7 +70,7 @@ function createValidationsScope(schema) { computedValues.forEach(([id, computedValue]) => { if (!computedValue.rule) { - throw Error(`Missing rule for computedValue with id of: "${id}".`); + throw Error(`[json-schema-form] json-logic error: Computed value "${id}" has missing rule.`); } checkRuleIntegrity(computedValue.rule, id, sampleEmptyObject); @@ -93,7 +93,9 @@ function createValidationsScope(schema) { applyComputedValueInField(id, values, fieldName) { const validation = computedValuesMap.get(id); if (validation === undefined) { - throw Error(`"${id}" computedValue in field "${fieldName}" doesn't exist.`); + throw Error( + `[json-schema-form] json-logic error: Computed value "${id}" doesn't exist in field "${fieldName}".` + ); } return validate(validation.rule, values); }, @@ -264,7 +266,7 @@ function validateInlineRules(jsonSchema, sampleEmptyObject) { fieldName, sampleEmptyObject, (item) => - `"${item.var}" in inline rule in property "${fieldName}.x-jsf-logic-computedAttrs.${key}" does not exist as a JSON schema property.` + `[json-schema-form] json-logic error: fieldName "${item.var}" doesn't exist in field "${fieldName}.x-jsf-logic-computedAttrs.${key}".` ); }); }); diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index c269224cd..d94dbf3bc 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -102,7 +102,9 @@ describe('jsonLogic: cross-values validations', () => { createHeadlessForm(schemaWithMissingRule, { strictInputType: false }); expect(console.error).toHaveBeenCalledWith( 'JSON Schema invalid!', - Error('Missing rule for validation with id of: "a_greater_than_ten".') + Error( + '[json-schema-form] json-logic error: Validation "a_greater_than_ten" has missing rule.' + ) ); }); @@ -110,7 +112,7 @@ describe('jsonLogic: cross-values validations', () => { createHeadlessForm(schemaWithMissingComputedValue, { strictInputType: false }); expect(console.error).toHaveBeenCalledWith( 'JSON Schema invalid!', - Error('Missing rule for computedValue with id of: "a_plus_ten".') + Error('[json-schema-form] json-logic error: Computed value "a_plus_ten" has missing rule.') ); }); @@ -120,7 +122,9 @@ describe('jsonLogic: cross-values validations', () => { }); expect(console.error).toHaveBeenCalledWith( 'JSON Schema invalid!', - Error(`"iDontExist" computedValue in field "field_a" doesn't exist.`) + Error( + `[json-schema-form] json-logic error: Computed value "iDontExist" doesn't exist in field "field_a".` + ) ); }); @@ -130,7 +134,9 @@ describe('jsonLogic: cross-values validations', () => { }); expect(console.error).toHaveBeenCalledWith( 'JSON Schema invalid!', - Error(`"iDontExist" computedValue in field "field_a" doesn't exist.`) + Error( + `[json-schema-form] json-logic error: Computed value "iDontExist" doesn't exist in field "field_a".` + ) ); }); @@ -140,7 +146,9 @@ describe('jsonLogic: cross-values validations', () => { }); expect(console.error).toHaveBeenCalledWith( 'JSON Schema invalid!', - Error(`"iDontExist" computedValue in field "field_a" doesn't exist.`) + Error( + `[json-schema-form] json-logic error: Computed value "iDontExist" doesn't exist in field "field_a".` + ) ); }); @@ -151,7 +159,7 @@ describe('jsonLogic: cross-values validations', () => { 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.' + `[json-schema-form] json-logic error: fieldName "IdontExist" doesn't exist in field "field_a.x-jsf-logic-computedAttrs.title".` ) ); }); From aa3432b75a23888bd89955b40823cc44b167ba10 Mon Sep 17 00:00:00 2001 From: brennj Date: Mon, 4 Sep 2023 19:35:59 +0200 Subject: [PATCH 18/26] chore: fix up code for fixtures --- src/tests/jsonLogic.fixtures.js | 26 --- src/tests/jsonLogicFixtures.js | 345 -------------------------------- 2 files changed, 371 deletions(-) delete mode 100644 src/tests/jsonLogicFixtures.js diff --git a/src/tests/jsonLogic.fixtures.js b/src/tests/jsonLogic.fixtures.js index e667f2490..fb86f5925 100644 --- a/src/tests/jsonLogic.fixtures.js +++ b/src/tests/jsonLogic.fixtures.js @@ -263,32 +263,6 @@ export const schemaWithComputedAttributeThatDoesntExistDescription = { }, }; -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], - }, - }, - }, - }, - }, - }, -}; - export const schemaWithComputedAttributesAndErrorMessages = { properties: { field_a: { diff --git a/src/tests/jsonLogicFixtures.js b/src/tests/jsonLogicFixtures.js deleted file mode 100644 index dd31a4421..000000000 --- a/src/tests/jsonLogicFixtures.js +++ /dev/null @@ -1,345 +0,0 @@ -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 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: { - 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 schemaWithMissingValueInlineRule = { - properties: { - field_a: { - type: 'number', - 'x-jsf-logic-computedAttrs': { - title: { - ruleOne: { - '+': [1, 2], - }, - ruleTwo: { - '+': [3, 4], - }, - }, - }, - }, - }, - required: [], -}; - -export const schemaWithTwoRules = { - properties: { - field_a: { - type: 'number', - 'x-jsf-logic-validations': ['a_bigger_than_b'], - }, - field_b: { - type: 'number', - 'x-jsf-logic-validations': ['is_even_number'], - }, - }, - required: ['field_a', 'field_b'], - 'x-jsf-logic': { - validations: { - a_bigger_than_b: { - errorMessage: 'A must be bigger than B', - rule: { - '>': [{ var: 'field_a' }, { var: 'field_b' }], - }, - }, - is_even_number: { - errorMessage: 'B must be even', - rule: { - '===': [{ '%': [{ var: 'field_b' }, 2] }, 0], - }, - }, - }, - }, -}; - -export const schemaWithComputedAttributes = { - properties: { - field_a: { - type: 'number', - }, - field_b: { - type: 'number', - 'x-jsf-logic-computedAttrs': { - title: 'This is {{a_times_two}}!', - const: 'a_times_two', - default: 'a_times_two', - description: 'This field is 2 times bigger than field_a with value of {{a_times_two}}.', - }, - }, - }, - required: ['field_a', 'field_b'], - 'x-jsf-logic': { - computedValues: { - a_times_two: { - rule: { - '*': [{ var: 'field_a' }, 2], - }, - }, - }, - }, -}; - -export const schemaWithInlineRuleForComputedAttributeWithoutCopy = { - properties: { - field_a: { - type: 'number', - }, - field_b: { - type: 'number', - 'x-jsf-logic-computedAttrs': { - title: { - rule: { - '+': [{ var: 'field_a' }, 10], - }, - }, - }, - }, - }, -}; - -export const schemaWithComputedAttributeThatDoesntExist = { - properties: { - field_a: { - type: 'number', - 'x-jsf-logic-computedAttrs': { - default: 'iDontExist', - }, - }, - }, -}; - -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: { - 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], - }, - }, - }, - }, - }, - }, -}; - -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 40679e597a8beef66816b767076e689f743a5680 Mon Sep 17 00:00:00 2001 From: brennj Date: Mon, 4 Sep 2023 19:44:27 +0200 Subject: [PATCH 19/26] chore: add a bunch of docs to try and make things clearer --- src/jsonLogic.js | 65 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/jsonLogic.js b/src/jsonLogic.js index 26eb5c54c..0f70f1633 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -163,6 +163,19 @@ function replaceHandlebarsTemplates({ return toReplace; } +/** + * Builds computed attributes for a field based on jsonLogic rules. + * + * Processes rules defined in the schema's x-jsf-logic section to build + * computed attributes like label, description, etc. + * + * Handles replacing handlebars templates in strings with computed values. + * + * @param {Object} fieldParams - The field configuration parameters + * @param {Object} options - Options + * @param {string} [options.parentID='root'] - ID of the validation scope + * @returns {Function} A function to build the computed attributes + */ export function calculateComputedAttributes(fieldParams, { parentID = 'root' } = {}) { return ({ logic, isRequired, config, formValues }) => { const { name, computedAttributes } = fieldParams; @@ -183,6 +196,17 @@ export function calculateComputedAttributes(fieldParams, { parentID = 'root' } = }; } +/** + * Handles computing a single attribute value. + * + * Evaluates jsonLogic rules to build the computed value. + * + * @param {Object} logic - Validation logic + * @param {Object} formValues - Current form values + * @param {string} parentID - ID of the validation scope + * @param {string} name - Name of the field + * @returns {Function} Function to compute the attribute value + */ function handleComputedAttribute(logic, formValues, parentID, name) { return ([key, value]) => { if (key === 'description') { @@ -232,6 +256,17 @@ function handleNestedObjectForComputedValues(values, formValues, parentID, logic ); } +/** + * Builds a sample empty object for the given schema. + * + * Recursively builds an object with empty values for each property in the schema. + * Used to provide a valid data structure to test jsonLogic validation rules against. + * + * Handles objects, arrays, and nested schemas. + * + * @param {Object} schema - The JSON schema + * @returns {Object} Sample empty object based on the schema + */ function buildSampleEmptyObject(schema = {}) { const sample = {}; if (typeof schema !== 'object' || !schema.properties) { @@ -252,6 +287,17 @@ function buildSampleEmptyObject(schema = {}) { return sample; } +/** + * Validates inline jsonLogic rules defined in the schema's x-jsf-logic-computedAttrs. + * + * For each field with computed attributes, checks that the variables + * referenced in the rules exist in the schema. + * + * Throws if any variable in a computed attribute rule does not exist. + * + * @param {Object} jsonSchema - The JSON schema object + * @param {Object} sampleEmptyObject - Sample empty object based on the schema + */ function validateInlineRules(jsonSchema, sampleEmptyObject) { const properties = (jsonSchema?.properties || jsonSchema?.items?.properties) ?? {}; Object.entries(properties) @@ -273,6 +319,17 @@ function validateInlineRules(jsonSchema, sampleEmptyObject) { }); } +/** + * Checks the integrity of a jsonLogic rule by validating that all referenced variables exist in the provided data object. + * + * Throws an error if any variable in the rule does not exist in the data. + * + * @param {Object|Array} rule - The jsonLogic rule object or array to validate + * @param {string} id - The ID of the rule (used in error messages) + * @param {Object} data - The data object to check the rule variables against + * @param {Function} errorMessage - Function to generate custom error message. + * Receives the invalid rule part and should throw an error message string. + */ function checkRuleIntegrity( rule, id, @@ -295,6 +352,14 @@ function checkRuleIntegrity( }); } +/** + * Removes array indices from a json schema path string. + * Converts paths like "foo.0.bar" to "foo.bar". + * This allows checking if a variable exists in an array item schema without needing the specific index. + * + * @param {string} path - The json schema path potentially containing array indices + * @returns {string} The path with array indices removed + */ function removeIndicesFromPath(path) { const intermediatePath = path.replace(/\.\d+\./g, '.'); return intermediatePath.replace(/\.\d+$/, ''); From 8d54d43048458fee1daed4f72ac73684e6a147ce Mon Sep 17 00:00:00 2001 From: brennj Date: Mon, 4 Sep 2023 19:53:13 +0200 Subject: [PATCH 20/26] chore: add example to docs --- src/jsonLogic.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/jsonLogic.js b/src/jsonLogic.js index 0f70f1633..958e30025 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -321,9 +321,16 @@ function validateInlineRules(jsonSchema, sampleEmptyObject) { /** * Checks the integrity of a jsonLogic rule by validating that all referenced variables exist in the provided data object. - * * Throws an error if any variable in the rule does not exist in the data. * + * @example + * + * const rule = { "+": [{ "var": "iDontExist"}, 10 ]} + * const badData = { a: 1 } + * checkRuleIntegrity(rule, "add_ten_to_field", badData) + * // throws Error(`"iDontExist" in rule "add_ten_to_field" does not exist as a JSON schema property.`) + * + * * @param {Object|Array} rule - The jsonLogic rule object or array to validate * @param {string} id - The ID of the rule (used in error messages) * @param {Object} data - The data object to check the rule variables against From df9b1a62416a06842240935ee4c86c882ec0e1e8 Mon Sep 17 00:00:00 2001 From: brennj Date: Tue, 5 Sep 2023 09:52:33 +0200 Subject: [PATCH 21/26] chore: higher level console check --- src/tests/createHeadlessForm.test.js | 14 +++----------- src/tests/jsonLogic.test.js | 12 +++++------- src/tests/testUtils.js | 11 +++++++++++ 3 files changed, 19 insertions(+), 18 deletions(-) create mode 100644 src/tests/testUtils.js diff --git a/src/tests/createHeadlessForm.test.js b/src/tests/createHeadlessForm.test.js index 784e5e395..09e57e9a8 100644 --- a/src/tests/createHeadlessForm.test.js +++ b/src/tests/createHeadlessForm.test.js @@ -57,6 +57,7 @@ import { schemaForErrorMessageSpecificity, jsfConfigForErrorMessageSpecificity, } from './helpers'; +import { mockConsole, restoreConsoleAndEnsureItWasNotCalled } from './testUtils'; function buildJSONSchemaInput({ presentationFields, inputFields = {}, required }) { return { @@ -92,17 +93,8 @@ const getField = (fields, name, ...subNames) => { return field; }; -beforeEach(() => { - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); -}); - -afterEach(() => { - expect(console.error).not.toHaveBeenCalled(); - console.error.mockRestore(); - expect(console.warn).not.toHaveBeenCalled(); - console.warn.mockRestore(); -}); +beforeEach(mockConsole); +afterEach(restoreConsoleAndEnsureItWasNotCalled); describe('createHeadlessForm', () => { it('returns empty result given no schema', () => { diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index d94dbf3bc..e10179d81 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -16,6 +16,10 @@ import { schemaWithNonRequiredField, schemaWithTwoRules, } from './jsonLogic.fixtures'; +import { mockConsole, restoreConsoleAndEnsureItWasNotCalled } from './testUtils'; + +beforeEach(mockConsole); +afterEach(restoreConsoleAndEnsureItWasNotCalled); describe('jsonLogic: cross-values validations', () => { describe('Does not conflict with native JSON schema', () => { @@ -90,13 +94,7 @@ describe('jsonLogic: cross-values validations', () => { }); describe('Incorrectly written schemas', () => { - beforeEach(() => { - jest.spyOn(console, 'error').mockImplementation(() => {}); - }); - - afterEach(() => { - console.error.mockRestore(); - }); + afterEach(() => console.error.mockClear()); it('Should throw when theres a missing rule', () => { createHeadlessForm(schemaWithMissingRule, { strictInputType: false }); diff --git a/src/tests/testUtils.js b/src/tests/testUtils.js new file mode 100644 index 000000000..d0231426d --- /dev/null +++ b/src/tests/testUtils.js @@ -0,0 +1,11 @@ +export function mockConsole() { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); +} + +export function restoreConsoleAndEnsureItWasNotCalled() { + expect(console.error).not.toHaveBeenCalled(); + console.error.mockRestore(); + expect(console.warn).not.toHaveBeenCalled(); + console.warn.mockRestore(); +} From 81402ed910a066a3d3ba2a7c1dd58f808548deed Mon Sep 17 00:00:00 2001 From: brennj Date: Tue, 5 Sep 2023 10:17:11 +0200 Subject: [PATCH 22/26] chore: use cases to clean up error tests --- src/tests/jsonLogic.test.js | 100 +++++++++++++----------------------- 1 file changed, 37 insertions(+), 63 deletions(-) diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index e10179d81..eed069d38 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -96,70 +96,44 @@ describe('jsonLogic: cross-values validations', () => { describe('Incorrectly written schemas', () => { afterEach(() => console.error.mockClear()); - it('Should throw when theres a missing rule', () => { - createHeadlessForm(schemaWithMissingRule, { strictInputType: false }); - expect(console.error).toHaveBeenCalledWith( - 'JSON Schema invalid!', - Error( - '[json-schema-form] json-logic error: Validation "a_greater_than_ten" has missing rule.' - ) - ); - }); - - it('Should throw when theres a missing computed value', () => { - createHeadlessForm(schemaWithMissingComputedValue, { strictInputType: false }); - expect(console.error).toHaveBeenCalledWith( - 'JSON Schema invalid!', - Error('[json-schema-form] json-logic error: Computed value "a_plus_ten" has missing rule.') - ); - }); - - 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( - `[json-schema-form] json-logic error: Computed value "iDontExist" doesn't exist in field "field_a".` - ) - ); - }); + const cases = [ + [ + 'x-jsf-logic.validations: throw when theres a missing rule', + schemaWithMissingRule, + '[json-schema-form] json-logic error: Validation "a_greater_than_ten" has missing rule.', + ], + [ + 'x-jsf-logic.computedValues: throw when theres a missing computed value', + schemaWithMissingComputedValue, + '[json-schema-form] json-logic error: Computed value "a_plus_ten" has missing rule.', + ], + [ + 'x-jsf-logic-computedAttrs: error if theres a value that does not exist on an attribute.', + schemaWithComputedAttributeThatDoesntExist, + `[json-schema-form] json-logic error: Computed value "iDontExist" doesn't exist in field "field_a".`, + ], + [ + 'x-jsf-logic-computedAttrs: error if theres a value that does not exist on a template string (title).', + schemaWithComputedAttributeThatDoesntExistTitle, + `[json-schema-form] json-logic error: Computed value "iDontExist" doesn't exist in field "field_a".`, + ], + [ + 'x-jsf-logic-computedAttrs: error if theres a value that does not exist on a template string (description).', + schemaWithComputedAttributeThatDoesntExistDescription, + `[json-schema-form] json-logic error: Computed value "iDontExist" doesn't exist in field "field_a".`, + ], + [ + 'x-jsf-logic-computedAttrs:, error if theres a value referenced that does not exist on an inline rule.', + schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar, + `[json-schema-form] json-logic error: fieldName "IdontExist" doesn't exist in field "field_a.x-jsf-logic-computedAttrs.title".`, + ], + ]; - 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( - `[json-schema-form] json-logic error: Computed value "iDontExist" doesn't exist in field "field_a".` - ) - ); - }); - - 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( - `[json-schema-form] json-logic error: Computed value "iDontExist" doesn't exist in field "field_a".` - ) - ); - }); - - 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( - `[json-schema-form] json-logic error: fieldName "IdontExist" doesn't exist in field "field_a.x-jsf-logic-computedAttrs.title".` - ) - ); + test.each(cases)('%p', (_, schema, expectedErrorString) => { + const { error } = createHeadlessForm(schema, { strictInputType: false }); + const expectedError = new Error(expectedErrorString); + expect(console.error).toHaveBeenCalledWith('JSON Schema invalid!', expectedError); + expect(error).toEqual(expectedError); }); }); From 827ba1b8cd16f52f0c5e418fef9f322f94d7fa27 Mon Sep 17 00:00:00 2001 From: brennj Date: Tue, 5 Sep 2023 10:30:32 +0200 Subject: [PATCH 23/26] chore: add more tests for missing vars --- src/tests/jsonLogic.fixtures.js | 23 +++++++++++++++++++++++ src/tests/jsonLogic.test.js | 12 ++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/tests/jsonLogic.fixtures.js b/src/tests/jsonLogic.fixtures.js index fb86f5925..fb5a9ce6e 100644 --- a/src/tests/jsonLogic.fixtures.js +++ b/src/tests/jsonLogic.fixtures.js @@ -94,6 +94,29 @@ export const schemaWithMissingRule = { required: [], }; +export const schemaWithUnknownVariableInValidations = { + properties: {}, + 'x-jsf-logic': { + validations: { + a_equals_ten: { + errorMessage: 'Must equal 10', + rule: { '===': [{ var: 'field_a' }, 10] }, + }, + }, + }, +}; + +export const schemaWithUnknownVariableInComputedValues = { + properties: {}, + 'x-jsf-logic': { + computedValues: { + a_times_ten: { + rule: { '*': [{ var: 'field_a' }, 10] }, + }, + }, + }, +}; + export const schemaWithMissingComputedValue = { properties: { field_a: { diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index eed069d38..0d90f623b 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -15,6 +15,8 @@ import { schemaWithNativeAndJSONLogicChecks, schemaWithNonRequiredField, schemaWithTwoRules, + schemaWithUnknownVariableInComputedValues, + schemaWithUnknownVariableInValidations, } from './jsonLogic.fixtures'; import { mockConsole, restoreConsoleAndEnsureItWasNotCalled } from './testUtils'; @@ -102,6 +104,16 @@ describe('jsonLogic: cross-values validations', () => { schemaWithMissingRule, '[json-schema-form] json-logic error: Validation "a_greater_than_ten" has missing rule.', ], + [ + 'x-jsf-validations: throw when theres a value that does not exist in a rule', + schemaWithUnknownVariableInValidations, + '"field_a" in rule "a_equals_ten" does not exist as a JSON schema property.', + ], + [ + 'x-jsf-validations: throw when theres a value that does not exist in a rule', + schemaWithUnknownVariableInComputedValues, + '"field_a" in rule "a_times_ten" does not exist as a JSON schema property.', + ], [ 'x-jsf-logic.computedValues: throw when theres a missing computed value', schemaWithMissingComputedValue, From 02a0ff5aadf9ad15833f81427637cf61ef70033c Mon Sep 17 00:00:00 2001 From: brennj Date: Tue, 5 Sep 2023 10:42:43 +0200 Subject: [PATCH 24/26] chore: use switch statement instead --- src/jsonLogic.js | 60 ++++++++++++++++++++---------------------------- 1 file changed, 25 insertions(+), 35 deletions(-) diff --git a/src/jsonLogic.js b/src/jsonLogic.js index 958e30025..5b0ee2d2f 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -209,41 +209,31 @@ export function calculateComputedAttributes(fieldParams, { parentID = 'root' } = */ function handleComputedAttribute(logic, formValues, parentID, name) { return ([key, value]) => { - if (key === 'description') { - return [key, replaceHandlebarsTemplates({ value, logic, formValues, parentID, name })]; - } - - if (key === 'title') { - return ['label', replaceHandlebarsTemplates({ value, logic, formValues, parentID, name })]; - } - - if (key === 'const') { - return [key, logic.getScope(parentID).applyComputedValueInField(value, formValues, name)]; - } - - if (key === 'x-jsf-errorMessage') { - return [ - 'errorMessage', - handleNestedObjectForComputedValues(value, formValues, parentID, logic, name), - ]; - } - - if (key === 'x-jsf-errorMessage') { - return [ - 'errorMessage', - handleNestedObjectForComputedValues(value, formValues, parentID, logic, name), - ]; - } - - if (typeof value === 'string') { - return [key, logic.getScope(parentID).applyComputedValueInField(value, formValues, name)]; - } - - if (key === 'x-jsf-presentation' && value.statement) { - return [ - 'statement', - handleNestedObjectForComputedValues(value.statement, formValues, parentID, logic, name), - ]; + switch (key) { + case 'description': + return [key, replaceHandlebarsTemplates({ value, logic, formValues, parentID, name })]; + case 'title': + return ['label', replaceHandlebarsTemplates({ value, logic, formValues, parentID, name })]; + case 'x-jsf-errorMessage': + return [ + 'errorMessage', + handleNestedObjectForComputedValues(value, formValues, parentID, logic, name), + ]; + case 'x-jsf-presentation': { + if (value.statement) { + return [ + 'statement', + handleNestedObjectForComputedValues(value.statement, formValues, parentID, logic, name), + ]; + } + return [ + key, + handleNestedObjectForComputedValues(value.statement, formValues, parentID, logic, name), + ]; + } + case 'const': + default: + return [key, logic.getScope(parentID).applyComputedValueInField(value, formValues, name)]; } }; } From 8cc22e01c3e5bcda81ddcb95c3754fda7eeb476a Mon Sep 17 00:00:00 2001 From: brennj Date: Tue, 5 Sep 2023 10:52:41 +0200 Subject: [PATCH 25/26] chore: add code comments why schemas fail --- src/tests/jsonLogic.fixtures.js | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/tests/jsonLogic.fixtures.js b/src/tests/jsonLogic.fixtures.js index fb5a9ce6e..32af4cf89 100644 --- a/src/tests/jsonLogic.fixtures.js +++ b/src/tests/jsonLogic.fixtures.js @@ -88,6 +88,7 @@ export const schemaWithMissingRule = { validations: { a_greater_than_ten: { errorMessage: 'Must be greater than 10', + // rule: { '>': [{ var: 'field_a' }, 10] }, this missing causes test to fail. }, }, }, @@ -95,7 +96,9 @@ export const schemaWithMissingRule = { }; export const schemaWithUnknownVariableInValidations = { - properties: {}, + properties: { + // field_a: { type: 'number' }, this missing causes test to fail. + }, 'x-jsf-logic': { validations: { a_equals_ten: { @@ -107,7 +110,9 @@ export const schemaWithUnknownVariableInValidations = { }; export const schemaWithUnknownVariableInComputedValues = { - properties: {}, + properties: { + // field_a: { type: 'number' }, this missing causes test to fail. + }, 'x-jsf-logic': { computedValues: { a_times_ten: { @@ -128,7 +133,9 @@ export const schemaWithMissingComputedValue = { }, 'x-jsf-logic': { computedValues: { - a_plus_ten: {}, + a_plus_ten: { + // rule: { '+': [{ var: 'field_a' }, 10 ]} this missing causes test to fail. + }, }, }, required: [], @@ -247,16 +254,18 @@ export const schemaWithComputedAttributeThatDoesntExist = { }, }, }, + // x-jsf-logic: { computedValues: { iDontExist: { rule: 10 }} this missing causes test to fail. }; export const schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar = { properties: { + // iDontExist: { type: 'number' } this missing causes test to fail. field_a: { type: 'number', 'x-jsf-logic-computedAttrs': { title: { rule: { - '+': [{ var: 'IdontExist' }], + '+': [{ var: 'IdontExist' }, 10], }, }, }, @@ -277,6 +286,7 @@ export const schemaWithComputedAttributeThatDoesntExistTitle = { export const schemaWithComputedAttributeThatDoesntExistDescription = { properties: { + // iDontExist: { type: 'number'}, this missing causes test to fail field_a: { type: 'number', 'x-jsf-logic-computedAttrs': { From f3b0895ec90890913004d538d9c2d3fb4e528ba0 Mon Sep 17 00:00:00 2001 From: brennj Date: Tue, 12 Sep 2023 10:28:40 +0200 Subject: [PATCH 26/26] chore: jsdoc tweaks --- src/jsonLogic.js | 37 ++++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/src/jsonLogic.js b/src/jsonLogic.js index 5b0ee2d2f..f91639dd3 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -11,10 +11,6 @@ import { buildYupSchema } from './yupSchema'; * @returns {Object} An object containing: * - scopes {Map} - A Map of the validation scopes (with IDs as keys) * - getScope {Function} - Function to retrieve a scope by name/ID - * - validate {Function} - Function to evaluate a validation rule - * - applyValidationRuleInCondition {Function} - Evaluate a validation rule used in a condition - * - applyComputedValueInField {Function} - Evaluate a computed value rule for a field - * - applyComputedValueRuleInCondition {Function} - Evaluate a computed value rule used in a condition */ export function createValidationChecker(schema) { const scopes = new Map(); @@ -45,6 +41,21 @@ export function createValidationChecker(schema) { }; } +/** + * Creates a validation scope object for a schema. + * + * Builds maps of validations and computed values defined in the schema's + * x-jsf-logic section. Includes functions to evaluate the rules. + * + * @param {Object} schema - The JSON schema + * @returns {Object} The validation scope object containing: + * - validationMap - Map of validation rules + * - computedValuesMap - Map of computed value rules + * - validate {Function} - Function to evaluate a validation rule + * - applyValidationRuleInCondition {Function} - Evaluate a validation rule used in a condition + * - applyComputedValueInField {Function} - Evaluate a computed value rule for a field + * - applyComputedValueRuleInCondition {Function} - Evaluate a computed value rule used in a condition + */ function createValidationsScope(schema) { const validationMap = new Map(); const computedValuesMap = new Map(); @@ -148,6 +159,20 @@ export function yupSchemaWithCustomJSONLogic({ field, logic, config, id }) { const HANDLEBARS_REGEX = /\{\{([^{}]+)\}\}/g; +/** + * Replaces Handlebars templates in a value with computed values. + * + * Handles recursively replacing Handlebars templates "{{var}}" in strings + * with computed values looked up from the validation logic. + * + * @param {Object} options - Options object + * @param {*} options.value - The value to replace templates in + * @param {Object} options.logic - The validation logic object + * @param {Object} options.formValues - The current form values + * @param {string} options.parentID - The ID of the validation scope + * @param {string} options.name - The name of the field + * @returns {*} The value with templates replaced with computed values + */ function replaceHandlebarsTemplates({ value: toReplace, logic, @@ -349,6 +374,8 @@ function checkRuleIntegrity( }); } +const regexToGetIndices = /\.\d+\./g; // eg. .0., .10. + /** * Removes array indices from a json schema path string. * Converts paths like "foo.0.bar" to "foo.bar". @@ -358,6 +385,6 @@ function checkRuleIntegrity( * @returns {string} The path with array indices removed */ function removeIndicesFromPath(path) { - const intermediatePath = path.replace(/\.\d+\./g, '.'); + const intermediatePath = path.replace(regexToGetIndices, '.'); return intermediatePath.replace(/\.\d+$/, ''); }