From 87d2d9b544d851eb9d03676491e29465e39c6777 Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 29 Jun 2023 16:24:04 +0200 Subject: [PATCH 01/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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 36e6845882563bcc9390664dc6f676a24b0f851e Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 29 Jun 2023 16:24:04 +0200 Subject: [PATCH 26/48] 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: error handling feat: add more error handling --- src/createHeadlessForm.js | 4 +- src/helpers.js | 14 +- src/jsonLogic.js | 62 +++-- src/tests/jsonLogic.test.js | 3 + src/tests/jsonLogicFixtures.js | 455 +++++++++++++++++++++++++++++++++ 5 files changed, 511 insertions(+), 27 deletions(-) create mode 100644 src/tests/jsonLogicFixtures.js diff --git a/src/createHeadlessForm.js b/src/createHeadlessForm.js index d97643085..484b2be2e 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, logic).forEach((groupField) => { + buildField(fieldParams, config, scopedJsonSchema, validations).forEach((groupField) => { fields.push(groupField); }); } else { @@ -344,7 +344,7 @@ export function createHeadlessForm(jsonSchema, customConfig = {}) { fields, getPrefillValues(fields, config.initialValues), jsonSchema, - logic + validations ); return { diff --git a/src/helpers.js b/src/helpers.js index e01a1c99d..2ec70d643 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, logic, config) { +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) { @@ -233,7 +233,7 @@ function updateField(field, requiredFields, node, formValues, logic, config) { node, formValues, config, - logic, + validations, }); updateValues(computedFieldValues); } @@ -330,7 +330,7 @@ export function processNode({ node.anyOf.forEach(({ required = [] }) => { required.forEach((fieldName) => { const field = getField(fieldName, formFields); - updateField(field, requiredFields, node, formValues, logic, { parentID }); + updateField(field, requiredFields, node, formValues, validations, { parentID }); }); }); } @@ -468,6 +468,14 @@ export function extractParametersFromNode(schemaNode) { 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 } + : {}), + }; + // This is when a forced value is computed. const decoratedComputedAttributes = getDecoratedComputedAttributes(computedAttributes); const node = omit(schemaNode, ['x-jsf-presentation', 'presentation']); diff --git a/src/jsonLogic.js b/src/jsonLogic.js index 5b0ee2d2f..6b0920baf 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(`[json-schema-form] json-logic error: Validation "${id}" has missing rule.`); + throw Error(`Missing rule for validation with id of: "${id}".`); } checkRuleIntegrity(validation.rule, id, sampleEmptyObject); @@ -70,7 +70,7 @@ function createValidationsScope(schema) { computedValues.forEach(([id, computedValue]) => { if (!computedValue.rule) { - throw Error(`[json-schema-form] json-logic error: Computed value "${id}" has missing rule.`); + throw Error(`Missing rule for computedValue with id of: "${id}".`); } checkRuleIntegrity(computedValue.rule, id, sampleEmptyObject); @@ -88,20 +88,24 @@ function createValidationsScope(schema) { validate, applyValidationRuleInCondition(id, values) { const validation = validationMap.get(id); - return validate(validation.rule, values); + if (validation === undefined) + throw Error(`"${id}" validation in if condition doesn't exist.`); + + return evaluateValidation(validation.rule, values); }, - applyComputedValueInField(id, values, fieldName) { + evaluateComputedValueRuleForField(id, values, fieldName) { const validation = computedValuesMap.get(id); - if (validation === undefined) { - throw Error( - `[json-schema-form] json-logic error: Computed value "${id}" doesn't exist in field "${fieldName}".` - ); - } - return validate(validation.rule, values); + if (validation === undefined) + throw Error(`"${id}" computedValue in field "${fieldName}" doesn't exist.`); + + return evaluateValidation(validation.rule, values); }, applyComputedValueRuleInCondition(id, values) { const validation = computedValuesMap.get(id); - return validate(validation.rule, values); + if (validation === undefined) + throw Error(`"${id}" computedValue in if condition doesn't exist.`); + + return evaluateValidation(validation.rule, values); }, }; } @@ -135,6 +139,10 @@ export function yupSchemaWithCustomJSONLogic({ field, logic, config, id }) { const { parentID = 'root' } = config; const validation = logic.getScope(parentID).validationMap.get(id); + if (validation === undefined) { + throw Error(`Validation "${id}" required for "${field.name}" doesn't exist.`); + } + return (yupSchema) => yupSchema.test( `${field.name}-validation-${id}`, @@ -156,8 +164,26 @@ function replaceHandlebarsTemplates({ name: fieldName, }) { if (typeof toReplace === 'string') { - return toReplace.replace(HANDLEBARS_REGEX, (match, key) => { - return logic.getScope(parentID).applyComputedValueInField(key.trim(), formValues, fieldName); + return toReplace.replace(/\{\{([^{}]+)\}\}/g, (match, key) => { + return validations + .getScope(parentID) + .evaluateComputedValueRuleForField(key.trim(), formValues, fieldName); + }); + } else if (typeof toReplace === 'object') { + const { value, ...rules } = toReplace; + + if (Object.keys(rules).length > 1 && !value) + throw Error('Cannot define multiple rules without a template string with key `value`.'); + + const computedTemplateValue = Object.entries(rules).reduce((prev, [key, rule]) => { + const computedValue = 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); }); } return toReplace; @@ -302,7 +328,7 @@ function validateInlineRules(jsonSchema, sampleEmptyObject) { fieldName, sampleEmptyObject, (item) => - `[json-schema-form] json-logic error: fieldName "${item.var}" doesn't exist in field "${fieldName}.x-jsf-logic-computedAttrs.${key}".` + `"${item.var}" in inline rule in property "${fieldName}.x-jsf-logic-computedAttrs.${key}" does not exist as a JSON schema property.` ); }); }); @@ -349,14 +375,6 @@ 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+$/, ''); diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index 0d90f623b..ea8b9a7c9 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -9,11 +9,14 @@ import { schemaWithComputedAttributeThatDoesntExistTitle, schemaWithComputedAttributes, schemaWithComputedAttributesAndErrorMessages, + schemaWithDeepVarThatDoesNotExist, + schemaWithDeepVarThatDoesNotExistOnFieldset, schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar, schemaWithMissingComputedValue, schemaWithMissingRule, schemaWithNativeAndJSONLogicChecks, schemaWithNonRequiredField, + schemaWithPropertyThatDoesNotExistInThatLevelButDoesInFieldset, schemaWithTwoRules, schemaWithUnknownVariableInComputedValues, schemaWithUnknownVariableInValidations, diff --git a/src/tests/jsonLogicFixtures.js b/src/tests/jsonLogicFixtures.js new file mode 100644 index 000000000..55398153a --- /dev/null +++ b/src/tests/jsonLogicFixtures.js @@ -0,0 +1,455 @@ +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 schemaWithVarThatDoesNotExist = { + properties: { + field_a: { + type: 'number', + }, + }, + 'x-jsf-logic': { + validations: { + a_greater_than_ten: { + errorMessage: 'Must be greater than 10', + rule: { + '>': [{ var: 'field_b' }, 10], + }, + }, + }, + }, + required: [], +}; + +export const schemaWithDeepVarThatDoesNotExist = { + properties: { + field_a: { + type: 'number', + }, + }, + 'x-jsf-logic': { + validations: { + a_greater_than_ten: { + errorMessage: 'Must be greater than 10', + rule: { + '>': [{ var: 'field_a' }, { '*': [2, { '/': [2, { '*': [1, { var: 'field_b' }] }] }] }], + }, + }, + }, + }, + required: [], +}; + +export const schemaWithDeepVarThatDoesNotExistOnFieldset = { + properties: { + field_a: { + type: 'object', + properties: { + child: { + type: 'number', + }, + }, + 'x-jsf-logic': { + validations: { + a_greater_than_ten: { + errorMessage: 'Must be greater than 10', + rule: { + '>': [{ var: 'child' }, { '*': [2, { '/': [2, { '*': [1, { var: 'field_a' }] }] }] }], + }, + }, + }, + }, + }, + }, + required: [], +}; + +export const schemaWithValidationThatDoesNotExistOnProperty = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-validations': ['iDontExist'], + }, + }, +}; + +export const multiRuleSchema = { + properties: { + field_a: { + 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}}!', // 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: { + 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], + }, + }, + }, + }, +}; + +export const schemaWithPropertyThatDoesNotExistInThatLevelButDoesInFieldset = { + properties: { + field_a: { + type: 'object', + 'x-jsf-presentation': { + inputType: 'fieldset', + }, + properties: { + child: { + type: 'number', + 'x-jsf-logic-validations': ['child_greater_than_10'], + }, + other_child: { + type: 'number', + 'x-jsf-logic-validations': ['greater_than_child'], + }, + }, + required: ['child', 'other_child'], + }, + }, + 'x-jsf-logic': { + validations: { + validation_parent: { + errorMessage: 'Must be greater than 10!', + rule: { + '>': [{ var: 'child' }, 10], + }, + }, + greater_than_child: { + errorMessage: 'Must be greater than child', + rule: { + '>': [{ var: 'other_child' }, { var: 'child' }], + }, + }, + }, + }, + required: ['field_a'], +}; From 70a14305020cf014f76d7bd2175b4ad07774c493 Mon Sep 17 00:00:00 2001 From: brennj Date: Tue, 5 Sep 2023 12:08:01 +0200 Subject: [PATCH 27/48] chore: changes --- src/createHeadlessForm.js | 4 +- src/helpers.js | 14 +- src/jsonLogic.js | 49 ++-- src/tests/jsonLogic.fixtures.js | 92 +++++++ src/tests/jsonLogic.test.js | 29 +- src/tests/jsonLogicFixtures.js | 455 -------------------------------- 6 files changed, 144 insertions(+), 499 deletions(-) delete mode 100644 src/tests/jsonLogicFixtures.js 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 2ec70d643..e01a1c99d 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); } @@ -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 }); }); }); } @@ -468,14 +468,6 @@ export function extractParametersFromNode(schemaNode) { 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 } - : {}), - }; - // This is when a forced value is computed. const decoratedComputedAttributes = getDecoratedComputedAttributes(computedAttributes); const node = omit(schemaNode, ['x-jsf-presentation', 'presentation']); diff --git a/src/jsonLogic.js b/src/jsonLogic.js index 6b0920baf..49a4331e3 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); @@ -88,24 +88,20 @@ function createValidationsScope(schema) { validate, applyValidationRuleInCondition(id, values) { const validation = validationMap.get(id); - if (validation === undefined) - throw Error(`"${id}" validation in if condition doesn't exist.`); - - return evaluateValidation(validation.rule, values); + return validate(validation.rule, values); }, - evaluateComputedValueRuleForField(id, values, fieldName) { + applyComputedValueInField(id, values, fieldName) { const validation = computedValuesMap.get(id); - if (validation === undefined) - throw Error(`"${id}" computedValue in field "${fieldName}" doesn't exist.`); - - return evaluateValidation(validation.rule, values); + if (validation === undefined) { + throw Error( + `[json-schema-form] json-logic error: Computed value "${id}" doesn't exist in field "${fieldName}".` + ); + } + return validate(validation.rule, values); }, applyComputedValueRuleInCondition(id, values) { const validation = computedValuesMap.get(id); - if (validation === undefined) - throw Error(`"${id}" computedValue in if condition doesn't exist.`); - - return evaluateValidation(validation.rule, values); + return validate(validation.rule, values); }, }; } @@ -140,7 +136,9 @@ export function yupSchemaWithCustomJSONLogic({ field, logic, config, id }) { const validation = logic.getScope(parentID).validationMap.get(id); if (validation === undefined) { - throw Error(`Validation "${id}" required for "${field.name}" doesn't exist.`); + throw Error( + `[json-schema-form] json-logic error: "${field.name}" required validation "${id}" doesn't exist.` + ); } return (yupSchema) => @@ -164,26 +162,22 @@ function replaceHandlebarsTemplates({ 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; if (Object.keys(rules).length > 1 && !value) - throw Error('Cannot define multiple rules without a template string with key `value`.'); + {throw Error('Cannot define multiple rules without a template string with key `value`.');} const computedTemplateValue = Object.entries(rules).reduce((prev, [key, rule]) => { - const computedValue = validations.getScope(parentID).evaluateValidation(rule, formValues); + 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); }); } return toReplace; @@ -328,7 +322,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}".` ); }); }); @@ -357,7 +351,8 @@ function checkRuleIntegrity( rule, id, data, - errorMessage = (item) => `"${item.var}" in rule "${id}" does not exist as a JSON schema property.` + errorMessage = (item) => + `[json-schema-form] json-logic error: rule "${id}" has no variable "${item.var}".` ) { Object.values(rule ?? {}).map((subRule) => { if (!Array.isArray(subRule) && subRule !== null && subRule !== undefined) return; diff --git a/src/tests/jsonLogic.fixtures.js b/src/tests/jsonLogic.fixtures.js index 32af4cf89..4094dd8f5 100644 --- a/src/tests/jsonLogic.fixtures.js +++ b/src/tests/jsonLogic.fixtures.js @@ -334,3 +334,95 @@ export const schemaWithComputedAttributesAndErrorMessages = { }, }, }; + +export const schemaWithDeepVarThatDoesNotExist = { + properties: { + field_a: { + type: 'number', + }, + }, + 'x-jsf-logic': { + validations: { + a_greater_than_ten: { + errorMessage: 'Must be greater than 10', + rule: { + '>': [{ var: 'field_a' }, { '*': [2, { '/': [2, { '*': [1, { var: 'field_b' }] }] }] }], + }, + }, + }, + }, + required: [], +}; + +export const schemaWithDeepVarThatDoesNotExistOnFieldset = { + properties: { + field_a: { + type: 'object', + properties: { + child: { + type: 'number', + }, + }, + 'x-jsf-logic': { + validations: { + a_greater_than_ten: { + errorMessage: 'Must be greater than 10', + rule: { + '>': [{ var: 'child' }, { '*': [2, { '/': [2, { '*': [1, { var: 'field_a' }] }] }] }], + }, + }, + }, + }, + }, + }, + required: [], +}; + +export const schemaWithValidationThatDoesNotExistOnProperty = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-validations': ['iDontExist'], + }, + }, +}; + +export const schemaWithPropertyThatDoesNotExistInThatLevelButDoesInFieldset = { + properties: { + field_a: { + type: 'object', + 'x-jsf-presentation': { + inputType: 'fieldset', + }, + properties: { + child: { + type: 'number', + 'x-jsf-logic-validations': ['child_greater_than_10'], + }, + other_child: { + type: 'number', + 'x-jsf-logic-validations': ['greater_than_child'], + }, + }, + required: ['child', 'other_child'], + }, + }, + // the issue here is that this should be nested inside `field_a` in order to not fail. + 'x-jsf-logic': { + validations: { + validation_parent: { + errorMessage: 'Must be greater than 10!', + rule: { + '>': [{ var: 'child' }, 10], + }, + }, + greater_than_child: { + errorMessage: 'Must be greater than child', + rule: { + '>': [{ var: 'other_child' }, { var: 'child' }], + }, + }, + }, + }, + required: ['field_a'], +}; diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index ea8b9a7c9..14729d483 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -20,6 +20,7 @@ import { schemaWithTwoRules, schemaWithUnknownVariableInComputedValues, schemaWithUnknownVariableInValidations, + schemaWithValidationThatDoesNotExistOnProperty, } from './jsonLogic.fixtures'; import { mockConsole, restoreConsoleAndEnsureItWasNotCalled } from './testUtils'; @@ -108,14 +109,14 @@ describe('jsonLogic: cross-values validations', () => { '[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', + 'x-jsf-logic.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.', + '[json-schema-form] json-logic error: rule "a_equals_ten" has no variable "field_a".', ], [ - 'x-jsf-validations: throw when theres a value that does not exist in a rule', + 'x-jsf-logic.computedValues: 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.', + '[json-schema-form] json-logic error: rule "a_times_ten" has no variable "field_a".', ], [ 'x-jsf-logic.computedValues: throw when theres a missing computed value', @@ -142,6 +143,26 @@ describe('jsonLogic: cross-values validations', () => { schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar, `[json-schema-form] json-logic error: fieldName "IdontExist" doesn't exist in field "field_a.x-jsf-logic-computedAttrs.title".`, ], + [ + 'x-jsf-logic.validations: error if a field does not exist in a deeply nested rule', + schemaWithDeepVarThatDoesNotExist, + '[json-schema-form] json-logic error: rule "a_greater_than_ten" has no variable "field_b".', + ], + [ + 'x-jsf-logic.validations: error if rule does not exist on a fieldset property', + schemaWithDeepVarThatDoesNotExistOnFieldset, + '[json-schema-form] json-logic error: rule "a_greater_than_ten" has no variable "field_a".', + ], + [ + 'x-jsf-validations: error if a validation name does not exist', + schemaWithValidationThatDoesNotExistOnProperty, + `[json-schema-form] json-logic error: "field_a" required validation "iDontExist" doesn't exist.`, + ], + [ + 'A top level logic keyword will not be able to reference fieldset properties', + schemaWithPropertyThatDoesNotExistInThatLevelButDoesInFieldset, + '[json-schema-form] json-logic error: rule "validation_parent" has no variable "child".', + ], ]; test.each(cases)('%p', (_, schema, expectedErrorString) => { diff --git a/src/tests/jsonLogicFixtures.js b/src/tests/jsonLogicFixtures.js deleted file mode 100644 index 55398153a..000000000 --- a/src/tests/jsonLogicFixtures.js +++ /dev/null @@ -1,455 +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 schemaWithVarThatDoesNotExist = { - properties: { - field_a: { - type: 'number', - }, - }, - 'x-jsf-logic': { - validations: { - a_greater_than_ten: { - errorMessage: 'Must be greater than 10', - rule: { - '>': [{ var: 'field_b' }, 10], - }, - }, - }, - }, - required: [], -}; - -export const schemaWithDeepVarThatDoesNotExist = { - properties: { - field_a: { - type: 'number', - }, - }, - 'x-jsf-logic': { - validations: { - a_greater_than_ten: { - errorMessage: 'Must be greater than 10', - rule: { - '>': [{ var: 'field_a' }, { '*': [2, { '/': [2, { '*': [1, { var: 'field_b' }] }] }] }], - }, - }, - }, - }, - required: [], -}; - -export const schemaWithDeepVarThatDoesNotExistOnFieldset = { - properties: { - field_a: { - type: 'object', - properties: { - child: { - type: 'number', - }, - }, - 'x-jsf-logic': { - validations: { - a_greater_than_ten: { - errorMessage: 'Must be greater than 10', - rule: { - '>': [{ var: 'child' }, { '*': [2, { '/': [2, { '*': [1, { var: 'field_a' }] }] }] }], - }, - }, - }, - }, - }, - }, - required: [], -}; - -export const schemaWithValidationThatDoesNotExistOnProperty = { - properties: { - field_a: { - type: 'number', - 'x-jsf-logic-validations': ['iDontExist'], - }, - }, -}; - -export const multiRuleSchema = { - properties: { - field_a: { - 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}}!', // 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: { - 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], - }, - }, - }, - }, -}; - -export const schemaWithPropertyThatDoesNotExistInThatLevelButDoesInFieldset = { - properties: { - field_a: { - type: 'object', - 'x-jsf-presentation': { - inputType: 'fieldset', - }, - properties: { - child: { - type: 'number', - 'x-jsf-logic-validations': ['child_greater_than_10'], - }, - other_child: { - type: 'number', - 'x-jsf-logic-validations': ['greater_than_child'], - }, - }, - required: ['child', 'other_child'], - }, - }, - 'x-jsf-logic': { - validations: { - validation_parent: { - errorMessage: 'Must be greater than 10!', - rule: { - '>': [{ var: 'child' }, 10], - }, - }, - greater_than_child: { - errorMessage: 'Must be greater than child', - rule: { - '>': [{ var: 'other_child' }, { var: 'child' }], - }, - }, - }, - }, - required: ['field_a'], -}; From d03b369e958ade8b1d3fe0455c7461fcf79ac4cc Mon Sep 17 00:00:00 2001 From: brennj Date: Tue, 5 Sep 2023 13:34:26 +0200 Subject: [PATCH 28/48] chore: add bad operator handling --- src/jsonLogic.js | 21 ++++++++++++++++++--- src/tests/jsonLogic.fixtures.js | 13 +++++++++++++ src/tests/jsonLogic.test.js | 8 +++++++- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/jsonLogic.js b/src/jsonLogic.js index 49a4331e3..9e76bdbee 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -168,8 +168,9 @@ function replaceHandlebarsTemplates({ } else if (typeof toReplace === 'object') { const { value, ...rules } = toReplace; - if (Object.keys(rules).length > 1 && !value) - {throw Error('Cannot define multiple rules without a template string with key `value`.');} + if (Object.keys(rules).length > 1 && !value) { + throw Error('Cannot define multiple rules without a template string with key `value`.'); + } const computedTemplateValue = Object.entries(rules).reduce((prev, [key, rule]) => { const computedValue = logic.getScope(parentID).evaluateValidation(rule, formValues); @@ -354,8 +355,10 @@ function checkRuleIntegrity( errorMessage = (item) => `[json-schema-form] json-logic error: rule "${id}" has no variable "${item.var}".` ) { - Object.values(rule ?? {}).map((subRule) => { + Object.entries(rule ?? {}).map(([operator, subRule]) => { if (!Array.isArray(subRule) && subRule !== null && subRule !== undefined) return; + throwIfUnknownOperator(operator, subRule, id); + subRule.map((item) => { const isVar = item !== null && typeof item === 'object' && Object.hasOwn(item, 'var'); if (isVar) { @@ -370,6 +373,18 @@ function checkRuleIntegrity( }); } +function throwIfUnknownOperator(operator, subRule, id) { + try { + jsonLogic.apply({ [operator]: subRule }); + } catch (e) { + if (e.message === `Unrecognized operation ${operator}`) { + throw Error( + `[json-schema-form] json-logic error: in "${id}" rule there is an unknown operator "${operator}".` + ); + } + } +} + function removeIndicesFromPath(path) { const intermediatePath = path.replace(/\.\d+\./g, '.'); return intermediatePath.replace(/\.\d+$/, ''); diff --git a/src/tests/jsonLogic.fixtures.js b/src/tests/jsonLogic.fixtures.js index 4094dd8f5..cea1b431f 100644 --- a/src/tests/jsonLogic.fixtures.js +++ b/src/tests/jsonLogic.fixtures.js @@ -426,3 +426,16 @@ export const schemaWithPropertyThatDoesNotExistInThatLevelButDoesInFieldset = { }, required: ['field_a'], }; + +export const schemaWithBadOperation = { + properties: {}, + 'x-jsf-logic': { + validations: { + badOperator: { + rule: { + '++': [10, 2], + }, + }, + }, + }, +}; diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index 14729d483..670097d1a 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -4,6 +4,7 @@ import { createSchemaWithRulesOnFieldA, createSchemaWithThreePropertiesWithRuleOnFieldA, multiRuleSchema, + schemaWithBadOperation, schemaWithComputedAttributeThatDoesntExist, schemaWithComputedAttributeThatDoesntExistDescription, schemaWithComputedAttributeThatDoesntExistTitle, @@ -159,10 +160,15 @@ describe('jsonLogic: cross-values validations', () => { `[json-schema-form] json-logic error: "field_a" required validation "iDontExist" doesn't exist.`, ], [ - 'A top level logic keyword will not be able to reference fieldset properties', + 'x-jsf-logic.validations: A top level logic keyword will not be able to reference fieldset properties', schemaWithPropertyThatDoesNotExistInThatLevelButDoesInFieldset, '[json-schema-form] json-logic error: rule "validation_parent" has no variable "child".', ], + [ + 'x-jsf-logic.validations: error if unknown operation', + schemaWithBadOperation, + '[json-schema-form] json-logic error: in "badOperator" rule there is an unknown operator "++".', + ], ]; test.each(cases)('%p', (_, schema, expectedErrorString) => { From 981340053403980a16a743af91e8ad2504b268a1 Mon Sep 17 00:00:00 2001 From: brennj Date: Tue, 5 Sep 2023 13:52:52 +0200 Subject: [PATCH 29/48] chore: fix bad naming --- src/tests/jsonLogic.fixtures.js | 6 +++--- src/tests/jsonLogic.test.js | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/tests/jsonLogic.fixtures.js b/src/tests/jsonLogic.fixtures.js index cea1b431f..1d8b16cd0 100644 --- a/src/tests/jsonLogic.fixtures.js +++ b/src/tests/jsonLogic.fixtures.js @@ -343,8 +343,8 @@ export const schemaWithDeepVarThatDoesNotExist = { }, 'x-jsf-logic': { validations: { - a_greater_than_ten: { - errorMessage: 'Must be greater than 10', + dummy_rule: { + errorMessage: 'Random stuff to illustrate a deeply nested rule.', rule: { '>': [{ var: 'field_a' }, { '*': [2, { '/': [2, { '*': [1, { var: 'field_b' }] }] }] }], }, @@ -365,7 +365,7 @@ export const schemaWithDeepVarThatDoesNotExistOnFieldset = { }, 'x-jsf-logic': { validations: { - a_greater_than_ten: { + dummy_rule: { errorMessage: 'Must be greater than 10', rule: { '>': [{ var: 'child' }, { '*': [2, { '/': [2, { '*': [1, { var: 'field_a' }] }] }] }], diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index 670097d1a..a3ef30cff 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -147,12 +147,12 @@ describe('jsonLogic: cross-values validations', () => { [ 'x-jsf-logic.validations: error if a field does not exist in a deeply nested rule', schemaWithDeepVarThatDoesNotExist, - '[json-schema-form] json-logic error: rule "a_greater_than_ten" has no variable "field_b".', + '[json-schema-form] json-logic error: rule "dummy_rule" has no variable "field_b".', ], [ 'x-jsf-logic.validations: error if rule does not exist on a fieldset property', schemaWithDeepVarThatDoesNotExistOnFieldset, - '[json-schema-form] json-logic error: rule "a_greater_than_ten" has no variable "field_a".', + '[json-schema-form] json-logic error: rule "dummy_rule" has no variable "field_a".', ], [ 'x-jsf-validations: error if a validation name does not exist', From 2d6eac70e1f7274b592558cb8ff66bbfc5c28e00 Mon Sep 17 00:00:00 2001 From: brennj Date: Tue, 5 Sep 2023 14:28:37 +0200 Subject: [PATCH 30/48] chore: matching after merging --- src/jsonLogic.js | 8 ++- src/tests/jsonLogic.fixtures.js | 111 ++++++++++++++++++++++++++++++++ src/tests/jsonLogic.test.js | 69 ++++++++++++++++++++ 3 files changed, 186 insertions(+), 2 deletions(-) diff --git a/src/jsonLogic.js b/src/jsonLogic.js index 9e76bdbee..7cb04a659 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -173,7 +173,7 @@ function replaceHandlebarsTemplates({ } const computedTemplateValue = Object.entries(rules).reduce((prev, [key, rule]) => { - const computedValue = logic.getScope(parentID).evaluateValidation(rule, formValues); + const computedValue = logic.getScope(parentID).validate(rule, formValues); return prev.replaceAll(`{{${key}}}`, computedValue); }, value); @@ -253,8 +253,12 @@ function handleComputedAttribute(logic, formValues, parentID, name) { ]; } case 'const': - default: + default: { + if (typeof value === 'object' && value.rule) { + return [key, logic.getScope(parentID).validate(value.rule, formValues)]; + } return [key, logic.getScope(parentID).applyComputedValueInField(value, formValues, name)]; + } } }; } diff --git a/src/tests/jsonLogic.fixtures.js b/src/tests/jsonLogic.fixtures.js index 1d8b16cd0..de50a9ef5 100644 --- a/src/tests/jsonLogic.fixtures.js +++ b/src/tests/jsonLogic.fixtures.js @@ -439,3 +439,114 @@ export const schemaWithBadOperation = { }, }, }; + +export const schemaWithInlineRuleForComputedAttributeWithCopy = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: { + value: 'I need this to work using the {{rule}}.', + rule: { + '+': [{ var: 'field_a' }, 10], + }, + }, + }, + }, + }, +}; + +export const schemaWithInlineMultipleRulesForComputedAttributes = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + description: { + value: 'Must be between {{half_a}} and {{double_a}}.', + half_a: { + '/': [{ var: 'field_a' }, 2], + }, + double_a: { + '*': [{ var: 'field_a' }, 2], + }, + }, + }, + }, + }, +}; + +export const schemaSelfContainedValueForTitleWithNoTemplate = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: { + value: '{{rule}}', + rule: { + '+': [{ var: 'field_a' }, 10], + }, + }, + }, + }, + }, +}; + +export const schemaSelfContainedValueForMaximumMinimumValues = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + maximum: { + rule: { + '+': [{ var: 'field_a' }, 10], + }, + }, + minimum: { + rule: { + '-': [{ var: 'field_a' }, 10], + }, + }, + }, + }, + }, +}; + +export const schemaWithJSFLogicAndInlineRule = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: { + value: 'Going to use {{rule}} and {{not_inline}}', + rule: { + '+': [{ var: 'field_a' }, 10], + }, + }, + }, + }, + }, + 'x-jsf-logic': { + computedValues: { + not_inline: { + rule: { + '+': [1, 3], + }, + }, + }, + }, +}; diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index a3ef30cff..6809924b3 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -4,6 +4,8 @@ import { createSchemaWithRulesOnFieldA, createSchemaWithThreePropertiesWithRuleOnFieldA, multiRuleSchema, + schemaSelfContainedValueForMaximumMinimumValues, + schemaSelfContainedValueForTitleWithNoTemplate, schemaWithBadOperation, schemaWithComputedAttributeThatDoesntExist, schemaWithComputedAttributeThatDoesntExistDescription, @@ -12,7 +14,10 @@ import { schemaWithComputedAttributesAndErrorMessages, schemaWithDeepVarThatDoesNotExist, schemaWithDeepVarThatDoesNotExistOnFieldset, + schemaWithInlineMultipleRulesForComputedAttributes, + schemaWithInlineRuleForComputedAttributeWithCopy, schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar, + schemaWithJSFLogicAndInlineRule, schemaWithMissingComputedValue, schemaWithMissingRule, schemaWithNativeAndJSONLogicChecks, @@ -344,5 +349,69 @@ describe('jsonLogic: cross-values validations', () => { expect(fieldB.maximum).toEqual(8); expect(fieldB.statement).toEqual({ description: 'Must be bigger than 4 and smaller than 8' }); }); + + it('Use a inline-rule in a schema for a title attribute', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaWithInlineRuleForComputedAttributeWithCopy, + { + strictInputType: false, + } + ); + const [, fieldB] = fields; + expect(handleValidation({ field_a: 0, field_b: null }).formErrors).toEqual(undefined); + expect(fieldB.label).toEqual('I need this to work using the 10.'); + expect(handleValidation({ field_a: 10 }).formErrors).toEqual(undefined); + expect(fieldB.label).toEqual('I need this to work using the 20.'); + }); + }); + + it('Use multiple inline rules with different identifiers', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaWithInlineMultipleRulesForComputedAttributes, + { + strictInputType: false, + } + ); + const [, fieldB] = fields; + expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toEqual(undefined); + expect(fieldB.description).toEqual('Must be between 5 and 20.'); + }); + + it('Use a self contained rule in a schema for a title but it just uses the value', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaSelfContainedValueForTitleWithNoTemplate, + { + strictInputType: false, + } + ); + const [, fieldB] = fields; + expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toEqual(undefined); + expect(fieldB.label).toEqual('20'); + }); + + it('Use a self contained rule for a minimum, maximum value', () => { + const { handleValidation } = createHeadlessForm( + schemaSelfContainedValueForMaximumMinimumValues, + { + strictInputType: false, + } + ); + expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toEqual(undefined); + expect(handleValidation({ field_a: 50, field_b: 20 }).formErrors).toEqual({ + field_b: 'Must be greater or equal to 40', + }); + expect(handleValidation({ field_a: 50, field_b: 70 }).formErrors).toEqual({ + field_b: 'Must be smaller or equal to 60', + }); + expect(handleValidation({ field_a: 50, field_b: 50 }).formErrors).toEqual(undefined); + }); + + it('Mix use of multiple inline rules and an external rule', () => { + const { fields, handleValidation } = createHeadlessForm(schemaWithJSFLogicAndInlineRule, { + strictInputType: false, + }); + handleValidation({ field_a: 10 }); + const [, fieldB] = fields; + expect(fieldB.label).toEqual('Going to use 20 and 4'); }); }); From 63e94240e861f297ce3252dbed914a2af7be728f Mon Sep 17 00:00:00 2001 From: brennj Date: Tue, 5 Sep 2023 14:38:46 +0200 Subject: [PATCH 31/48] chore: test field to be explicit in test --- src/tests/jsonLogic.fixtures.js | 1 + src/tests/jsonLogic.test.js | 12 +++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/tests/jsonLogic.fixtures.js b/src/tests/jsonLogic.fixtures.js index de50a9ef5..38735d3c9 100644 --- a/src/tests/jsonLogic.fixtures.js +++ b/src/tests/jsonLogic.fixtures.js @@ -504,6 +504,7 @@ export const schemaSelfContainedValueForMaximumMinimumValues = { properties: { field_a: { type: 'number', + default: 0, }, field_b: { type: 'number', diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index 6809924b3..e43fc1348 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -390,20 +390,26 @@ describe('jsonLogic: cross-values validations', () => { }); it('Use a self contained rule for a minimum, maximum value', () => { - const { handleValidation } = createHeadlessForm( + const { fields, handleValidation } = createHeadlessForm( schemaSelfContainedValueForMaximumMinimumValues, { strictInputType: false, } ); - expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toEqual(undefined); + const [, fieldB] = fields; + expect(fieldB).toMatchObject({ minimum: -10, maximum: 10 }); + expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toBeUndefined(); + expect(fieldB).toMatchObject({ minimum: 0, maximum: 20 }); expect(handleValidation({ field_a: 50, field_b: 20 }).formErrors).toEqual({ field_b: 'Must be greater or equal to 40', }); + expect(fieldB).toMatchObject({ minimum: 40, maximum: 60 }); expect(handleValidation({ field_a: 50, field_b: 70 }).formErrors).toEqual({ field_b: 'Must be smaller or equal to 60', }); - expect(handleValidation({ field_a: 50, field_b: 50 }).formErrors).toEqual(undefined); + expect(fieldB).toMatchObject({ minimum: 40, maximum: 60 }); + expect(handleValidation({ field_a: 50, field_b: 50 }).formErrors).toBeUndefined(); + expect(fieldB).toMatchObject({ minimum: 40, maximum: 60 }); }); it('Mix use of multiple inline rules and an external rule', () => { From deb9d6c95f7102f970e47eed871de1b5373737a6 Mon Sep 17 00:00:00 2001 From: brennj Date: Tue, 5 Sep 2023 14:40:14 +0200 Subject: [PATCH 32/48] chore: remove unused schema --- src/tests/jsonLogic.fixtures.js | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/tests/jsonLogic.fixtures.js b/src/tests/jsonLogic.fixtures.js index 38735d3c9..ab64b82be 100644 --- a/src/tests/jsonLogic.fixtures.js +++ b/src/tests/jsonLogic.fixtures.js @@ -227,24 +227,6 @@ export const schemaWithComputedAttributes = { }, }; -export const schemaWithInlineRuleForComputedAttributeWithoutCopy = { - properties: { - field_a: { - type: 'number', - }, - field_b: { - type: 'number', - 'x-jsf-logic-computedAttrs': { - title: { - rule: { - '+': [{ var: 'field_a' }, 10], - }, - }, - }, - }, - }, -}; - export const schemaWithComputedAttributeThatDoesntExist = { properties: { field_a: { From 6f14ac4c1e8b7b9d0031629263e09354b2c30ad2 Mon Sep 17 00:00:00 2001 From: brennj Date: Tue, 5 Sep 2023 14:42:00 +0200 Subject: [PATCH 33/48] chore: fix bad var name --- src/tests/jsonLogic.fixtures.js | 2 +- src/tests/jsonLogic.test.js | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/tests/jsonLogic.fixtures.js b/src/tests/jsonLogic.fixtures.js index ab64b82be..4f1f29976 100644 --- a/src/tests/jsonLogic.fixtures.js +++ b/src/tests/jsonLogic.fixtures.js @@ -463,7 +463,7 @@ export const schemaWithInlineMultipleRulesForComputedAttributes = { }, }; -export const schemaSelfContainedValueForTitleWithNoTemplate = { +export const schemaSelfContainedValueForTitle = { properties: { field_a: { type: 'number', diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index e43fc1348..414b5e366 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -5,7 +5,7 @@ import { createSchemaWithThreePropertiesWithRuleOnFieldA, multiRuleSchema, schemaSelfContainedValueForMaximumMinimumValues, - schemaSelfContainedValueForTitleWithNoTemplate, + schemaSelfContainedValueForTitle, schemaWithBadOperation, schemaWithComputedAttributeThatDoesntExist, schemaWithComputedAttributeThatDoesntExistDescription, @@ -378,12 +378,9 @@ describe('jsonLogic: cross-values validations', () => { }); it('Use a self contained rule in a schema for a title but it just uses the value', () => { - const { fields, handleValidation } = createHeadlessForm( - schemaSelfContainedValueForTitleWithNoTemplate, - { - strictInputType: false, - } - ); + const { fields, handleValidation } = createHeadlessForm(schemaSelfContainedValueForTitle, { + strictInputType: false, + }); const [, fieldB] = fields; expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toEqual(undefined); expect(fieldB.label).toEqual('20'); From 2beb330e4452274256cb7df98e547817a97a2dc4 Mon Sep 17 00:00:00 2001 From: brennj Date: Wed, 6 Sep 2023 14:56:42 +0200 Subject: [PATCH 34/48] chore: restore code after merge conflicts --- src/calculateConditionalProperties.js | 39 ++- src/checkIfConditionMatches.js | 27 +- src/createHeadlessForm.js | 16 +- src/helpers.js | 21 +- src/jsonLogic.js | 63 ++++ src/tests/jsonLogic.fixtures.js | 421 ++++++++++++++++++++++++++ src/tests/jsonLogic.test.js | 226 +++++++++++--- 7 files changed, 753 insertions(+), 60 deletions(-) diff --git a/src/calculateConditionalProperties.js b/src/calculateConditionalProperties.js index 385ca3484..00220286e 100644 --- a/src/calculateConditionalProperties.js +++ b/src/calculateConditionalProperties.js @@ -4,6 +4,7 @@ import omit from 'lodash/omit'; import { extractParametersFromNode } from './helpers'; import { supportedTypes } from './internals/fields'; import { getFieldDescription, pickXKey } from './internals/helpers'; +import { calculateComputedAttributes } from './jsonLogic'; import { buildYupSchema } from './yupSchema'; /** * @typedef {import('./createHeadlessForm').FieldParameters} FieldParameters @@ -69,14 +70,14 @@ function rebuildFieldset(fields, property) { * @param {FieldParameters} fieldParams - field parameters * @returns {Function} */ -export function calculateConditionalProperties(fieldParams, customProperties) { +export function calculateConditionalProperties(fieldParams, customProperties, logic, config) { /** * Runs dynamic property calculation on a field based on a conditional that has been calculated * @param {Boolean} isRequired - if the field is required * @param {Object} conditionBranch - condition branch being applied * @returns {Object} updated field parameters */ - return (isRequired, conditionBranch) => { + return (isRequired, conditionBranch, __, _, formValues) => { // Check if the current field is conditionally declared in the schema const conditionalProperty = conditionBranch?.properties?.[fieldParams.name]; @@ -98,17 +99,37 @@ export function calculateConditionalProperties(fieldParams, customProperties) { newFieldParams.fields = fieldSetFields; } + const { computedAttributes, ...restNewFieldParams } = newFieldParams; + const calculatedComputedAttributes = computedAttributes + ? calculateComputedAttributes(newFieldParams, config)({ logic, formValues }) + : {}; + + const requiredValidations = [ + ...(fieldParams.requiredValidations ?? []), + ...(restNewFieldParams.requiredValidations ?? []), + ]; + const base = { isVisible: true, required: isRequired, ...(presentation?.inputType && { type: presentation.inputType }), - schema: buildYupSchema({ - ...fieldParams, - ...newFieldParams, - // If there are inner fields (case of fieldset) they need to be updated based on the condition - fields: fieldSetFields, - required: isRequired, - }), + ...calculatedComputedAttributes, + ...(calculatedComputedAttributes.value + ? { value: calculatedComputedAttributes.value } + : { value: undefined }), + schema: buildYupSchema( + { + ...fieldParams, + ...restNewFieldParams, + ...calculatedComputedAttributes, + requiredValidations, + // If there are inner fields (case of fieldset) they need to be updated based on the condition + fields: fieldSetFields, + required: isRequired, + }, + config, + logic + ), }; return omit(merge(base, presentation, newFieldParams), ['inputType']); diff --git a/src/checkIfConditionMatches.js b/src/checkIfConditionMatches.js index 6108b60a8..ab99ed57a 100644 --- a/src/checkIfConditionMatches.js +++ b/src/checkIfConditionMatches.js @@ -7,8 +7,8 @@ import { hasProperty } from './utils'; * @param {Object} formValues - form state * @returns {Boolean} */ -export function checkIfConditionMatches(node, formValues, formFields) { - return Object.keys(node.if.properties).every((name) => { +export function checkIfConditionMatches(node, formValues, formFields, logic) { + return Object.keys(node.if.properties ?? {}).every((name) => { const currentProperty = node.if.properties[name]; const value = formValues[name]; const hasEmptyValue = @@ -50,7 +50,8 @@ export function checkIfConditionMatches(node, formValues, formFields) { return checkIfConditionMatches( { if: currentProperty }, formValues[name], - getField(name, formFields).fields + getField(name, formFields).fields, + logic ); } @@ -68,3 +69,23 @@ export function checkIfConditionMatches(node, formValues, formFields) { ); }); } + +export function checkIfMatchesValidationsAndComputedValues(node, formValues, logic, parentID) { + const validationsMatch = Object.entries(node.if.validations ?? {}).every(([name, property]) => { + const currentValue = logic.getScope(parentID).applyValidationRuleInCondition(name, formValues); + if (Object.hasOwn(property, 'const') && currentValue === property.const) return true; + return false; + }); + + const computedValuesMatch = Object.entries(node.if.computedValues ?? {}).every( + ([name, property]) => { + const currentValue = logic + .getScope(parentID) + .applyComputedValueRuleInCondition(name, formValues); + if (Object.hasOwn(property, 'const') && currentValue === property.const) return true; + return false; + } + ); + + return computedValuesMatch && validationsMatch; +} diff --git a/src/createHeadlessForm.js b/src/createHeadlessForm.js index d97643085..901eaae14 100644 --- a/src/createHeadlessForm.js +++ b/src/createHeadlessForm.js @@ -100,7 +100,7 @@ function removeInvalidAttributes(fields) { * * @returns {FieldParameters} */ -function buildFieldParameters(name, fieldProperties, required = [], config = {}) { +function buildFieldParameters(name, fieldProperties, required = [], config = {}, logic) { const { position } = pickXKey(fieldProperties, 'presentation') ?? {}; let fields; @@ -108,9 +108,14 @@ function buildFieldParameters(name, fieldProperties, required = [], config = {}) if (inputType === supportedTypes.FIELDSET) { // eslint-disable-next-line no-use-before-define - fields = getFieldsFromJSONSchema(fieldProperties, { - customProperties: get(config, `customProperties.${name}`, {}), - }); + fields = getFieldsFromJSONSchema( + fieldProperties, + { + customProperties: get(config, `customProperties.${name}`, {}), + parentID: name, + }, + logic + ); } const result = { @@ -235,7 +240,8 @@ function buildField(fieldParams, config, scopedJsonSchema, logic) { const yupSchema = buildYupSchema(fieldParams, config, logic); const calculateConditionalFieldsClosure = - fieldParams.isDynamic && calculateConditionalProperties(fieldParams, customProperties); + fieldParams.isDynamic && + calculateConditionalProperties(fieldParams, customProperties, logic, config); const calculateCustomValidationPropertiesClosure = calculateCustomValidationProperties( fieldParams, diff --git a/src/helpers.js b/src/helpers.js index e01a1c99d..5349f1ae4 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -8,6 +8,7 @@ import { lazy } from 'yup'; import { checkIfConditionMatches } from './checkIfConditionMatches'; import { supportedTypes, getInputType } from './internals/fields'; import { pickXKey } from './internals/helpers'; +import { processJSONLogicNode } from './jsonLogic'; import { containsHTML, hasProperty, wrapWithSpan } from './utils'; import { buildCompleteYupSchema, buildYupSchema } from './yupSchema'; @@ -240,7 +241,13 @@ function updateField(field, requiredFields, node, formValues, logic, config) { // If field has a calculateConditionalProperties closure, run it and update the field properties if (field.calculateConditionalProperties) { - const newFieldValues = field.calculateConditionalProperties(fieldIsRequired, node); + const newFieldValues = field.calculateConditionalProperties( + fieldIsRequired, + node, + logic, + config, + formValues + ); updateValues(newFieldValues); } @@ -368,6 +375,18 @@ export function processNode({ }); } + if (node['x-jsf-logic']) { + const { required: requiredFromLogic } = processJSONLogicNode({ + node: node['x-jsf-logic'], + formValues, + formFields, + accRequired: requiredFields, + parentID, + logic, + }); + requiredFromLogic.forEach((field) => requiredFields.add(field)); + } + return { required: requiredFields, }; diff --git a/src/jsonLogic.js b/src/jsonLogic.js index 7cb04a659..7845d6fdb 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -1,5 +1,10 @@ import jsonLogic from 'json-logic-js'; +import { + checkIfConditionMatches, + checkIfMatchesValidationsAndComputedValues, +} from './checkIfConditionMatches'; +import { processNode } from './helpers'; import { buildYupSchema } from './yupSchema'; /** @@ -393,3 +398,61 @@ function removeIndicesFromPath(path) { const intermediatePath = path.replace(/\.\d+\./g, '.'); return intermediatePath.replace(/\.\d+$/, ''); } + +export function processJSONLogicNode({ + node, + formFields, + formValues, + accRequired, + parentID, + logic, +}) { + const requiredFields = new Set(accRequired); + + if (node.allOf) { + node.allOf + .map((allOfNode) => + processJSONLogicNode({ node: allOfNode, formValues, formFields, logic, parentID }) + ) + .forEach(({ required: allOfItemRequired }) => { + allOfItemRequired.forEach(requiredFields.add, requiredFields); + }); + } + + if (node.if) { + const matchesPropertyCondition = checkIfConditionMatches(node, formValues, formFields, logic); + const matchesValidationsAndComputedValues = checkIfMatchesValidationsAndComputedValues( + node, + formValues, + logic, + parentID + ); + + const isConditionMatch = matchesPropertyCondition && matchesValidationsAndComputedValues; + + if (isConditionMatch && node.then) { + const { required: branchRequired } = processNode({ + node: node.then, + formValues, + formFields, + accRequired, + logic, + parentID, + }); + branchRequired.forEach((field) => requiredFields.add(field)); + } + if (!isConditionMatch && node.else) { + const { required: branchRequired } = processNode({ + node: node.else, + formValues, + formFields, + accRequired: requiredFields, + parentID, + logic, + }); + branchRequired.forEach((field) => requiredFields.add(field)); + } + } + + return { required: requiredFields }; +} diff --git a/src/tests/jsonLogic.fixtures.js b/src/tests/jsonLogic.fixtures.js index 4f1f29976..5ede3e9ec 100644 --- a/src/tests/jsonLogic.fixtures.js +++ b/src/tests/jsonLogic.fixtures.js @@ -533,3 +533,424 @@ export const schemaWithJSFLogicAndInlineRule = { }, }, }; + +export const schemaWithGreaterThanChecksForThreeFields = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + }, + field_c: { + type: 'number', + }, + }, + required: ['field_a', 'field_b'], + 'x-jsf-logic': { + validations: { + require_c: { + rule: { + and: [ + { '!==': [{ var: 'field_b' }, null] }, + { '!==': [{ var: 'field_a' }, null] }, + { '>': [{ var: 'field_a' }, { var: 'field_b' }] }, + ], + }, + }, + }, + allOf: [ + { + if: { + validations: { + require_c: { + const: true, + }, + }, + }, + then: { + required: ['field_c'], + }, + else: { + properties: { + field_c: false, + }, + }, + }, + ], + }, +}; + +export const schemaWithPropertiesCheckAndValidationsInAIf = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + }, + field_c: { + type: 'number', + }, + }, + required: ['field_a', 'field_b'], + 'x-jsf-logic': { + validations: { + require_c: { + rule: { + and: [ + { '!==': [{ var: 'field_b' }, null] }, + { '!==': [{ var: 'field_a' }, null] }, + { '>': [{ var: 'field_a' }, { var: 'field_b' }] }, + ], + }, + }, + }, + allOf: [ + { + if: { + validations: { + require_c: { + const: true, + }, + }, + properties: { + field_a: { + const: 10, + }, + }, + }, + then: { + required: ['field_c'], + }, + else: { + properties: { + field_c: false, + }, + }, + }, + ], + }, +}; + +export const schemaWithChecksAndThenValidationsOnThen = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + }, + field_c: { + type: 'number', + }, + }, + required: ['field_a', 'field_b'], + 'x-jsf-logic': { + validations: { + c_must_be_large: { + errorMessage: 'Needs more numbers', + rule: { + '>': [{ var: 'field_c' }, 200], + }, + }, + require_c: { + rule: { + and: [ + { '!==': [{ var: 'field_b' }, null] }, + { '!==': [{ var: 'field_a' }, null] }, + { '>': [{ var: 'field_a' }, { var: 'field_b' }] }, + ], + }, + }, + }, + allOf: [ + { + if: { + validations: { + require_c: { + const: true, + }, + }, + }, + then: { + required: ['field_c'], + properties: { + field_c: { + description: 'I am a description!', + 'x-jsf-logic-validations': ['c_must_be_large'], + }, + }, + }, + else: { + properties: { + field_c: false, + }, + }, + }, + ], + }, +}; + +export const schemaWithComputedValueChecksInIf = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + }, + field_c: { + type: 'number', + }, + }, + required: ['field_a', 'field_b'], + 'x-jsf-logic': { + computedValues: { + require_c: { + rule: { + and: [ + { '!==': [{ var: 'field_b' }, null] }, + { '!==': [{ var: 'field_a' }, null] }, + { '>': [{ var: 'field_a' }, { var: 'field_b' }] }, + ], + }, + }, + }, + allOf: [ + { + if: { + computedValues: { + require_c: { + const: true, + }, + }, + }, + then: { + required: ['field_c'], + }, + else: { + properties: { + field_c: false, + }, + }, + }, + ], + }, +}; + +export const schemaWithMultipleComputedValueChecks = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + }, + field_c: { + type: 'number', + }, + }, + required: ['field_a', 'field_b'], + 'x-jsf-logic': { + validations: { + double_b: { + errorMessage: 'Must be two times B', + rule: { + '>': [{ var: 'field_c' }, { '*': [{ var: 'field_b' }, 2] }], + }, + }, + }, + computedValues: { + a_times_two: { + rule: { + '*': [{ var: 'field_a' }, 2], + }, + }, + mod_by_five: { + rule: { + '%': [{ var: 'field_b' }, 5], + }, + }, + }, + allOf: [ + { + if: { + computedValues: { + a_times_two: { + const: 20, + }, + mod_by_five: { + const: 3, + }, + }, + }, + then: { + required: ['field_c'], + properties: { + field_c: { + 'x-jsf-logic-validations': ['double_b'], + title: 'Adding a title.', + }, + }, + }, + else: { + properties: { + field_c: false, + }, + }, + }, + ], + }, +}; + +export const schemaWithIfStatementWithComputedValuesAndValidationChecks = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + }, + field_c: { + type: 'number', + }, + }, + required: ['field_a', 'field_b'], + 'x-jsf-logic': { + validations: { + greater_than_b: { + rule: { + '>': [{ var: 'field_a' }, { var: 'field_b' }], + }, + }, + }, + computedValues: { + a_times_two: { + rule: { + '*': [{ var: 'field_a' }, 2], + }, + }, + }, + allOf: [ + { + if: { + computedValues: { + a_times_two: { + const: 20, + }, + }, + validations: { + greater_than_b: { + const: true, + }, + }, + }, + then: { + required: ['field_c'], + }, + else: { + properties: { + field_c: false, + }, + }, + }, + ], + }, +}; + +export const schemaWhereValidationAndComputedValueIsAppliedOnNormalThenStatement = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + }, + }, + 'x-jsf-logic': { + computedValues: { + a_plus_ten: { + rule: { + '+': [{ var: 'field_a' }, 10], + }, + }, + }, + validations: { + greater_than_a_plus_ten: { + errorMessage: 'Must be greater than Field A + 10', + rule: { + '>': [{ var: 'field_b' }, { '+': [{ var: 'field_a' }, 10] }], + }, + }, + }, + }, + allOf: [ + { + if: { + properties: { + field_a: { + const: 20, + }, + }, + }, + then: { + properties: { + field_b: { + 'x-jsf-logic-computedAttrs': { + title: 'Must be greater than {{a_plus_ten}}.', + }, + 'x-jsf-logic-validations': ['greater_than_a_plus_ten'], + }, + }, + }, + }, + ], +}; + +export const schemaWithTwoValidationsWhereOneOfThemIsAppliedConditionally = { + required: ['field_a', 'field_b'], + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-validations': ['greater_than_field_a'], + }, + }, + 'x-jsf-logic': { + validations: { + greater_than_field_a: { + errorMessage: 'Must be greater than A', + rule: { + '>': [{ var: 'field_b' }, { var: 'field_a' }], + }, + }, + greater_than_two_times_a: { + errorMessage: 'Must be greater than two times A', + rule: { + '>': [{ var: 'field_b' }, { '*': [{ var: 'field_a' }, 2] }], + }, + }, + }, + }, + allOf: [ + { + if: { + properties: { + field_a: { + const: 20, + }, + }, + }, + then: { + properties: { + field_b: { + 'x-jsf-logic-validations': ['greater_than_two_times_a'], + }, + }, + }, + }, + ], +}; diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index 414b5e366..a6eaab418 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -6,24 +6,32 @@ import { multiRuleSchema, schemaSelfContainedValueForMaximumMinimumValues, schemaSelfContainedValueForTitle, + schemaWhereValidationAndComputedValueIsAppliedOnNormalThenStatement, schemaWithBadOperation, + schemaWithChecksAndThenValidationsOnThen, schemaWithComputedAttributeThatDoesntExist, schemaWithComputedAttributeThatDoesntExistDescription, schemaWithComputedAttributeThatDoesntExistTitle, schemaWithComputedAttributes, schemaWithComputedAttributesAndErrorMessages, + schemaWithComputedValueChecksInIf, schemaWithDeepVarThatDoesNotExist, schemaWithDeepVarThatDoesNotExistOnFieldset, + schemaWithGreaterThanChecksForThreeFields, + schemaWithIfStatementWithComputedValuesAndValidationChecks, schemaWithInlineMultipleRulesForComputedAttributes, schemaWithInlineRuleForComputedAttributeWithCopy, schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar, schemaWithJSFLogicAndInlineRule, schemaWithMissingComputedValue, schemaWithMissingRule, + schemaWithMultipleComputedValueChecks, schemaWithNativeAndJSONLogicChecks, schemaWithNonRequiredField, + schemaWithPropertiesCheckAndValidationsInAIf, schemaWithPropertyThatDoesNotExistInThatLevelButDoesInFieldset, schemaWithTwoRules, + schemaWithTwoValidationsWhereOneOfThemIsAppliedConditionally, schemaWithUnknownVariableInComputedValues, schemaWithUnknownVariableInValidations, schemaWithValidationThatDoesNotExistOnProperty, @@ -363,58 +371,192 @@ describe('jsonLogic: cross-values validations', () => { expect(handleValidation({ field_a: 10 }).formErrors).toEqual(undefined); expect(fieldB.label).toEqual('I need this to work using the 20.'); }); - }); - it('Use multiple inline rules with different identifiers', () => { - const { fields, handleValidation } = createHeadlessForm( - schemaWithInlineMultipleRulesForComputedAttributes, - { + it('Use multiple inline rules with different identifiers', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaWithInlineMultipleRulesForComputedAttributes, + { + strictInputType: false, + } + ); + const [, fieldB] = fields; + expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toEqual(undefined); + expect(fieldB.description).toEqual('Must be between 5 and 20.'); + }); + + it('Use a self contained rule in a schema for a title but it just uses the value', () => { + const { fields, handleValidation } = createHeadlessForm(schemaSelfContainedValueForTitle, { strictInputType: false, - } - ); - const [, fieldB] = fields; - expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toEqual(undefined); - expect(fieldB.description).toEqual('Must be between 5 and 20.'); - }); + }); + const [, fieldB] = fields; + expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toEqual(undefined); + expect(fieldB.label).toEqual('20'); + }); + + it('Use a self contained rule for a minimum, maximum value', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaSelfContainedValueForMaximumMinimumValues, + { + strictInputType: false, + } + ); + const [, fieldB] = fields; + expect(fieldB).toMatchObject({ minimum: -10, maximum: 10 }); + expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toBeUndefined(); + expect(fieldB).toMatchObject({ minimum: 0, maximum: 20 }); + expect(handleValidation({ field_a: 50, field_b: 20 }).formErrors).toEqual({ + field_b: 'Must be greater or equal to 40', + }); + expect(fieldB).toMatchObject({ minimum: 40, maximum: 60 }); + expect(handleValidation({ field_a: 50, field_b: 70 }).formErrors).toEqual({ + field_b: 'Must be smaller or equal to 60', + }); + expect(fieldB).toMatchObject({ minimum: 40, maximum: 60 }); + expect(handleValidation({ field_a: 50, field_b: 50 }).formErrors).toBeUndefined(); + expect(fieldB).toMatchObject({ minimum: 40, maximum: 60 }); + }); - it('Use a self contained rule in a schema for a title but it just uses the value', () => { - const { fields, handleValidation } = createHeadlessForm(schemaSelfContainedValueForTitle, { - strictInputType: false, + it('Mix use of multiple inline rules and an external rule', () => { + const { fields, handleValidation } = createHeadlessForm(schemaWithJSFLogicAndInlineRule, { + strictInputType: false, + }); + handleValidation({ field_a: 10 }); + const [, fieldB] = fields; + expect(fieldB.label).toEqual('Going to use 20 and 4'); }); - const [, fieldB] = fields; - expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toEqual(undefined); - expect(fieldB.label).toEqual('20'); }); - it('Use a self contained rule for a minimum, maximum value', () => { - const { fields, handleValidation } = createHeadlessForm( - schemaSelfContainedValueForMaximumMinimumValues, - { + describe('Conditionals', () => { + it('when field_a > field_b, show field_c', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaWithGreaterThanChecksForThreeFields, + { strictInputType: false } + ); + expect(fields.find((i) => i.name === 'field_c').isVisible).toEqual(false); + + expect(handleValidation({ field_a: 1, field_b: 3 }).formErrors).toEqual(undefined); + expect(handleValidation({ field_a: 1, field_b: null }).formErrors).toEqual({ + field_b: 'Required field', + }); + expect(handleValidation({ field_a: 10, field_b: 3 }).formErrors).toEqual({ + field_c: 'Required field', + }); + expect(handleValidation({ field_a: 10, field_b: 3, field_c: 0 }).formErrors).toEqual( + undefined + ); + }); + + it('A schema with both a `x-jsf-validations` and `properties` check', () => { + const { handleValidation } = createHeadlessForm( + schemaWithPropertiesCheckAndValidationsInAIf, + { strictInputType: false } + ); + expect(handleValidation({ field_a: 1, field_b: 3 }).formErrors).toEqual(undefined); + expect(handleValidation({ field_a: 10, field_b: 3 }).formErrors).toEqual({ + field_c: 'Required field', + }); + expect(handleValidation({ field_a: 5, field_b: 3 }).formErrors).toEqual(undefined); + }); + + it('Conditionally apply a validation on a property depending on values', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaWithChecksAndThenValidationsOnThen, + { strictInputType: false } + ); + const cField = fields.find((i) => i.name === 'field_c'); + expect(cField.isVisible).toEqual(false); + expect(cField.description).toEqual(undefined); + expect(handleValidation({ field_a: 10, field_b: 5 }).formErrors).toEqual({ + field_c: 'Required field', + }); + expect(handleValidation({ field_a: 10, field_b: 5, field_c: 0 }).formErrors).toEqual({ + field_c: 'Needs more numbers', + }); + expect(cField.description).toBe('I am a description!'); + expect(handleValidation({ field_a: 10, field_b: 5, field_c: 201 }).formErrors).toEqual( + undefined + ); + }); + + it('Should apply a conditional based on a true computedValue', () => { + const { fields, handleValidation } = createHeadlessForm(schemaWithComputedValueChecksInIf, { strictInputType: false, - } - ); - const [, fieldB] = fields; - expect(fieldB).toMatchObject({ minimum: -10, maximum: 10 }); - expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toBeUndefined(); - expect(fieldB).toMatchObject({ minimum: 0, maximum: 20 }); - expect(handleValidation({ field_a: 50, field_b: 20 }).formErrors).toEqual({ - field_b: 'Must be greater or equal to 40', + }); + const cField = fields.find((i) => i.name === 'field_c'); + expect(cField.isVisible).toEqual(false); + expect(cField.description).toEqual(undefined); + expect(handleValidation({ field_a: 10, field_b: 5 }).formErrors).toEqual({ + field_c: 'Required field', + }); + expect(handleValidation({ field_a: 10, field_b: 5, field_c: 201 }).formErrors).toEqual( + undefined + ); }); - expect(fieldB).toMatchObject({ minimum: 40, maximum: 60 }); - expect(handleValidation({ field_a: 50, field_b: 70 }).formErrors).toEqual({ - field_b: 'Must be smaller or equal to 60', + + it('Handle multiple computedValue checks by ANDing them together', () => { + const { handleValidation } = createHeadlessForm(schemaWithMultipleComputedValueChecks, { + strictInputType: false, + }); + expect(handleValidation({}).formErrors).toEqual({ + field_a: 'Required field', + field_b: 'Required field', + }); + expect(handleValidation({ field_a: 10, field_b: 8 }).formErrors).toEqual({ + field_c: 'Required field', + }); + expect(handleValidation({ field_a: 10, field_b: 8, field_c: 0 }).formErrors).toEqual({ + field_c: 'Must be two times B', + }); + expect(handleValidation({ field_a: 10, field_b: 8, field_c: 17 }).formErrors).toEqual( + undefined + ); }); - expect(fieldB).toMatchObject({ minimum: 40, maximum: 60 }); - expect(handleValidation({ field_a: 50, field_b: 50 }).formErrors).toBeUndefined(); - expect(fieldB).toMatchObject({ minimum: 40, maximum: 60 }); - }); - it('Mix use of multiple inline rules and an external rule', () => { - const { fields, handleValidation } = createHeadlessForm(schemaWithJSFLogicAndInlineRule, { - strictInputType: false, + it('Handle having a true condition with both validations and computedValue checks', () => { + const { handleValidation } = createHeadlessForm( + schemaWithIfStatementWithComputedValuesAndValidationChecks, + { strictInputType: false } + ); + expect(handleValidation({ field_a: 1, field_b: 1 }).formErrors).toEqual(undefined); + expect(handleValidation({ field_a: 10, field_b: 20 }).formErrors).toEqual(undefined); + expect(handleValidation({ field_a: 10, field_b: 9 }).formErrors).toEqual({ + field_c: 'Required field', + }); + expect(handleValidation({ field_a: 10, field_b: 9, field_c: 10 }).formErrors).toEqual( + undefined + ); + }); + + it('Apply validations and computed values on normal if statement.', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaWhereValidationAndComputedValueIsAppliedOnNormalThenStatement, + { strictInputType: false } + ); + expect(handleValidation({ field_a: 0, field_b: 0 }).formErrors).toEqual(undefined); + expect(handleValidation({ field_a: 20, field_b: 0 }).formErrors).toEqual({ + field_b: 'Must be greater than Field A + 10', + }); + const [, fieldB] = fields; + expect(fieldB.label).toEqual('Must be greater than 30.'); + expect(handleValidation({ field_a: 20, field_b: 31 }).formErrors).toEqual(undefined); + }); + + it('When we have a required validation on a top level property and another validation is added, both should be accounted for.', () => { + const { handleValidation } = createHeadlessForm( + schemaWithTwoValidationsWhereOneOfThemIsAppliedConditionally, + { strictInputType: false } + ); + expect(handleValidation({ field_a: 10, field_b: 0 }).formErrors).toEqual({ + field_b: 'Must be greater than A', + }); + expect(handleValidation({ field_a: 10, field_b: 20 }).formErrors).toEqual(undefined); + expect(handleValidation({ field_a: 20, field_b: 10 }).formErrors).toEqual({ + field_b: 'Must be greater than A', + }); + expect(handleValidation({ field_a: 20, field_b: 21 }).formErrors).toEqual({ + field_b: 'Must be greater than two times A', + }); + expect(handleValidation({ field_a: 20, field_b: 41 }).formErrors).toEqual(); }); - handleValidation({ field_a: 10 }); - const [, fieldB] = fields; - expect(fieldB.label).toEqual('Going to use 20 and 4'); }); }); From dbaba0f9bd1f3f40a74f7e78c531231d2139c4dd Mon Sep 17 00:00:00 2001 From: brennj Date: Wed, 6 Sep 2023 15:29:37 +0200 Subject: [PATCH 35/48] chore: fix jsdocs --- src/calculateConditionalProperties.js | 28 ++++++++++++++++++--------- src/createHeadlessForm.js | 2 +- src/helpers.js | 12 +++++------- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src/calculateConditionalProperties.js b/src/calculateConditionalProperties.js index 00220286e..dd3f618d9 100644 --- a/src/calculateConditionalProperties.js +++ b/src/calculateConditionalProperties.js @@ -65,19 +65,29 @@ function rebuildFieldset(fields, property) { } /** - * Builds a function that updates the fields properties based on the form values and the - * dependencies the field has on the current schema. - * @param {FieldParameters} fieldParams - field parameters - * @returns {Function} + * Builds a function that updates the field properties based on the form values, + * schema dependencies, and conditional logic. + * + * @param {Object} params - Parameters + * @param {Object} params.fieldParams - Current field parameters + * @param {Object} params.customProperties - Custom field properties from schema + * @param {Object} params.logic - JSON-logic + * @param {Object} params.config - Form configuration + * + * @returns {Function} A function that calculates conditional properties */ -export function calculateConditionalProperties(fieldParams, customProperties, logic, config) { +export function calculateConditionalProperties({ fieldParams, customProperties, logic, config }) { /** * Runs dynamic property calculation on a field based on a conditional that has been calculated - * @param {Boolean} isRequired - if the field is required - * @param {Object} conditionBranch - condition branch being applied - * @returns {Object} updated field parameters + * + * @param {Object} params - Parameters + * @param {Boolean} params.isRequired - If field is required + * @param {Object} params.conditionBranch - Condition branch + * @param {Object} params.formValues - Current form values + * + * @returns {Object} Updated field parameters */ - return (isRequired, conditionBranch, __, _, formValues) => { + return ({ isRequired, conditionBranch, formValues }) => { // Check if the current field is conditionally declared in the schema const conditionalProperty = conditionBranch?.properties?.[fieldParams.name]; diff --git a/src/createHeadlessForm.js b/src/createHeadlessForm.js index 901eaae14..d76183534 100644 --- a/src/createHeadlessForm.js +++ b/src/createHeadlessForm.js @@ -241,7 +241,7 @@ function buildField(fieldParams, config, scopedJsonSchema, logic) { const yupSchema = buildYupSchema(fieldParams, config, logic); const calculateConditionalFieldsClosure = fieldParams.isDynamic && - calculateConditionalProperties(fieldParams, customProperties, logic, config); + calculateConditionalProperties({ fieldParams, customProperties, logic, config }); const calculateCustomValidationPropertiesClosure = calculateCustomValidationProperties( fieldParams, diff --git a/src/helpers.js b/src/helpers.js index 5349f1ae4..355be689c 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -241,13 +241,11 @@ function updateField(field, requiredFields, node, formValues, logic, config) { // If field has a calculateConditionalProperties closure, run it and update the field properties if (field.calculateConditionalProperties) { - const newFieldValues = field.calculateConditionalProperties( - fieldIsRequired, - node, - logic, - config, - formValues - ); + const newFieldValues = field.calculateConditionalProperties({ + isRequired: fieldIsRequired, + conditionBranch: node, + formValues, + }); updateValues(newFieldValues); } From 83a258ee63015bdef5d34605cbd662b323d369da Mon Sep 17 00:00:00 2001 From: brennj Date: Wed, 6 Sep 2023 15:36:29 +0200 Subject: [PATCH 36/48] chore: rename requiredValidations -> jsonLogicValidations --- src/calculateConditionalProperties.js | 8 ++++---- src/helpers.js | 4 ++-- src/yupSchema.js | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/calculateConditionalProperties.js b/src/calculateConditionalProperties.js index dd3f618d9..5471c9876 100644 --- a/src/calculateConditionalProperties.js +++ b/src/calculateConditionalProperties.js @@ -114,9 +114,9 @@ export function calculateConditionalProperties({ fieldParams, customProperties, ? calculateComputedAttributes(newFieldParams, config)({ logic, formValues }) : {}; - const requiredValidations = [ - ...(fieldParams.requiredValidations ?? []), - ...(restNewFieldParams.requiredValidations ?? []), + const jsonLogicValidations = [ + ...(fieldParams.jsonLogicValidations ?? []), + ...(restNewFieldParams.jsonLogicValidations ?? []), ]; const base = { @@ -132,7 +132,7 @@ export function calculateConditionalProperties({ fieldParams, customProperties, ...fieldParams, ...restNewFieldParams, ...calculatedComputedAttributes, - requiredValidations, + jsonLogicValidations, // If there are inner fields (case of fieldset) they need to be updated based on the condition fields: fieldSetFields, required: isRequired, diff --git a/src/helpers.js b/src/helpers.js index 355be689c..dd2066db7 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -482,7 +482,7 @@ export function extractParametersFromNode(schemaNode) { const presentation = pickXKey(schemaNode, 'presentation') ?? {}; const errorMessage = pickXKey(schemaNode, 'errorMessage') ?? {}; - const requiredValidations = schemaNode['x-jsf-logic-validations']; + const jsonLogicValidations = schemaNode['x-jsf-logic-validations']; const computedAttributes = schemaNode['x-jsf-logic-computedAttrs']; // This is when a forced value is computed. @@ -532,7 +532,7 @@ export function extractParametersFromNode(schemaNode) { // Handle [name].presentation ...presentation, - requiredValidations, + jsonLogicValidations, computedAttributes: decoratedComputedAttributes, description: containsHTML(description) ? wrapWithSpan(description, { diff --git a/src/yupSchema.js b/src/yupSchema.js index c2659f36c..94f5e0aa0 100644 --- a/src/yupSchema.js +++ b/src/yupSchema.js @@ -407,8 +407,8 @@ export function buildYupSchema(field, config, logic) { validators.push(withConst); } - if (propertyFields.requiredValidations) { - propertyFields.requiredValidations.forEach((id) => + if (propertyFields.jsonLogicValidations) { + propertyFields.jsonLogicValidations.forEach((id) => validators.push(yupSchemaWithCustomJSONLogic({ field, id, logic, config })) ); } From b1e6772da317c19d11aae77b5d3b5db05bd1230a Mon Sep 17 00:00:00 2001 From: brennj Date: Wed, 6 Sep 2023 16:40:08 +0200 Subject: [PATCH 37/48] chore: rename checkIfConditionMatches -> checkIfConditionMatchesProperties --- src/checkIfConditionMatches.js | 4 ++-- src/helpers.js | 4 ++-- src/jsonLogic.js | 12 +++++++----- src/tests/checkIfConditionMatches.test.js | 12 ++++++------ 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/checkIfConditionMatches.js b/src/checkIfConditionMatches.js index ab99ed57a..cb7c50cbc 100644 --- a/src/checkIfConditionMatches.js +++ b/src/checkIfConditionMatches.js @@ -7,7 +7,7 @@ import { hasProperty } from './utils'; * @param {Object} formValues - form state * @returns {Boolean} */ -export function checkIfConditionMatches(node, formValues, formFields, logic) { +export function checkIfConditionMatchesProperties(node, formValues, formFields, logic) { return Object.keys(node.if.properties ?? {}).every((name) => { const currentProperty = node.if.properties[name]; const value = formValues[name]; @@ -47,7 +47,7 @@ export function checkIfConditionMatches(node, formValues, formFields, logic) { } if (currentProperty.properties) { - return checkIfConditionMatches( + return checkIfConditionMatchesProperties( { if: currentProperty }, formValues[name], getField(name, formFields).fields, diff --git a/src/helpers.js b/src/helpers.js index dd2066db7..b730f4489 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -5,7 +5,7 @@ import omitBy from 'lodash/omitBy'; import set from 'lodash/set'; import { lazy } from 'yup'; -import { checkIfConditionMatches } from './checkIfConditionMatches'; +import { checkIfConditionMatchesProperties } from './checkIfConditionMatches'; import { supportedTypes, getInputType } from './internals/fields'; import { pickXKey } from './internals/helpers'; import { processJSONLogicNode } from './jsonLogic'; @@ -299,7 +299,7 @@ export function processNode({ }); if (node.if) { - const matchesCondition = checkIfConditionMatches(node, formValues, formFields, logic); + const matchesCondition = checkIfConditionMatchesProperties(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) { diff --git a/src/jsonLogic.js b/src/jsonLogic.js index 7845d6fdb..9697cc248 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -1,7 +1,7 @@ import jsonLogic from 'json-logic-js'; import { - checkIfConditionMatches, + checkIfConditionMatchesProperties, checkIfMatchesValidationsAndComputedValues, } from './checkIfConditionMatches'; import { processNode } from './helpers'; @@ -420,13 +420,15 @@ export function processJSONLogicNode({ } if (node.if) { - const matchesPropertyCondition = checkIfConditionMatches(node, formValues, formFields, logic); - const matchesValidationsAndComputedValues = checkIfMatchesValidationsAndComputedValues( + const matchesPropertyCondition = checkIfConditionMatchesProperties( node, formValues, - logic, - parentID + formFields, + logic ); + const matchesValidationsAndComputedValues = + matchesPropertyCondition && + checkIfMatchesValidationsAndComputedValues(node, formValues, logic, parentID); const isConditionMatch = matchesPropertyCondition && matchesValidationsAndComputedValues; diff --git a/src/tests/checkIfConditionMatches.test.js b/src/tests/checkIfConditionMatches.test.js index 1859cfca8..02467a93f 100644 --- a/src/tests/checkIfConditionMatches.test.js +++ b/src/tests/checkIfConditionMatches.test.js @@ -1,12 +1,12 @@ -import { checkIfConditionMatches } from '../checkIfConditionMatches'; +import { checkIfConditionMatchesProperties } from '../checkIfConditionMatches'; it('Empty if is always going to be true', () => { - expect(checkIfConditionMatches({ if: { properties: {} } })).toBe(true); + expect(checkIfConditionMatchesProperties({ if: { properties: {} } })).toBe(true); }); it('Basic if check passes with correct value', () => { expect( - checkIfConditionMatches( + checkIfConditionMatchesProperties( { if: { properties: { a: { const: 'hello' } } } }, { a: 'hello', @@ -17,7 +17,7 @@ it('Basic if check passes with correct value', () => { it('Basic if check fails with incorrect value', () => { expect( - checkIfConditionMatches( + checkIfConditionMatchesProperties( { if: { properties: { a: { const: 'hello' } } } }, { a: 'goodbye', @@ -28,7 +28,7 @@ it('Basic if check fails with incorrect value', () => { it('Nested properties check passes with correct value', () => { expect( - checkIfConditionMatches( + checkIfConditionMatchesProperties( { if: { properties: { parent: { properties: { child: { const: 'hello from child' } } } } } }, { parent: { child: 'hello from child' }, @@ -40,7 +40,7 @@ it('Nested properties check passes with correct value', () => { it('Nested properties check passes with correct value', () => { expect( - checkIfConditionMatches( + checkIfConditionMatchesProperties( { if: { properties: { parent: { properties: { child: { const: 'hello from child' } } } } } }, { parent: { child: 'goodbye from child' }, From 6fbb9e1b334f9efaae652a1f92440f75b5e490aa Mon Sep 17 00:00:00 2001 From: brennj Date: Wed, 6 Sep 2023 16:45:50 +0200 Subject: [PATCH 38/48] chore: some small refactor --- src/jsonLogic.js | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/jsonLogic.js b/src/jsonLogic.js index 9697cc248..dbbba6e98 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -432,25 +432,21 @@ export function processJSONLogicNode({ const isConditionMatch = matchesPropertyCondition && matchesValidationsAndComputedValues; + let nextNode; if (isConditionMatch && node.then) { - const { required: branchRequired } = processNode({ - node: node.then, - formValues, - formFields, - accRequired, - logic, - parentID, - }); - branchRequired.forEach((field) => requiredFields.add(field)); + nextNode = node.then; } if (!isConditionMatch && node.else) { + nextNode = node.else; + } + if (nextNode) { const { required: branchRequired } = processNode({ - node: node.else, + node: nextNode, formValues, formFields, - accRequired: requiredFields, - parentID, + accRequired, logic, + parentID, }); branchRequired.forEach((field) => requiredFields.add(field)); } From 3fd0092385a4d3079cd72d96f054362c9625d3fa Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 7 Sep 2023 10:45:56 +0200 Subject: [PATCH 39/48] chore: can remove need for nulls --- src/jsonLogic.js | 7 +++++-- src/tests/jsonLogic.fixtures.js | 24 ++++-------------------- src/tests/jsonLogic.test.js | 5 ++++- 3 files changed, 13 insertions(+), 23 deletions(-) diff --git a/src/jsonLogic.js b/src/jsonLogic.js index dbbba6e98..9b4739f7e 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -84,7 +84,10 @@ function createValidationsScope(schema) { }); function validate(rule, values) { - return jsonLogic.apply(rule, replaceUndefinedValuesWithNulls(values)); + return jsonLogic.apply( + rule, + replaceUndefinedValuesWithNulls({ ...sampleEmptyObject, ...values }) + ); } return { @@ -120,7 +123,7 @@ function createValidationsScope(schema) { */ function replaceUndefinedValuesWithNulls(values = {}) { return Object.entries(values).reduce((prev, [key, value]) => { - return { ...prev, [key]: value === undefined ? null : value }; + return { ...prev, [key]: value === undefined || value === null ? NaN : value }; }, {}); } diff --git a/src/tests/jsonLogic.fixtures.js b/src/tests/jsonLogic.fixtures.js index 5ede3e9ec..0e5edc2b1 100644 --- a/src/tests/jsonLogic.fixtures.js +++ b/src/tests/jsonLogic.fixtures.js @@ -551,11 +551,7 @@ export const schemaWithGreaterThanChecksForThreeFields = { validations: { require_c: { rule: { - and: [ - { '!==': [{ var: 'field_b' }, null] }, - { '!==': [{ var: 'field_a' }, null] }, - { '>': [{ var: 'field_a' }, { var: 'field_b' }] }, - ], + and: [{ '>': [{ var: 'field_a' }, { var: 'field_b' }] }], }, }, }, @@ -598,11 +594,7 @@ export const schemaWithPropertiesCheckAndValidationsInAIf = { validations: { require_c: { rule: { - and: [ - { '!==': [{ var: 'field_b' }, null] }, - { '!==': [{ var: 'field_a' }, null] }, - { '>': [{ var: 'field_a' }, { var: 'field_b' }] }, - ], + and: [{ '>': [{ var: 'field_a' }, { var: 'field_b' }] }], }, }, }, @@ -656,11 +648,7 @@ export const schemaWithChecksAndThenValidationsOnThen = { }, require_c: { rule: { - and: [ - { '!==': [{ var: 'field_b' }, null] }, - { '!==': [{ var: 'field_a' }, null] }, - { '>': [{ var: 'field_a' }, { var: 'field_b' }] }, - ], + and: [{ '>': [{ var: 'field_a' }, { var: 'field_b' }] }], }, }, }, @@ -709,11 +697,7 @@ export const schemaWithComputedValueChecksInIf = { computedValues: { require_c: { rule: { - and: [ - { '!==': [{ var: 'field_b' }, null] }, - { '!==': [{ var: 'field_a' }, null] }, - { '>': [{ var: 'field_a' }, { var: 'field_b' }] }, - ], + and: [{ '>': [{ var: 'field_a' }, { var: 'field_b' }] }], }, }, }, diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index a6eaab418..9f0716356 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -435,7 +435,10 @@ describe('jsonLogic: cross-values validations', () => { expect(fields.find((i) => i.name === 'field_c').isVisible).toEqual(false); expect(handleValidation({ field_a: 1, field_b: 3 }).formErrors).toEqual(undefined); - expect(handleValidation({ field_a: 1, field_b: null }).formErrors).toEqual({ + expect(handleValidation({ field_a: 1 }).formErrors).toEqual({ + field_b: 'Required field', + }); + expect(handleValidation({ field_a: 1, field_b: undefined }).formErrors).toEqual({ field_b: 'Required field', }); expect(handleValidation({ field_a: 10, field_b: 3 }).formErrors).toEqual({ From ae07dbf9eb0c1137890d6979c3c90e1a692db323 Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 7 Sep 2023 10:59:58 +0200 Subject: [PATCH 40/48] chore: add missing isVisible checks --- src/tests/jsonLogic.test.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index 9f0716356..a0a131653 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -432,7 +432,8 @@ describe('jsonLogic: cross-values validations', () => { schemaWithGreaterThanChecksForThreeFields, { strictInputType: false } ); - expect(fields.find((i) => i.name === 'field_c').isVisible).toEqual(false); + const fieldC = fields.find((i) => i.name === 'field_c'); + expect(fieldC.isVisible).toEqual(false); expect(handleValidation({ field_a: 1, field_b: 3 }).formErrors).toEqual(undefined); expect(handleValidation({ field_a: 1 }).formErrors).toEqual({ @@ -444,20 +445,24 @@ describe('jsonLogic: cross-values validations', () => { expect(handleValidation({ field_a: 10, field_b: 3 }).formErrors).toEqual({ field_c: 'Required field', }); + expect(fieldC.isVisible).toEqual(true); expect(handleValidation({ field_a: 10, field_b: 3, field_c: 0 }).formErrors).toEqual( undefined ); }); it('A schema with both a `x-jsf-validations` and `properties` check', () => { - const { handleValidation } = createHeadlessForm( + const { fields, handleValidation } = createHeadlessForm( schemaWithPropertiesCheckAndValidationsInAIf, { strictInputType: false } ); + const fieldC = fields.find((i) => i.name === 'field_c'); expect(handleValidation({ field_a: 1, field_b: 3 }).formErrors).toEqual(undefined); + expect(fieldC.isVisible).toEqual(false); expect(handleValidation({ field_a: 10, field_b: 3 }).formErrors).toEqual({ field_c: 'Required field', }); + expect(fieldC.isVisible).toEqual(true); expect(handleValidation({ field_a: 5, field_b: 3 }).formErrors).toEqual(undefined); }); From 42cf4abc89342b3ef8d2ce7510e83c881c658510 Mon Sep 17 00:00:00 2001 From: brennj Date: Thu, 7 Sep 2023 12:11:01 +0200 Subject: [PATCH 41/48] chore: assert field c is invisible --- src/tests/jsonLogic.test.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index a0a131653..560d30060 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -484,6 +484,11 @@ describe('jsonLogic: cross-values validations', () => { expect(handleValidation({ field_a: 10, field_b: 5, field_c: 201 }).formErrors).toEqual( undefined ); + expect(handleValidation({ field_a: 5, field_b: 10 }).formErrors).toBeUndefined(); + expect(cField).toMatchObject({ + isVisible: false, + // description: null, the description will currently be `I am a description!`, how do we change it back to null from here? + }); }); it('Should apply a conditional based on a true computedValue', () => { From a85d58870d275bddb1ad84e211389390eaebc411 Mon Sep 17 00:00:00 2001 From: brennj Date: Wed, 13 Sep 2023 15:11:56 +0200 Subject: [PATCH 42/48] chore: more fixing inline rule verbage --- src/tests/jsonLogic.fixtures.js | 4 ++-- src/tests/jsonLogic.test.js | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/tests/jsonLogic.fixtures.js b/src/tests/jsonLogic.fixtures.js index cf0debd28..e36365b8a 100644 --- a/src/tests/jsonLogic.fixtures.js +++ b/src/tests/jsonLogic.fixtures.js @@ -481,7 +481,7 @@ export const schemaWithInlineMultipleRulesForComputedAttributes = { }, }; -export const schemaSelfContainedValueForTitle = { +export const schemaInlineComputedAttrForTitle = { properties: { field_a: { type: 'number', @@ -500,7 +500,7 @@ export const schemaSelfContainedValueForTitle = { }, }; -export const schemaSelfContainedValueForMaximumMinimumValues = { +export const schemaInlineComputedAttrForMaximumMinimumValues = { properties: { field_a: { type: 'number', diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index 414b5e366..e78d8dabe 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -4,8 +4,8 @@ import { createSchemaWithRulesOnFieldA, createSchemaWithThreePropertiesWithRuleOnFieldA, multiRuleSchema, - schemaSelfContainedValueForMaximumMinimumValues, - schemaSelfContainedValueForTitle, + schemaInlineComputedAttrForMaximumMinimumValues, + schemaInlineComputedAttrForTitle, schemaWithBadOperation, schemaWithComputedAttributeThatDoesntExist, schemaWithComputedAttributeThatDoesntExistDescription, @@ -377,8 +377,8 @@ describe('jsonLogic: cross-values validations', () => { expect(fieldB.description).toEqual('Must be between 5 and 20.'); }); - it('Use a self contained rule in a schema for a title but it just uses the value', () => { - const { fields, handleValidation } = createHeadlessForm(schemaSelfContainedValueForTitle, { + it('Use an inline rule in a schema for a title but it just uses the value', () => { + const { fields, handleValidation } = createHeadlessForm(schemaInlineComputedAttrForTitle, { strictInputType: false, }); const [, fieldB] = fields; @@ -386,9 +386,9 @@ describe('jsonLogic: cross-values validations', () => { expect(fieldB.label).toEqual('20'); }); - it('Use a self contained rule for a minimum, maximum value', () => { + it('Use an inline rule for a minimum, maximum value', () => { const { fields, handleValidation } = createHeadlessForm( - schemaSelfContainedValueForMaximumMinimumValues, + schemaInlineComputedAttrForMaximumMinimumValues, { strictInputType: false, } From 1f1593958982bd21aa3efd076944bc534b964e8b Mon Sep 17 00:00:00 2001 From: brennj Date: Wed, 13 Sep 2023 16:17:04 +0200 Subject: [PATCH 43/48] Merge remote-tracking branch 'origin/inline-rule-handling' into conditionals-json-logic --- CHANGELOG.md | 15 +++++++++++ package-lock.json | 4 +-- package.json | 2 +- src/helpers.js | 1 + src/jsonLogic.js | 45 +++++++++++++++++++++++++++++---- src/tests/const.test.js | 12 +++++++++ src/tests/jsonLogic.fixtures.js | 22 ++++++++++++++-- src/tests/jsonLogic.test.js | 12 ++++----- 8 files changed, 97 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d6689128..794197c2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +#### 0.6.1-beta.0 (2023-09-13) + +##### Changes + + + +- Computed string based values for json-logic ([#37](https://github.com/remoteoss/json-schema-form/pull/37)) ([6e042ea5](https://github.com/remoteoss/json-schema-form/commit/6e042ea579497ea573710c307a6ff7ee2f19b931)) + +#### 0.5.0-beta.0 (2023-09-12) + +##### Changes + +- Computed Attributes ([#36](https://github.com/remoteoss/json-schema-form/pull/36)) ([80c29589](https://github.com/remoteoss/json-schema-form/commit/80c29589ac0972e0f33add70a59df15a46db1b43)) +- JSON Logic Skeleton ([#35](https://github.com/remoteoss/json-schema-form/pull/35)) ([63149ae8](https://github.com/remoteoss/json-schema-form/commit/63149ae863cf1b5ad76a3b2a49c7f343e55ce07b)) + #### 0.4.5-beta.0 (2023-08-31) ##### Changes diff --git a/package-lock.json b/package-lock.json index 0d5f1d99b..32941c436 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.6.1-beta.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@remoteoss/json-schema-form", - "version": "0.4.5-beta.0", + "version": "0.6.1-beta.0", "license": "MIT", "dependencies": { "json-logic-js": "^2.0.2", diff --git a/package.json b/package.json index f95bff62c..a8219bf78 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@remoteoss/json-schema-form", - "version": "0.4.5-beta.0", + "version": "0.6.1-beta.0", "description": "Headless UI form powered by JSON Schemas", "author": "Remote.com (https://remote.com/)", "license": "MIT", diff --git a/src/helpers.js b/src/helpers.js index b730f4489..5577c7ca2 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -497,6 +497,7 @@ export function extractParametersFromNode(schemaNode) { return omitBy( { const: node.const, + ...(node.const && node.default ? { value: node.const } : {}), label: node.title, readOnly: node.readOnly, ...(node.deprecated && { diff --git a/src/jsonLogic.js b/src/jsonLogic.js index 9b4739f7e..27e90d3b3 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -16,10 +16,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(); @@ -50,6 +46,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(); @@ -162,6 +173,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, @@ -397,8 +422,18 @@ function throwIfUnknownOperator(operator, subRule, id) { } } +const regexToGetIndices = /\.\d+\./g; // eg. .0., .10. + +/** + * Removes array indices from a json schema path string. + * Converts paths like "foo.0.bar" to "foo.bar". + * This allows checking if a variable exists in an array item schema without needing the specific index. + * + * @param {string} path - The json schema path potentially containing array indices + * @returns {string} The path with array indices removed + */ function removeIndicesFromPath(path) { - const intermediatePath = path.replace(/\.\d+\./g, '.'); + const intermediatePath = path.replace(regexToGetIndices, '.'); return intermediatePath.replace(/\.\d+$/, ''); } diff --git a/src/tests/const.test.js b/src/tests/const.test.js index ccb6a1e13..09b767001 100644 --- a/src/tests/const.test.js +++ b/src/tests/const.test.js @@ -83,4 +83,16 @@ describe('validations: const', () => { }); expect(handleValidation({ string: 'hello' }).formErrors).toEqual(undefined); }); + + it('Should have value attribute for when const & default is present', () => { + const { fields } = createHeadlessForm( + { + properties: { + ten_only: { type: 'number', const: 10, default: 10 }, + }, + }, + { strictInputType: false } + ); + expect(fields[0]).toMatchObject({ value: 10, const: 10, default: 10 }); + }); }); diff --git a/src/tests/jsonLogic.fixtures.js b/src/tests/jsonLogic.fixtures.js index 0e5edc2b1..951455cea 100644 --- a/src/tests/jsonLogic.fixtures.js +++ b/src/tests/jsonLogic.fixtures.js @@ -227,6 +227,24 @@ 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: { @@ -463,7 +481,7 @@ export const schemaWithInlineMultipleRulesForComputedAttributes = { }, }; -export const schemaSelfContainedValueForTitle = { +export const schemaInlineComputedAttrForTitle = { properties: { field_a: { type: 'number', @@ -482,7 +500,7 @@ export const schemaSelfContainedValueForTitle = { }, }; -export const schemaSelfContainedValueForMaximumMinimumValues = { +export const schemaInlineComputedAttrForMaximumMinimumValues = { properties: { field_a: { type: 'number', diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index 560d30060..e71bcfc58 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -4,9 +4,9 @@ import { createSchemaWithRulesOnFieldA, createSchemaWithThreePropertiesWithRuleOnFieldA, multiRuleSchema, - schemaSelfContainedValueForMaximumMinimumValues, - schemaSelfContainedValueForTitle, schemaWhereValidationAndComputedValueIsAppliedOnNormalThenStatement, + schemaInlineComputedAttrForMaximumMinimumValues, + schemaInlineComputedAttrForTitle, schemaWithBadOperation, schemaWithChecksAndThenValidationsOnThen, schemaWithComputedAttributeThatDoesntExist, @@ -384,8 +384,8 @@ describe('jsonLogic: cross-values validations', () => { expect(fieldB.description).toEqual('Must be between 5 and 20.'); }); - it('Use a self contained rule in a schema for a title but it just uses the value', () => { - const { fields, handleValidation } = createHeadlessForm(schemaSelfContainedValueForTitle, { + it('Use an inline rule in a schema for a title but it just uses the value', () => { + const { fields, handleValidation } = createHeadlessForm(schemaInlineComputedAttrForTitle, { strictInputType: false, }); const [, fieldB] = fields; @@ -393,9 +393,9 @@ describe('jsonLogic: cross-values validations', () => { expect(fieldB.label).toEqual('20'); }); - it('Use a self contained rule for a minimum, maximum value', () => { + it('Use an inline rule for a minimum, maximum value', () => { const { fields, handleValidation } = createHeadlessForm( - schemaSelfContainedValueForMaximumMinimumValues, + schemaInlineComputedAttrForMaximumMinimumValues, { strictInputType: false, } From b36e5bd8aa8758c6b6fdd4d5e0fe48bc3aceaa0e Mon Sep 17 00:00:00 2001 From: brennj Date: Wed, 13 Sep 2023 16:43:00 +0200 Subject: [PATCH 44/48] chore: remove need for default: 0 --- src/tests/jsonLogic.fixtures.js | 1 - src/tests/jsonLogic.test.js | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/tests/jsonLogic.fixtures.js b/src/tests/jsonLogic.fixtures.js index 951455cea..11c749224 100644 --- a/src/tests/jsonLogic.fixtures.js +++ b/src/tests/jsonLogic.fixtures.js @@ -504,7 +504,6 @@ export const schemaInlineComputedAttrForMaximumMinimumValues = { properties: { field_a: { type: 'number', - default: 0, }, field_b: { type: 'number', diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index e71bcfc58..bb4db331a 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -401,7 +401,7 @@ describe('jsonLogic: cross-values validations', () => { } ); const [, fieldB] = fields; - expect(fieldB).toMatchObject({ minimum: -10, maximum: 10 }); + expect(fieldB).toMatchObject({ minimum: NaN, maximum: NaN }); expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toBeUndefined(); expect(fieldB).toMatchObject({ minimum: 0, maximum: 20 }); expect(handleValidation({ field_a: 50, field_b: 20 }).formErrors).toEqual({ From ee18568ba2edc9717fda8ae1c3e59058386f5f07 Mon Sep 17 00:00:00 2001 From: brennj Date: Wed, 13 Sep 2023 16:47:06 +0200 Subject: [PATCH 45/48] chore: add note to internal issue --- src/tests/jsonLogic.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index bb4db331a..d09007e34 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -487,7 +487,7 @@ describe('jsonLogic: cross-values validations', () => { expect(handleValidation({ field_a: 5, field_b: 10 }).formErrors).toBeUndefined(); expect(cField).toMatchObject({ isVisible: false, - // description: null, the description will currently be `I am a description!`, how do we change it back to null from here? + // description: null, the description will currently be `I am a description!`, how do we change it back to null from here? Needs to be fixed by RMT-58 }); }); From 2e52614308765855923632b4fa395b0bbef85e62 Mon Sep 17 00:00:00 2001 From: brennj Date: Mon, 18 Sep 2023 10:31:45 +0200 Subject: [PATCH 46/48] chore: add comment to track NaN bug --- src/tests/jsonLogic.test.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index d09007e34..56d84557b 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -401,6 +401,10 @@ describe('jsonLogic: cross-values validations', () => { } ); const [, fieldB] = fields; + + // FIXME: We are currently setting NaN here because of how the data clean up works for json-logic + // We should probably set this as undefined when theres no values set? + // tracked in INF-53. expect(fieldB).toMatchObject({ minimum: NaN, maximum: NaN }); expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toBeUndefined(); expect(fieldB).toMatchObject({ minimum: 0, maximum: 20 }); From df233cc4f7de3c31f871ec3a90323b4fe8218a3d Mon Sep 17 00:00:00 2001 From: brennj Date: Mon, 18 Sep 2023 10:33:04 +0200 Subject: [PATCH 47/48] Release 0.6.5-dev.20230918083235 --- 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 fe412bce2..83b8b22c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@remoteoss/json-schema-form", - "version": "0.6.4-beta.0", + "version": "0.6.5-dev.20230918083235", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@remoteoss/json-schema-form", - "version": "0.6.4-beta.0", + "version": "0.6.5-dev.20230918083235", "license": "MIT", "dependencies": { "json-logic-js": "^2.0.2", diff --git a/package.json b/package.json index c96e35fb5..81914e04e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@remoteoss/json-schema-form", - "version": "0.6.4-beta.0", + "version": "0.6.5-dev.20230918083235", "description": "Headless UI form powered by JSON Schemas", "author": "Remote.com (https://remote.com/)", "license": "MIT", From 7efc061685d5755506ef0f1b18479019d0881974 Mon Sep 17 00:00:00 2001 From: brennj Date: Mon, 18 Sep 2023 10:33:24 +0200 Subject: [PATCH 48/48] Revert "Release 0.6.5-dev.20230918083235" This reverts commit df233cc4f7de3c31f871ec3a90323b4fe8218a3d. --- 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 83b8b22c8..fe412bce2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@remoteoss/json-schema-form", - "version": "0.6.5-dev.20230918083235", + "version": "0.6.4-beta.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@remoteoss/json-schema-form", - "version": "0.6.5-dev.20230918083235", + "version": "0.6.4-beta.0", "license": "MIT", "dependencies": { "json-logic-js": "^2.0.2", diff --git a/package.json b/package.json index 81914e04e..c96e35fb5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@remoteoss/json-schema-form", - "version": "0.6.5-dev.20230918083235", + "version": "0.6.4-beta.0", "description": "Headless UI form powered by JSON Schemas", "author": "Remote.com (https://remote.com/)", "license": "MIT",