From d3f26e2b8b5aaf3e35909e7f75279f519f8fa77d Mon Sep 17 00:00:00 2001 From: brennj Date: Tue, 12 Sep 2023 09:51:33 +0200 Subject: [PATCH 1/5] parent 80c29589ac0972e0f33add70a59df15a46db1b43 author brennj 1694505093 +0200 committer brennj 1694508319 +0200 Release 0.5.0-beta.0 chore: support barebones computedAttrs chore: fix errors chore: fix mess ups from rebase chore: feedback from PR chore: pass logic down at updateFieldsProperties to prevent bugs 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: fix tests chore: consistency for curly braces chore: remove unneeded code for now --- .eslintrc | 1 + CHANGELOG.md | 7 ++++ package-lock.json | 4 +-- package.json | 2 +- src/jsonLogic.js | 63 +++++++++++++++++++++++++++++++-- src/tests/jsonLogic.fixtures.js | 41 +++++++++++++++++++++ src/tests/jsonLogic.test.js | 22 ++++++++++++ 7 files changed, 134 insertions(+), 6 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/CHANGELOG.md b/CHANGELOG.md index 0d6689128..b624feb5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +#### 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..fe299e20b 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-beta.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@remoteoss/json-schema-form", - "version": "0.4.5-beta.0", + "version": "0.5.0-beta.0", "license": "MIT", "dependencies": { "json-logic-js": "^2.0.2", diff --git a/package.json b/package.json index f95bff62c..9d0e9d629 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-beta.0", "description": "Headless UI form powered by JSON Schemas", "author": "Remote.com (https://remote.com/)", "license": "MIT", diff --git a/src/jsonLogic.js b/src/jsonLogic.js index d2d80d775..cd21ff5a3 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,25 @@ export function yupSchemaWithCustomJSONLogic({ field, logic, config, id }) { ); } +const HANDLEBARS_REGEX = /\{\{([^{}]+)\}\}/g; + +function replaceHandlebarsTemplates({ + value: toReplace, + logic, + formValues, + parentID, + name: fieldName, +}) { + if (typeof toReplace === 'string') { + return toReplace.replace(HANDLEBARS_REGEX, (match, key) => { + return logic.getScope(parentID).applyComputedValueInField(key.trim(), formValues, fieldName); + }); + } + return toReplace; +} + 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 +151,55 @@ export function calculateComputedAttributes(fieldParams, { parentID = 'root' } = .filter(([, value]) => value !== null) ); - return attributes; + return { + ...attributes, + schema: buildYupSchema( + { ...fieldParams, ...attributes, required: isRequired }, + config, + logic + ), + }; }; } function handleComputedAttribute(logic, formValues, parentID) { return ([key, value]) => { - if (key === 'const') + 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)]; + } + + 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)]; } + + if (key === 'x-jsf-presentation' && value.statement) { + return [ + 'statement', + handleNestedObjectForComputedValues(value.statement, formValues, parentID, logic, name), + ]; + } }; } + +function handleNestedObjectForComputedValues(values, formValues, parentID, logic, name) { + return Object.fromEntries( + Object.entries(values).map(([key, value]) => { + return [key, replaceHandlebarsTemplates({ value, logic, formValues, parentID, name })]; + }) + ); +} 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 b40a06b80..4a0f19567 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 8!'); + }); + + it('Derived errorMessages and statements work', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaWithComputedAttributesAndErrorMessages, + { strictInputType: false } + ); + const fieldB = fields.find((i) => i.name === 'field_b'); + expect(handleValidation({ field_a: 2, field_b: 0 }).formErrors).toEqual({ + field_b: 'Must be bigger than 4', + }); + expect(handleValidation({ field_a: 2, field_b: 100 }).formErrors).toEqual({ + field_b: 'Must be smaller than 8', + }); + expect(fieldB.minimum).toEqual(4); + expect(fieldB.maximum).toEqual(8); + expect(fieldB.statement).toEqual({ description: 'Must be bigger than 4 and smaller than 8' }); }); }); }); From 8edfeb5270337c2ad2b1eca00477aafbe8a131dd Mon Sep 17 00:00:00 2001 From: John Brennan Date: Wed, 13 Sep 2023 10:22:34 +0200 Subject: [PATCH 2/5] [JSON Logic] Part 4: Computed Attribute error handling (#38) * 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: fix errors * chore: fix mess ups from rebase * chore: feedback from PR * chore: pass logic down at updateFieldsProperties to prevent bugs * Release 0.5.0-dev.20230901130231 * Revert "Release 0.5.0-dev.20230901130231" This reverts commit 55ed2966685f5574c41bb0667d72207b777622fd. * 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: fix tests * chore: consistency for curly braces * chore: remove unneeded code for now * 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 * chore: fix tests * chore: review errors * chore: fix up code for fixtures * chore: add a bunch of docs to try and make things clearer * chore: add example to docs * chore: higher level console check * chore: use cases to clean up error tests * chore: add more tests for missing vars * chore: use switch statement instead * chore: add code comments why schemas fail * chore: jsdoc tweaks --- src/jsonLogic.js | 257 +++++++++++++++++++++++---- src/tests/createHeadlessForm.test.js | 14 +- src/tests/jsonLogic.fixtures.js | 133 ++++++++++++++ src/tests/jsonLogic.test.js | 66 +++++++ src/tests/testUtils.js | 11 ++ 5 files changed, 434 insertions(+), 47 deletions(-) create mode 100644 src/tests/testUtils.js diff --git a/src/jsonLogic.js b/src/jsonLogic.js index cd21ff5a3..f91639dd3 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -11,15 +11,12 @@ 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(); 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 +27,8 @@ export function createValidationChecker(schema) { createScopes(property, key); } }); + + validateInlineRules(jsonSchema, sampleEmptyObject); } createScopes(schema); @@ -42,6 +41,21 @@ export function createValidationChecker(schema) { }; } +/** + * Creates a validation scope object for a schema. + * + * Builds maps of validations and computed values defined in the schema's + * x-jsf-logic section. Includes functions to evaluate the rules. + * + * @param {Object} schema - The JSON schema + * @returns {Object} The validation scope object containing: + * - validationMap - Map of validation rules + * - computedValuesMap - Map of computed value rules + * - validate {Function} - Function to evaluate a validation rule + * - applyValidationRuleInCondition {Function} - Evaluate a validation rule used in a condition + * - applyComputedValueInField {Function} - Evaluate a computed value rule for a field + * - applyComputedValueRuleInCondition {Function} - Evaluate a computed value rule used in a condition + */ function createValidationsScope(schema) { const validationMap = new Map(); const computedValuesMap = new Map(); @@ -53,12 +67,25 @@ function createValidationsScope(schema) { const validations = Object.entries(logic.validations ?? {}); const computedValues = Object.entries(logic.computedValues ?? {}); + const sampleEmptyObject = buildSampleEmptyObject(schema); validations.forEach(([id, validation]) => { + if (!validation.rule) { + throw Error(`[json-schema-form] json-logic error: Validation "${id}" has missing rule.`); + } + + checkRuleIntegrity(validation.rule, id, sampleEmptyObject); + validationMap.set(id, validation); }); computedValues.forEach(([id, computedValue]) => { + if (!computedValue.rule) { + throw Error(`[json-schema-form] json-logic error: Computed value "${id}" has missing rule.`); + } + + checkRuleIntegrity(computedValue.rule, id, sampleEmptyObject); + computedValuesMap.set(id, computedValue); }); @@ -74,8 +101,13 @@ function createValidationsScope(schema) { const validation = validationMap.get(id); return validate(validation.rule, values); }, - applyComputedValueInField(id, values) { + applyComputedValueInField(id, values, fieldName) { const validation = computedValuesMap.get(id); + if (validation === undefined) { + throw Error( + `[json-schema-form] json-logic error: Computed value "${id}" doesn't exist in field "${fieldName}".` + ); + } return validate(validation.rule, values); }, applyComputedValueRuleInCondition(id, values) { @@ -127,6 +159,20 @@ export function yupSchemaWithCustomJSONLogic({ field, logic, config, id }) { const HANDLEBARS_REGEX = /\{\{([^{}]+)\}\}/g; +/** + * Replaces Handlebars templates in a value with computed values. + * + * Handles recursively replacing Handlebars templates "{{var}}" in strings + * with computed values looked up from the validation logic. + * + * @param {Object} options - Options object + * @param {*} options.value - The value to replace templates in + * @param {Object} options.logic - The validation logic object + * @param {Object} options.formValues - The current form values + * @param {string} options.parentID - The ID of the validation scope + * @param {string} options.name - The name of the field + * @returns {*} The value with templates replaced with computed values + */ function replaceHandlebarsTemplates({ value: toReplace, logic, @@ -142,12 +188,25 @@ 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 { 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) ); @@ -162,36 +221,44 @@ export function calculateComputedAttributes(fieldParams, { parentID = 'root' } = }; } -function handleComputedAttribute(logic, formValues, parentID) { +/** + * Handles computing a single attribute value. + * + * Evaluates jsonLogic rules to build the computed value. + * + * @param {Object} logic - Validation logic + * @param {Object} formValues - Current form values + * @param {string} parentID - ID of the validation scope + * @param {string} name - Name of the field + * @returns {Function} Function to compute the attribute value + */ +function handleComputedAttribute(logic, formValues, parentID, name) { return ([key, value]) => { - if (key === '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)]; - } - - 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)]; - } - - 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)]; } }; } @@ -203,3 +270,121 @@ 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) { + return schema; + } + + for (const key in schema.properties) { + if (schema.properties[key].type === 'object') { + sample[key] = buildSampleEmptyObject(schema.properties[key]); + } else if (schema.properties[key].type === 'array') { + const itemSchema = schema.properties[key].items; + sample[key] = buildSampleEmptyObject(itemSchema); + } else { + sample[key] = true; + } + } + + return sample; +} + +/** + * Validates inline jsonLogic rules defined in the schema's x-jsf-logic-computedAttrs. + * + * For each field with computed attributes, checks that the variables + * referenced in the rules exist in the schema. + * + * Throws if any variable in a computed attribute rule does not exist. + * + * @param {Object} jsonSchema - The JSON schema object + * @param {Object} sampleEmptyObject - Sample empty object based on the schema + */ +function validateInlineRules(jsonSchema, sampleEmptyObject) { + const properties = (jsonSchema?.properties || jsonSchema?.items?.properties) ?? {}; + Object.entries(properties) + .filter(([, property]) => property['x-jsf-logic-computedAttrs'] !== undefined) + .forEach(([fieldName, property]) => { + Object.entries(property['x-jsf-logic-computedAttrs']) + .filter(([, value]) => typeof value === 'object') + .forEach(([key, item]) => { + Object.values(item).forEach((rule) => { + checkRuleIntegrity( + rule, + fieldName, + sampleEmptyObject, + (item) => + `[json-schema-form] json-logic error: fieldName "${item.var}" doesn't exist in field "${fieldName}.x-jsf-logic-computedAttrs.${key}".` + ); + }); + }); + }); +} + +/** + * Checks the integrity of a jsonLogic rule by validating that all referenced variables exist in the provided data object. + * Throws an error if any variable in the rule does not exist in the data. + * + * @example + * + * const rule = { "+": [{ "var": "iDontExist"}, 10 ]} + * const badData = { a: 1 } + * checkRuleIntegrity(rule, "add_ten_to_field", badData) + * // throws Error(`"iDontExist" in rule "add_ten_to_field" does not exist as a JSON schema property.`) + * + * + * @param {Object|Array} rule - The jsonLogic rule object or array to validate + * @param {string} id - The ID of the rule (used in error messages) + * @param {Object} data - The data object to check the rule variables against + * @param {Function} errorMessage - Function to generate custom error message. + * Receives the invalid rule part and should throw an error message string. + */ +function checkRuleIntegrity( + rule, + id, + data, + errorMessage = (item) => `"${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); + } + }); + }); +} + +const regexToGetIndices = /\.\d+\./g; // eg. .0., .10. + +/** + * Removes array indices from a json schema path string. + * Converts paths like "foo.0.bar" to "foo.bar". + * This allows checking if a variable exists in an array item schema without needing the specific index. + * + * @param {string} path - The json schema path potentially containing array indices + * @returns {string} The path with array indices removed + */ +function removeIndicesFromPath(path) { + const intermediatePath = path.replace(regexToGetIndices, '.'); + return intermediatePath.replace(/\.\d+$/, ''); +} diff --git a/src/tests/createHeadlessForm.test.js b/src/tests/createHeadlessForm.test.js index 784e5e395..09e57e9a8 100644 --- a/src/tests/createHeadlessForm.test.js +++ b/src/tests/createHeadlessForm.test.js @@ -57,6 +57,7 @@ import { schemaForErrorMessageSpecificity, jsfConfigForErrorMessageSpecificity, } from './helpers'; +import { mockConsole, restoreConsoleAndEnsureItWasNotCalled } from './testUtils'; function buildJSONSchemaInput({ presentationFields, inputFields = {}, required }) { return { @@ -92,17 +93,8 @@ const getField = (fields, name, ...subNames) => { return field; }; -beforeEach(() => { - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); -}); - -afterEach(() => { - expect(console.error).not.toHaveBeenCalled(); - console.error.mockRestore(); - expect(console.warn).not.toHaveBeenCalled(); - console.warn.mockRestore(); -}); +beforeEach(mockConsole); +afterEach(restoreConsoleAndEnsureItWasNotCalled); describe('createHeadlessForm', () => { it('returns empty result given no schema', () => { diff --git a/src/tests/jsonLogic.fixtures.js b/src/tests/jsonLogic.fixtures.js index f1583beff..32af4cf89 100644 --- a/src/tests/jsonLogic.fixtures.js +++ b/src/tests/jsonLogic.fixtures.js @@ -77,6 +77,70 @@ export const schemaWithNativeAndJSONLogicChecks = { required: ['field_a'], }; +export const schemaWithMissingRule = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-validations': ['a_greater_than_ten'], + }, + }, + 'x-jsf-logic': { + validations: { + a_greater_than_ten: { + errorMessage: 'Must be greater than 10', + // rule: { '>': [{ var: 'field_a' }, 10] }, this missing causes test to fail. + }, + }, + }, + required: [], +}; + +export const schemaWithUnknownVariableInValidations = { + properties: { + // field_a: { type: 'number' }, this missing causes test to fail. + }, + 'x-jsf-logic': { + validations: { + a_equals_ten: { + errorMessage: 'Must equal 10', + rule: { '===': [{ var: 'field_a' }, 10] }, + }, + }, + }, +}; + +export const schemaWithUnknownVariableInComputedValues = { + properties: { + // field_a: { type: 'number' }, this missing causes test to fail. + }, + 'x-jsf-logic': { + computedValues: { + a_times_ten: { + rule: { '*': [{ var: 'field_a' }, 10] }, + }, + }, + }, +}; + +export const schemaWithMissingComputedValue = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: '{{a_plus_ten}}', + }, + }, + }, + 'x-jsf-logic': { + computedValues: { + a_plus_ten: { + // rule: { '+': [{ var: 'field_a' }, 10 ]} this missing causes test to fail. + }, + }, + }, + required: [], +}; + export const multiRuleSchema = { properties: { field_a: { @@ -163,6 +227,75 @@ 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', + }, + }, + }, + // x-jsf-logic: { computedValues: { iDontExist: { rule: 10 }} this missing causes test to fail. +}; + +export const schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar = { + properties: { + // iDontExist: { type: 'number' } this missing causes test to fail. + field_a: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: { + rule: { + '+': [{ var: 'IdontExist' }, 10], + }, + }, + }, + }, + }, +}; + +export const schemaWithComputedAttributeThatDoesntExistTitle = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: `this doesn't exist {{iDontExist}}`, + }, + }, + }, +}; + +export const schemaWithComputedAttributeThatDoesntExistDescription = { + properties: { + // iDontExist: { type: 'number'}, this missing causes test to fail + field_a: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + description: `this doesn't exist {{iDontExist}}`, + }, + }, + }, +}; + export const schemaWithComputedAttributesAndErrorMessages = { properties: { field_a: { diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index 4a0f19567..0d90f623b 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -4,12 +4,24 @@ import { createSchemaWithRulesOnFieldA, createSchemaWithThreePropertiesWithRuleOnFieldA, multiRuleSchema, + schemaWithComputedAttributeThatDoesntExist, + schemaWithComputedAttributeThatDoesntExistDescription, + schemaWithComputedAttributeThatDoesntExistTitle, schemaWithComputedAttributes, schemaWithComputedAttributesAndErrorMessages, + schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar, + schemaWithMissingComputedValue, + schemaWithMissingRule, schemaWithNativeAndJSONLogicChecks, schemaWithNonRequiredField, schemaWithTwoRules, + schemaWithUnknownVariableInComputedValues, + schemaWithUnknownVariableInValidations, } 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', () => { @@ -83,6 +95,60 @@ describe('jsonLogic: cross-values validations', () => { }); }); + describe('Incorrectly written schemas', () => { + afterEach(() => console.error.mockClear()); + + const cases = [ + [ + 'x-jsf-logic.validations: throw when theres a missing rule', + schemaWithMissingRule, + '[json-schema-form] json-logic error: Validation "a_greater_than_ten" has missing rule.', + ], + [ + 'x-jsf-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, + '[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".`, + ], + ]; + + test.each(cases)('%p', (_, schema, expectedErrorString) => { + const { error } = createHeadlessForm(schema, { strictInputType: false }); + const expectedError = new Error(expectedErrorString); + expect(console.error).toHaveBeenCalledWith('JSON Schema invalid!', expectedError); + expect(error).toEqual(expectedError); + }); + }); + describe('Arithmetic: +, -, *, /', () => { it('multiple: field_a > field_b * 2', () => { const schema = createSchemaWithRulesOnFieldA({ 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 8abcf020e733953f160e7f842bfc480472a1e686 Mon Sep 17 00:00:00 2001 From: John Brennan Date: Wed, 13 Sep 2023 10:35:27 +0200 Subject: [PATCH 3/5] [JSON Logic] Part 5: Add general error handling (#39) * 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: fix errors * chore: fix mess ups from rebase * chore: feedback from PR * chore: pass logic down at updateFieldsProperties to prevent bugs * Release 0.5.0-dev.20230901130231 * Revert "Release 0.5.0-dev.20230901130231" This reverts commit 55ed2966685f5574c41bb0667d72207b777622fd. * 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: fix tests * chore: consistency for curly braces * chore: remove unneeded code for now * 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 * chore: fix tests * chore: review errors * chore: fix up code for fixtures * chore: add a bunch of docs to try and make things clearer * chore: add example to docs * chore: higher level console check * chore: use cases to clean up error tests * chore: add more tests for missing vars * chore: use switch statement instead * chore: add code comments why schemas fail * 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 * chore: changes * chore: add bad operator handling * chore: fix bad naming --- src/jsonLogic.js | 40 +++++++++++- src/tests/jsonLogic.fixtures.js | 105 ++++++++++++++++++++++++++++++++ src/tests/jsonLogic.test.js | 38 ++++++++++-- 3 files changed, 177 insertions(+), 6 deletions(-) diff --git a/src/jsonLogic.js b/src/jsonLogic.js index f91639dd3..2729d1142 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -146,6 +146,12 @@ export function yupSchemaWithCustomJSONLogic({ field, logic, config, id }) { const { parentID = 'root' } = config; const validation = logic.getScope(parentID).validationMap.get(id); + if (validation === undefined) { + throw Error( + `[json-schema-form] json-logic error: "${field.name}" required validation "${id}" doesn't exist.` + ); + } + return (yupSchema) => yupSchema.test( `${field.name}-validation-${id}`, @@ -184,6 +190,21 @@ 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; + + if (Object.keys(rules).length > 1 && !value) { + throw Error('Cannot define multiple rules without a template string with key `value`.'); + } + + const computedTemplateValue = Object.entries(rules).reduce((prev, [key, rule]) => { + const computedValue = logic.getScope(parentID).evaluateValidation(rule, formValues); + return prev.replaceAll(`{{${key}}}`, computedValue); + }, value); + + return computedTemplateValue.replace(/\{\{([^{}]+)\}\}/g, (match, key) => { + return logic.getScope(parentID).applyComputedValueInField(key.trim(), formValues, fieldName); + }); } return toReplace; } @@ -356,10 +377,13 @@ 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) => { + 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) { @@ -374,6 +398,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}".` + ); + } + } +} + const regexToGetIndices = /\.\d+\./g; // eg. .0., .10. /** diff --git a/src/tests/jsonLogic.fixtures.js b/src/tests/jsonLogic.fixtures.js index 32af4cf89..1d8b16cd0 100644 --- a/src/tests/jsonLogic.fixtures.js +++ b/src/tests/jsonLogic.fixtures.js @@ -334,3 +334,108 @@ export const schemaWithComputedAttributesAndErrorMessages = { }, }, }; + +export const schemaWithDeepVarThatDoesNotExist = { + properties: { + field_a: { + type: 'number', + }, + }, + 'x-jsf-logic': { + validations: { + dummy_rule: { + errorMessage: 'Random stuff to illustrate a deeply nested rule.', + rule: { + '>': [{ var: 'field_a' }, { '*': [2, { '/': [2, { '*': [1, { var: 'field_b' }] }] }] }], + }, + }, + }, + }, + required: [], +}; + +export const schemaWithDeepVarThatDoesNotExistOnFieldset = { + properties: { + field_a: { + type: 'object', + properties: { + child: { + type: 'number', + }, + }, + 'x-jsf-logic': { + validations: { + dummy_rule: { + errorMessage: 'Must be greater than 10', + rule: { + '>': [{ var: 'child' }, { '*': [2, { '/': [2, { '*': [1, { var: 'field_a' }] }] }] }], + }, + }, + }, + }, + }, + }, + required: [], +}; + +export const schemaWithValidationThatDoesNotExistOnProperty = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-validations': ['iDontExist'], + }, + }, +}; + +export const schemaWithPropertyThatDoesNotExistInThatLevelButDoesInFieldset = { + properties: { + field_a: { + type: 'object', + 'x-jsf-presentation': { + inputType: 'fieldset', + }, + properties: { + child: { + type: 'number', + 'x-jsf-logic-validations': ['child_greater_than_10'], + }, + other_child: { + type: 'number', + 'x-jsf-logic-validations': ['greater_than_child'], + }, + }, + required: ['child', 'other_child'], + }, + }, + // the issue here is that this should be nested inside `field_a` in order to not fail. + 'x-jsf-logic': { + validations: { + validation_parent: { + errorMessage: 'Must be greater than 10!', + rule: { + '>': [{ var: 'child' }, 10], + }, + }, + greater_than_child: { + errorMessage: 'Must be greater than child', + rule: { + '>': [{ var: 'other_child' }, { var: 'child' }], + }, + }, + }, + }, + required: ['field_a'], +}; + +export const schemaWithBadOperation = { + properties: {}, + 'x-jsf-logic': { + validations: { + badOperator: { + rule: { + '++': [10, 2], + }, + }, + }, + }, +}; diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index 0d90f623b..a3ef30cff 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -4,19 +4,24 @@ import { createSchemaWithRulesOnFieldA, createSchemaWithThreePropertiesWithRuleOnFieldA, multiRuleSchema, + schemaWithBadOperation, schemaWithComputedAttributeThatDoesntExist, schemaWithComputedAttributeThatDoesntExistDescription, schemaWithComputedAttributeThatDoesntExistTitle, schemaWithComputedAttributes, schemaWithComputedAttributesAndErrorMessages, + schemaWithDeepVarThatDoesNotExist, + schemaWithDeepVarThatDoesNotExistOnFieldset, schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar, schemaWithMissingComputedValue, schemaWithMissingRule, schemaWithNativeAndJSONLogicChecks, schemaWithNonRequiredField, + schemaWithPropertyThatDoesNotExistInThatLevelButDoesInFieldset, schemaWithTwoRules, schemaWithUnknownVariableInComputedValues, schemaWithUnknownVariableInValidations, + schemaWithValidationThatDoesNotExistOnProperty, } from './jsonLogic.fixtures'; import { mockConsole, restoreConsoleAndEnsureItWasNotCalled } from './testUtils'; @@ -105,14 +110,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', @@ -139,6 +144,31 @@ 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 "dummy_rule" has no variable "field_b".', + ], + [ + 'x-jsf-logic.validations: error if rule does not exist on a fieldset property', + schemaWithDeepVarThatDoesNotExistOnFieldset, + '[json-schema-form] json-logic error: rule "dummy_rule" has no variable "field_a".', + ], + [ + 'x-jsf-validations: error if a validation name does not exist', + schemaWithValidationThatDoesNotExistOnProperty, + `[json-schema-form] json-logic error: "field_a" required validation "iDontExist" doesn't exist.`, + ], + [ + 'x-jsf-logic.validations: A top level logic keyword will not be able to reference fieldset properties', + schemaWithPropertyThatDoesNotExistInThatLevelButDoesInFieldset, + '[json-schema-form] json-logic error: rule "validation_parent" has no variable "child".', + ], + [ + 'x-jsf-logic.validations: error if unknown operation', + schemaWithBadOperation, + '[json-schema-form] json-logic error: in "badOperator" rule there is an unknown operator "++".', + ], ]; test.each(cases)('%p', (_, schema, expectedErrorString) => { From 0f24b2785f24f1ac48a81340ce33ff20c6ca7ddc Mon Sep 17 00:00:00 2001 From: brennj Date: Wed, 13 Sep 2023 10:39:03 +0200 Subject: [PATCH 4/5] Release 0.5.1-dev.20230913083845 --- 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 fe299e20b..4acbf9e58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@remoteoss/json-schema-form", - "version": "0.5.0-beta.0", + "version": "0.5.1-dev.20230913083845", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@remoteoss/json-schema-form", - "version": "0.5.0-beta.0", + "version": "0.5.1-dev.20230913083845", "license": "MIT", "dependencies": { "json-logic-js": "^2.0.2", diff --git a/package.json b/package.json index 9d0e9d629..f87e1036a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@remoteoss/json-schema-form", - "version": "0.5.0-beta.0", + "version": "0.5.1-dev.20230913083845", "description": "Headless UI form powered by JSON Schemas", "author": "Remote.com (https://remote.com/)", "license": "MIT", From 60f8b7bb3e493de1469a7d5024cbeaa0b179a822 Mon Sep 17 00:00:00 2001 From: brennj Date: Wed, 13 Sep 2023 11:02:41 +0200 Subject: [PATCH 5/5] Revert "Release 0.5.1-dev.20230913083845" This reverts commit 0f24b2785f24f1ac48a81340ce33ff20c6ca7ddc. --- 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 4acbf9e58..fe299e20b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@remoteoss/json-schema-form", - "version": "0.5.1-dev.20230913083845", + "version": "0.5.0-beta.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@remoteoss/json-schema-form", - "version": "0.5.1-dev.20230913083845", + "version": "0.5.0-beta.0", "license": "MIT", "dependencies": { "json-logic-js": "^2.0.2", diff --git a/package.json b/package.json index f87e1036a..9d0e9d629 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@remoteoss/json-schema-form", - "version": "0.5.1-dev.20230913083845", + "version": "0.5.0-beta.0", "description": "Headless UI form powered by JSON Schemas", "author": "Remote.com (https://remote.com/)", "license": "MIT",