diff --git a/package-lock.json b/package-lock.json index 7a64d5962..c0e99e7af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.4.3-beta.0", "license": "MIT", "dependencies": { + "json-logic-js": "^2.0.2", "lodash": "^4.17.21", "randexp": "^0.5.3", "yup": "^0.30.0" @@ -6461,6 +6462,11 @@ "node": ">=4" } }, + "node_modules/json-logic-js": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/json-logic-js/-/json-logic-js-2.0.2.tgz", + "integrity": "sha512-ZBtBdMJieqQcH7IX/LaBsr5pX+Y5JIW+EhejtM3Ffg2jdN9Iwf+Ht6TbHnvAZ/YtwyuhPaCBlnvzrwVeWdvGDQ==" + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -13342,6 +13348,11 @@ "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", "dev": true }, + "json-logic-js": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/json-logic-js/-/json-logic-js-2.0.2.tgz", + "integrity": "sha512-ZBtBdMJieqQcH7IX/LaBsr5pX+Y5JIW+EhejtM3Ffg2jdN9Iwf+Ht6TbHnvAZ/YtwyuhPaCBlnvzrwVeWdvGDQ==" + }, "json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", diff --git a/package.json b/package.json index 9ae29288e..b1151cd36 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ ] }, "dependencies": { + "json-logic-js": "^2.0.2", "lodash": "^4.17.21", "randexp": "^0.5.3", "yup": "^0.30.0" diff --git a/src/calculateConditionalProperties.js b/src/calculateConditionalProperties.js index 385ca3484..0a4825b89 100644 --- a/src/calculateConditionalProperties.js +++ b/src/calculateConditionalProperties.js @@ -4,6 +4,7 @@ import omit from 'lodash/omit'; import { extractParametersFromNode } from './helpers'; import { supportedTypes } from './internals/fields'; import { getFieldDescription, pickXKey } from './internals/helpers'; +import { calculateComputedAttributes } from './jsonLogic'; import { buildYupSchema } from './yupSchema'; /** * @typedef {import('./createHeadlessForm').FieldParameters} FieldParameters @@ -69,14 +70,14 @@ function rebuildFieldset(fields, property) { * @param {FieldParameters} fieldParams - field parameters * @returns {Function} */ -export function calculateConditionalProperties(fieldParams, customProperties) { +export function calculateConditionalProperties(fieldParams, customProperties, validations, config) { /** * Runs dynamic property calculation on a field based on a conditional that has been calculated * @param {Boolean} isRequired - if the field is required * @param {Object} conditionBranch - condition branch being applied * @returns {Object} updated field parameters */ - return (isRequired, conditionBranch) => { + return (isRequired, conditionBranch, __, _, formValues) => { // Check if the current field is conditionally declared in the schema const conditionalProperty = conditionBranch?.properties?.[fieldParams.name]; @@ -98,17 +99,37 @@ export function calculateConditionalProperties(fieldParams, customProperties) { newFieldParams.fields = fieldSetFields; } + const { computedAttributes, ...restNewFieldParams } = newFieldParams; + const calculatedComputedAttributes = computedAttributes + ? calculateComputedAttributes(newFieldParams, config)({ validations, formValues }) + : {}; + + const requiredValidations = [ + ...(fieldParams.requiredValidations ?? []), + ...(restNewFieldParams.requiredValidations ?? []), + ]; + const base = { isVisible: true, required: isRequired, ...(presentation?.inputType && { type: presentation.inputType }), - schema: buildYupSchema({ - ...fieldParams, - ...newFieldParams, - // If there are inner fields (case of fieldset) they need to be updated based on the condition - fields: fieldSetFields, - required: isRequired, - }), + ...calculatedComputedAttributes, + ...(calculatedComputedAttributes.value + ? { value: calculatedComputedAttributes.value } + : { value: undefined }), + schema: buildYupSchema( + { + ...fieldParams, + ...restNewFieldParams, + ...calculatedComputedAttributes, + requiredValidations, + // If there are inner fields (case of fieldset) they need to be updated based on the condition + fields: fieldSetFields, + required: isRequired, + }, + config, + validations + ), }; return omit(merge(base, presentation, newFieldParams), ['inputType']); diff --git a/src/checkIfConditionMatches.js b/src/checkIfConditionMatches.js index 6108b60a8..235e37d94 100644 --- a/src/checkIfConditionMatches.js +++ b/src/checkIfConditionMatches.js @@ -7,8 +7,8 @@ import { hasProperty } from './utils'; * @param {Object} formValues - form state * @returns {Boolean} */ -export function checkIfConditionMatches(node, formValues, formFields) { - return Object.keys(node.if.properties).every((name) => { +export function checkIfConditionMatches(node, formValues, formFields, validations) { + return Object.keys(node.if.properties ?? {}).every((name) => { const currentProperty = node.if.properties[name]; const value = formValues[name]; const hasEmptyValue = @@ -50,7 +50,8 @@ export function checkIfConditionMatches(node, formValues, formFields) { return checkIfConditionMatches( { if: currentProperty }, formValues[name], - getField(name, formFields).fields + getField(name, formFields).fields, + validations ); } @@ -68,3 +69,30 @@ export function checkIfConditionMatches(node, formValues, formFields) { ); }); } + +export function checkIfMatchesValidationsAndComputedValues( + node, + formValues, + validations, + parentID +) { + const validationsMatch = Object.entries(node.if.validations ?? {}).every(([name, property]) => { + const currentValue = validations + .getScope(parentID) + .evaluateValidationRuleInCondition(name, formValues); + if (Object.hasOwn(property, 'const') && currentValue === property.const) return true; + return false; + }); + + const computedValuesMatch = Object.entries(node.if.computedValues ?? {}).every( + ([name, property]) => { + const currentValue = validations + .getScope(parentID) + .evaluateComputedValueRuleInCondition(name, formValues); + if (Object.hasOwn(property, 'const') && currentValue === property.const) return true; + return false; + } + ); + + return computedValuesMatch && validationsMatch; +} diff --git a/src/createHeadlessForm.js b/src/createHeadlessForm.js index 3bd7e7332..48b5728a2 100644 --- a/src/createHeadlessForm.js +++ b/src/createHeadlessForm.js @@ -24,6 +24,7 @@ import { getInputType, } from './internals/fields'; import { pickXKey } from './internals/helpers'; +import { calculateComputedAttributes, createValidationChecker } from './jsonLogic'; import { buildYupSchema } from './yupSchema'; // Some type definitions (to be migrated into .d.ts file or TS Interfaces) @@ -136,7 +137,8 @@ function buildFieldParameters(name, fieldProperties, required = [], config = {}) */ function convertJSONSchemaPropertiesToFieldParameters( { properties, required, 'x-jsf-order': order }, - config = {} + config = {}, + validations ) { const sortFields = (a, b) => sortByOrderOrPosition(a, b, order); @@ -144,7 +146,7 @@ function convertJSONSchemaPropertiesToFieldParameters( // their position and then remove the position property (since it's no longer needed) return Object.entries(properties) .filter(([, value]) => typeof value === 'object') - .map(([key, value]) => buildFieldParameters(key, value, required, config)) + .map(([key, value]) => buildFieldParameters(key, value, required, config, validations)) .sort(sortFields) .map(({ position, ...fieldParams }) => fieldParams); } @@ -187,6 +189,10 @@ function applyFieldsDependencies(fieldsParameters, node) { applyFieldsDependencies(fieldsParameters, condition); }); } + + if (node?.['x-jsf-logic']) { + applyFieldsDependencies(fieldsParameters, node['x-jsf-logic']); + } } /** @@ -222,19 +228,24 @@ function getComposeFunctionForField(fieldParams, hasCustomizations) { * @param {JsfConfig} config - parser config * @returns {Object} field object */ -function buildField(fieldParams, config, scopedJsonSchema) { +function buildField(fieldParams, config, scopedJsonSchema, validations) { const customProperties = getCustomPropertiesForField(fieldParams, config); const composeFn = getComposeFunctionForField(fieldParams, !!customProperties); - const yupSchema = buildYupSchema(fieldParams, config); + const yupSchema = buildYupSchema(fieldParams, config, validations); const calculateConditionalFieldsClosure = - fieldParams.isDynamic && calculateConditionalProperties(fieldParams, customProperties); + fieldParams.isDynamic && + calculateConditionalProperties(fieldParams, customProperties, validations, config); const calculateCustomValidationPropertiesClosure = calculateCustomValidationProperties( fieldParams, customProperties ); + const getComputedAttributes = + Object.keys(fieldParams.computedAttributes).length > 0 && + calculateComputedAttributes(fieldParams, config); + const hasCustomValidations = !!customProperties && size(pick(customProperties, SUPPORTED_CUSTOM_VALIDATION_FIELD_PARAMS)) > 0; @@ -250,6 +261,7 @@ function buildField(fieldParams, config, scopedJsonSchema) { ...(hasCustomValidations && { calculateCustomValidationProperties: calculateCustomValidationPropertiesClosure, }), + ...(getComputedAttributes && { getComputedAttributes }), // field customization properties ...(customProperties && { fieldCustomization: customProperties }), // base schema @@ -267,7 +279,7 @@ function buildField(fieldParams, config, scopedJsonSchema) { * @param {JsfConfig} config - JSON-schema-form config * @returns {ParserFields} ParserFields */ -function getFieldsFromJSONSchema(scopedJsonSchema, config) { +function getFieldsFromJSONSchema(scopedJsonSchema, config, validations) { if (!scopedJsonSchema) { // NOTE: other type of verifications might be needed. return []; @@ -299,11 +311,11 @@ function getFieldsFromJSONSchema(scopedJsonSchema, config) { addFieldText: fieldParams.addFieldText, }; - buildField(fieldParams, config, scopedJsonSchema).forEach((groupField) => { + buildField(fieldParams, config, scopedJsonSchema, validations).forEach((groupField) => { fields.push(groupField); }); } else { - fields.push(buildField(fieldParams, config, scopedJsonSchema)); + fields.push(buildField(fieldParams, config, scopedJsonSchema, validations)); } }); @@ -323,11 +335,17 @@ export function createHeadlessForm(jsonSchema, customConfig = {}) { }; try { - const fields = getFieldsFromJSONSchema(jsonSchema, config); + const validations = createValidationChecker(jsonSchema); + const fields = getFieldsFromJSONSchema(jsonSchema, config, validations); - const handleValidation = handleValuesChange(fields, jsonSchema, config); + const handleValidation = handleValuesChange(fields, jsonSchema, config, validations); - updateFieldsProperties(fields, getPrefillValues(fields, config.initialValues), jsonSchema); + updateFieldsProperties( + fields, + getPrefillValues(fields, config.initialValues), + jsonSchema, + validations + ); return { fields, diff --git a/src/helpers.js b/src/helpers.js index 47cb7b893..15f336647 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -8,6 +8,7 @@ import { lazy } from 'yup'; import { checkIfConditionMatches } from './checkIfConditionMatches'; import { supportedTypes, getInputType } from './internals/fields'; import { pickXKey } from './internals/helpers'; +import { processJSONLogicNode } from './jsonLogic'; import { containsHTML, hasProperty, wrapWithSpan } from './utils'; import { buildCompleteYupSchema, buildYupSchema } from './yupSchema'; @@ -40,8 +41,8 @@ export function getField(fieldName, fields) { * @param {any} value * @returns */ -export function validateFieldSchema(field, value) { - const validator = buildYupSchema(field); +export function validateFieldSchema(field, value, validations) { + const validator = buildYupSchema(field, {}, validations); return validator().isValidSync(value); } @@ -169,7 +170,7 @@ export function getPrefillValues(fields, initialValues = {}) { * @param {Object} node - JSON-schema node * @returns */ -function updateField(field, requiredFields, node, formValues) { +function updateField(field, requiredFields, node, formValues, validations, config) { // If there was an error building the field, it might not exist in the form even though // it can be mentioned in the schema so we return early in that case if (!field) { @@ -216,9 +217,27 @@ 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); + const newFieldValues = field.calculateConditionalProperties( + fieldIsRequired, + node, + validations, + config, + formValues + ); updateValues(newFieldValues); } @@ -246,42 +265,55 @@ function updateField(field, requiredFields, node, formValues) { * @param {Set} accRequired - set of required field names gathered by traversing the tree * @returns {Object} */ -function processNode(node, formValues, formFields, accRequired = new Set()) { +export function processNode({ + node, + formValues, + formFields, + accRequired = new Set(), + parentID = 'root', + validations, +}) { // Set initial required fields const requiredFields = new Set(accRequired); // 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) { - const matchesCondition = checkIfConditionMatches(node, formValues, formFields); + const matchesCondition = checkIfConditionMatches(node, formValues, formFields, validations); // BUG HERE (unreleated) - what if it matches but doesn't has a then, // it should do nothing, but instead it jumps to node.else when it shouldn't. if (matchesCondition && node.then) { - const { required: branchRequired } = processNode( - node.then, + const { required: branchRequired } = processNode({ + node: node.then, formValues, formFields, - requiredFields - ); + accRequired: requiredFields, + parentID, + validations, + }); branchRequired.forEach((field) => requiredFields.add(field)); } else if (node.else) { - const { required: branchRequired } = processNode( - node.else, + const { required: branchRequired } = processNode({ + node: node.else, formValues, formFields, - requiredFields - ); + accRequired: requiredFields, + parentID, + validations, + }); branchRequired.forEach((field) => requiredFields.add(field)); } } @@ -295,14 +327,23 @@ function processNode(node, formValues, formFields, accRequired = new Set()) { node.anyOf.forEach(({ required = [] }) => { required.forEach((fieldName) => { const field = getField(fieldName, formFields); - updateField(field, requiredFields, node, formValues); + updateField(field, requiredFields, node, formValues, validations, { parentID }); }); }); } if (node.allOf) { node.allOf - .map((allOfNode) => processNode(allOfNode, formValues, formFields, requiredFields)) + .map((allOfNode) => + processNode({ + node: allOfNode, + formValues, + formFields, + accRequired: requiredFields, + parentID, + validations, + }) + ) .forEach(({ required: allOfItemRequired }) => { allOfItemRequired.forEach(requiredFields.add, requiredFields); }); @@ -313,11 +354,29 @@ function processNode(node, formValues, formFields, accRequired = new Set()) { const inputType = getInputType(nestedNode); if (inputType === supportedTypes.FIELDSET) { // It's a fieldset, which might contain scoped conditions - processNode(nestedNode, formValues[name] || {}, getField(name, formFields).fields); + processNode({ + node: nestedNode, + formValues: formValues[name] || {}, + formFields: getField(name, formFields).fields, + validations, + parentID: name, + }); } }); } + if (node['x-jsf-logic']) { + const { required: requiredFromLogic } = processJSONLogicNode({ + node: node['x-jsf-logic'], + formValues, + formFields, + accRequired: requiredFields, + parentID, + validations, + }); + requiredFromLogic.forEach((field) => requiredFields.add(field)); + } + return { required: requiredFields, }; @@ -348,11 +407,11 @@ function clearValuesIfNotVisible(fields, formValues) { * @param {Object} formValues - current values of the form * @param {Object} jsonSchema - JSON schema object */ -export function updateFieldsProperties(fields, formValues, jsonSchema) { +export function updateFieldsProperties(fields, formValues, jsonSchema, validations) { if (!jsonSchema?.properties) { return; } - processNode(jsonSchema, formValues, fields); + processNode({ node: jsonSchema, formValues, formFields: fields, validations }); clearValuesIfNotVisible(fields, formValues); } @@ -415,6 +474,16 @@ 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']); @@ -425,6 +494,9 @@ export function extractParametersFromNode(schemaNode) { return omitBy( { + const: node.const, + // This is a "forced value" when both const and default are present. + ...(node.const && node.default ? { value: node.const } : {}), label: node.title, readOnly: node.readOnly, ...(node.deprecated && { @@ -460,6 +532,8 @@ export function extractParametersFromNode(schemaNode) { // Handle [name].presentation ...presentation, + requiredValidations, + computedAttributes: decoratedComputedAttributes, description: containsHTML(description) ? wrapWithSpan(description, { class: 'jsf-description', @@ -517,10 +591,10 @@ export function yupToFormErrors(yupError) { * @param {JsfConfig} config - jsf config * @returns {Function(values: Object): { YupError: YupObject, formErrors: Object }} Callback that returns Yup errors */ -export const handleValuesChange = (fields, jsonSchema, config) => (values) => { - updateFieldsProperties(fields, values, jsonSchema); +export const handleValuesChange = (fields, jsonSchema, config, validations) => (values) => { + updateFieldsProperties(fields, values, jsonSchema, validations); - const lazySchema = lazy(() => buildCompleteYupSchema(fields, config)); + const lazySchema = lazy(() => buildCompleteYupSchema(fields, config, validations)); let errors; try { diff --git a/src/jsonLogic.js b/src/jsonLogic.js new file mode 100644 index 000000000..49d7470cc --- /dev/null +++ b/src/jsonLogic.js @@ -0,0 +1,384 @@ +import jsonLogic from 'json-logic-js'; + +import { + checkIfConditionMatches, + checkIfMatchesValidationsAndComputedValues, +} from './checkIfConditionMatches'; +import { processNode } from './helpers'; +import { buildYupSchema } from './yupSchema'; + +/** + * Parses the JSON schema to extract the advanced validation logic and returns a set of functionality to check the current status of said rules. + * @param {Object} schema - JSON schema node + * @param {Object} initialValues - form state + * @returns {Object} + */ +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') + .forEach(([key, property]) => { + if (property.type === 'array') { + createScopes(property.items, `${key}[]`); + } + createScopes(property, key); + }); + + validateInlineRules(jsonSchema, sampleEmptyObject); + } + + createScopes(schema); + + return { + scopes, + getScope(name = 'root') { + return scopes.get(name); + }, + }; +} + +function createValidationsScope(schema) { + const validationMap = new Map(); + const computedValuesMap = new Map(); + + const logic = schema?.['x-jsf-logic'] ?? { + validations: {}, + computedValues: {}, + }; + + const validations = Object.entries(logic.validations ?? {}); + const computedValues = Object.entries(logic.computedValues ?? {}); + 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); + }); + + function evaluateValidation(rule, values) { + return jsonLogic.apply(rule, clean(values)); + } + + return { + validationMap, + computedValuesMap, + evaluateValidation, + evaluateValidationRuleInCondition(id, values) { + const validation = validationMap.get(id); + if (validation === undefined) + throw Error(`"${id}" validation in if condition doesn't exist.`); + + return evaluateValidation(validation.rule, values); + }, + evaluateComputedValueRuleForField(id, values, fieldName) { + const validation = computedValuesMap.get(id); + if (validation === undefined) + throw Error(`"${id}" computedValue in field "${fieldName}" doesn't exist.`); + + return evaluateValidation(validation.rule, values); + }, + evaluateComputedValueRuleInCondition(id, values) { + const validation = computedValuesMap.get(id); + if (validation === undefined) + throw Error(`"${id}" computedValue in if condition doesn't exist.`); + + return evaluateValidation(validation.rule, values); + }, + }; +} + +function clean(values = {}) { + return Object.entries(values).reduce((prev, [key, value]) => { + return { ...prev, [key]: value === undefined ? null : value }; + }, {}); +} + +/** + * Creates a Yup validation test function with custom JSON Logic for a specific field. + * + * @param {Object} options - The options for creating the validation function. + * @param {Object} options.field - The field configuration object. + * @param {string} options.field.name - The name of the field. + * @param {Object} options.validations - The validations object containing validation scopes and rules. + * @param {Object} options.config - Additional configuration options. + * @param {string} options.config.id - The ID of the validation rule. + * @param {string} [options.config.parentID='root'] - The ID of the validation rule scope. + * @returns {Function} A Yup validation test function. + */ +export function yupSchemaWithCustomJSONLogic({ field, validations, config, id }) { + const { parentID = 'root' } = config; + const validation = validations.getScope(parentID).validationMap.get(id); + + if (validation === undefined) { + throw Error(`Validation "${id}" required for "${field.name}" doesn't exist.`); + } + + return (yupSchema) => + yupSchema.test( + `${field.name}-validation-${id}`, + validation?.errorMessage ?? 'This field is invalid.', + (value, { parent }) => { + if (value === undefined && !field.required) return true; + return jsonLogic.apply(validation.rule, parent); + } + ); +} + +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; + + 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); + }); + } +} + +export function calculateComputedAttributes(fieldParams, { parentID = 'root' } = {}) { + return ({ validations, isRequired, config, formValues }) => { + const { name, computedAttributes } = fieldParams; + const attributes = Object.fromEntries( + Object.entries(computedAttributes) + .map(handleComputedAttribute(validations, formValues, parentID, name)) + .filter(([, value]) => value !== null) + ); + + return { + ...attributes, + schema: buildYupSchema( + { ...fieldParams, ...attributes, required: isRequired }, + config, + validations + ), + }; + }; +} + +function handleComputedAttribute(validations, formValues, parentID, name) { + return ([key, value]) => { + if (key === 'description') + return [key, replaceHandlebarsTemplates({ value, validations, formValues, parentID, name })]; + + if (key === 'title') { + return [ + 'label', + replaceHandlebarsTemplates({ value, validations, formValues, parentID, name }), + ]; + } + + if (key === 'const') + return [ + key, + validations.getScope(parentID).evaluateComputedValueRuleForField(value, formValues, name), + ]; + + if (key === 'x-jsf-errorMessage') { + return [ + 'errorMessage', + handleNestedObjectForComputedValues(value, formValues, parentID, validations, name), + ]; + } + + if (typeof value === 'string') { + return [ + key, + validations.getScope(parentID).evaluateComputedValueRuleForField(value, formValues, name), + ]; + } + + if (key === 'x-jsf-presentation' && value.statement) { + return [ + 'statement', + handleNestedObjectForComputedValues( + value.statement, + formValues, + parentID, + validations, + name + ), + ]; + } + + if (typeof value === 'object' && value.rule) { + return [key, validations.getScope(parentID).evaluateValidation(value.rule, formValues)]; + } + }; +} + +function handleNestedObjectForComputedValues(values, formValues, parentID, validations, name) { + return Object.fromEntries( + Object.entries(values).map(([key, value]) => { + return [key, replaceHandlebarsTemplates({ value, validations, formValues, parentID, name })]; + }) + ); +} + +export function processJSONLogicNode({ + node, + formFields, + formValues, + accRequired, + parentID, + validations, +}) { + const requiredFields = new Set(accRequired); + + if (node.allOf) { + node.allOf + .map((allOfNode) => + processJSONLogicNode({ node: allOfNode, formValues, formFields, validations, parentID }) + ) + .forEach(({ required: allOfItemRequired }) => { + allOfItemRequired.forEach(requiredFields.add, requiredFields); + }); + } + + if (node.if) { + const matchesPropertyCondition = checkIfConditionMatches( + node, + formValues, + formFields, + validations + ); + const matchesValidationsAndComputedValues = checkIfMatchesValidationsAndComputedValues( + node, + formValues, + validations, + parentID + ); + + const isConditionMatch = matchesPropertyCondition && matchesValidationsAndComputedValues; + + if (isConditionMatch && node.then) { + const { required: branchRequired } = processNode({ + node: node.then, + formValues, + formFields, + accRequired, + validations, + }); + branchRequired.forEach((field) => requiredFields.add(field)); + } + if (!isConditionMatch && node.else) { + const { required: branchRequired } = processNode({ + node: node.else, + formValues, + formFields, + accRequired: requiredFields, + validations, + }); + branchRequired.forEach((field) => requiredFields.add(field)); + } + } + + return { required: requiredFields }; +} + +function buildSampleEmptyObject(schema = {}) { + const sample = {}; + if (typeof schema !== 'object' || !schema.properties) { + 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 new file mode 100644 index 000000000..a57ca4dd1 --- /dev/null +++ b/src/tests/jsonLogic.test.js @@ -0,0 +1,591 @@ +import { createHeadlessForm } from '../createHeadlessForm'; + +import { + createSchemaWithRulesOnFieldA, + createSchemaWithThreePropertiesWithRuleOnFieldA, + multiRuleSchema, + schemaSelfContainedValueForMaximumMinimumValues, + schemaSelfContainedValueForTitleWithNoTemplate, + schemaWhereValidationAndComputedValueIsAppliedOnNormalThenStatement, + schemaWithChecksAndThenValidationsOnThen, + schemaWithComputedAttributeThatDoesntExist, + schemaWithComputedAttributeThatDoesntExistDescription, + schemaWithComputedAttributeThatDoesntExistTitle, + schemaWithComputedAttributes, + schemaWithComputedAttributesAndErrorMessages, + schemaWithComputedValueChecksInIf, + schemaWithDeepVarThatDoesNotExist, + schemaWithDeepVarThatDoesNotExistOnFieldset, + schemaWithGreaterThanChecksForThreeFields, + schemaWithIfStatementWithComputedValuesAndValidationChecks, + schemaWithInlineMultipleRulesForComputedAttributes, + schemaWithInlineRuleForComputedAttributeWithCopy, + schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar, + schemaWithJSFLogicAndInlineRule, + schemaWithMissingComputedValue, + schemaWithMissingRule, + schemaWithMissingValueInlineRule, + schemaWithMultipleComputedValueChecks, + schemaWithNativeAndJSONLogicChecks, + schemaWithNonRequiredField, + schemaWithPropertiesCheckAndValidationsInAIf, + schemaWithPropertyThatDoesNotExistInThatLevelButDoesInFieldset, + schemaWithTwoRules, + schemaWithTwoValidationsWhereOneOfThemIsAppliedConditionally, + schemaWithValidationThatDoesNotExistOnProperty, + schemaWithVarThatDoesNotExist, +} from './jsonLogicFixtures'; + +describe('cross-value validations', () => { + describe('Does not conflict with native JSON schema', () => { + it('When a field is not required, validations should not block submitting when its an empty value', () => { + const { handleValidation } = createHeadlessForm(schemaWithNonRequiredField, { + strictInputType: false, + }); + expect(handleValidation({}).formErrors).toEqual(undefined); + expect(handleValidation({ field_a: 0 }).formErrors).toEqual({ + field_a: 'Must be greater than 10', + }); + expect(handleValidation({ field_a: 'incorrect value' }).formErrors).toEqual({ + field_a: 'The value must be a number', + }); + expect(handleValidation({ field_a: 11 }).formErrors).toEqual(undefined); + }); + + it('Native validations always appear first', () => { + const { handleValidation } = createHeadlessForm(schemaWithNativeAndJSONLogicChecks, { + strictInputType: false, + }); + expect(handleValidation({}).formErrors).toEqual({ field_a: 'Required field' }); + expect(handleValidation({ field_a: 0 }).formErrors).toEqual({ + field_a: 'Must be greater or equal to 5', + }); + expect(handleValidation({ field_a: 5 }).formErrors).toEqual({ + field_a: 'Must be greater than 10', + }); + }); + }); + + describe('Relative: <, >, =', () => { + it('bigger: field_a > field_b', () => { + const schema = createSchemaWithRulesOnFieldA({ + a_greater_than_b: { + errorMessage: 'Field A must be bigger than field B', + rule: { '>': [{ var: 'field_a' }, { var: 'field_b' }] }, + }, + }); + const { handleValidation } = createHeadlessForm(schema, { strictInputType: false }); + const { formErrors } = handleValidation({ field_a: 1, field_b: 2 }); + expect(formErrors.field_a).toEqual('Field A must be bigger than field B'); + expect(handleValidation({ field_a: 2, field_b: 0 }).formErrors).toEqual(undefined); + }); + + it('smaller: field_a < field_b', () => { + const schema = createSchemaWithRulesOnFieldA({ + a_less_than_b: { + errorMessage: 'Field A must be smaller than field B', + rule: { '<': [{ var: 'field_a' }, { var: 'field_b' }] }, + }, + }); + const { handleValidation } = createHeadlessForm(schema, { strictInputType: false }); + const { formErrors } = handleValidation({ field_a: 2, field_b: 2 }); + expect(formErrors.field_a).toEqual('Field A must be smaller than field B'); + expect(handleValidation({ field_a: 0, field_b: 2 }).formErrors).toEqual(undefined); + }); + + it('equal: field_a = field_b', () => { + const schema = createSchemaWithRulesOnFieldA({ + a_equals_b: { + errorMessage: 'Field A must equal field B', + rule: { '==': [{ var: 'field_a' }, { var: 'field_b' }] }, + }, + }); + const { handleValidation } = createHeadlessForm(schema, { strictInputType: false }); + const { formErrors } = handleValidation({ field_a: 3, field_b: 2 }); + expect(formErrors.field_a).toEqual('Field A must equal field B'); + expect(handleValidation({ field_a: 2, field_b: 2 }).formErrors).toEqual(undefined); + }); + }); + + describe('Incorrectly written schemas', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + console.error.mockRestore(); + }); + + it('Should throw when a var does not exist in a rule.', () => { + createHeadlessForm(schemaWithVarThatDoesNotExist, { strictInputType: false }); + expect(console.error).toHaveBeenCalledWith( + 'JSON Schema invalid!', + Error('"field_b" in rule "a_greater_than_ten" does not exist as a JSON schema property.') + ); + }); + + it('Should throw when a var does not exist in a deeply nested rule', () => { + createHeadlessForm(schemaWithDeepVarThatDoesNotExist, { strictInputType: false }); + expect(console.error).toHaveBeenCalledWith( + 'JSON Schema invalid!', + Error('"field_b" in rule "a_greater_than_ten" does not exist as a JSON schema property.') + ); + }); + + it('Should throw when a var does not exist in a fieldset.', () => { + createHeadlessForm(schemaWithDeepVarThatDoesNotExistOnFieldset, { strictInputType: false }); + expect(console.error).toHaveBeenCalledWith( + 'JSON Schema invalid!', + Error('"field_a" in rule "a_greater_than_ten" does not exist as a JSON schema property.') + ); + }); + + it('On a property, it should throw an error for a requiredValidation that does not exist', () => { + createHeadlessForm(schemaWithValidationThatDoesNotExistOnProperty, { + strictInputType: false, + }); + expect(console.error).toHaveBeenCalledWith( + 'JSON Schema invalid!', + Error(`Validation "iDontExist" required for "field_a" doesn't exist.`) + ); + }); + + it('A top level logic keyword will not be able to reference fieldset properties', () => { + createHeadlessForm(schemaWithPropertyThatDoesNotExistInThatLevelButDoesInFieldset, { + strictInputType: false, + }); + expect(console.error).toHaveBeenCalledWith( + 'JSON Schema invalid!', + Error('"child" in rule "validation_parent" does not exist as a JSON schema property.') + ); + }); + + it('Should throw when theres a missing rule', () => { + createHeadlessForm(schemaWithMissingRule, { strictInputType: false }); + expect(console.error).toHaveBeenCalledWith( + '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({ + a_greater_than_b_multiplied_by_2: { + errorMessage: 'Field A must be at least twice as big as field b', + rule: { '>': [{ var: 'field_a' }, { '*': [{ var: 'field_b' }, 2] }] }, + }, + }); + const { handleValidation } = createHeadlessForm(schema, { strictInputType: false }); + + const { formErrors } = handleValidation({ field_a: 1, field_b: 4 }); + expect(formErrors.field_a).toEqual('Field A must be at least twice as big as field b'); + expect(handleValidation({ field_a: 3, field_b: 1 }).formErrors).toEqual(undefined); + }); + + it('divide: field_a > field_b / 2', () => { + const { handleValidation } = createHeadlessForm( + createSchemaWithRulesOnFieldA({ + a_greater_than_b_divided_by_2: { + errorMessage: 'Field A must be greater than field_b / 2', + rule: { '>': [{ var: 'field_a' }, { '/': [{ var: 'field_b' }, 2] }] }, + }, + }), + { strictInputType: false } + ); + const { formErrors } = handleValidation({ field_a: 2, field_b: 4 }); + expect(formErrors.field_a).toEqual('Field A must be greater than field_b / 2'); + expect(handleValidation({ field_a: 3, field_b: 5 }).formErrors).toEqual(undefined); + }); + + it('sum: field_a > field_b + field_c', () => { + const schema = createSchemaWithThreePropertiesWithRuleOnFieldA({ + a_is_greater_than_b_plus_c: { + errorMessage: 'Field A must be greater than field_b and field_b added together', + rule: { + '>': [{ var: 'field_a' }, { '+': [{ var: 'field_b' }, { var: 'field_c' }] }], + }, + }, + }); + const { handleValidation } = createHeadlessForm(schema, { strictInputType: false }); + const { formErrors } = handleValidation({ field_a: 0, field_b: 1, field_c: 2 }); + expect(formErrors.field_a).toEqual( + 'Field A must be greater than field_b and field_b added together' + ); + expect(handleValidation({ field_a: 4, field_b: 1, field_c: 2 }).formErrors).toEqual( + undefined + ); + }); + }); + + describe('Logical: ||, &&', () => { + it('AND: field_a > field_b && field_a > field_c (implicit with multiple rules in a single field)', () => { + const schema = createSchemaWithThreePropertiesWithRuleOnFieldA({ + a_is_greater_than_b: { + errorMessage: 'Field A must be greater than field_b', + rule: { + '>': [{ var: 'field_a' }, { var: 'field_b' }], + }, + }, + a_is_greater_than_c: { + errorMessage: 'Field A must be greater than field_c', + rule: { + '>': [{ var: 'field_a' }, { var: 'field_c' }], + }, + }, + }); + const { handleValidation } = createHeadlessForm(schema, { strictInputType: false }); + expect(handleValidation({ field_a: 1, field_b: 10, field_c: 0 }).formErrors.field_a).toEqual( + 'Field A must be greater than field_b' + ); + expect(handleValidation({ field_a: 1, field_b: 0, field_c: 10 }).formErrors.field_a).toEqual( + 'Field A must be greater than field_c' + ); + expect(handleValidation({ field_a: 10, field_b: 5, field_c: 5 }).formErrors).toEqual( + undefined + ); + }); + + it('OR: field_a > field_b or field_a > field_c', () => { + const schema = createSchemaWithThreePropertiesWithRuleOnFieldA({ + field_a_is_greater_than_b_or_c: { + errorMessage: 'Field A must be greater than field_b or field_c', + rule: { + or: [ + { '>': [{ var: 'field_a' }, { var: 'field_b' }] }, + { '>': [{ var: 'field_a' }, { var: 'field_c' }] }, + ], + }, + }, + }); + const { handleValidation } = createHeadlessForm(schema, { strictInputType: false }); + expect(handleValidation({ field_a: 0, field_b: 10, field_c: 10 }).formErrors.field_a).toEqual( + 'Field A must be greater than field_b or field_c' + ); + expect(handleValidation({ field_a: 1, field_b: 0, field_c: 10 }).formErrors).toEqual( + undefined + ); + expect(handleValidation({ field_a: 10, field_b: 5, field_c: 5 }).formErrors).toEqual( + undefined + ); + }); + }); + + describe('Conditionals', () => { + it('when field_a > field_b, show field_c', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaWithGreaterThanChecksForThreeFields, + { strictInputType: false } + ); + expect(fields.find((i) => i.name === 'field_c').isVisible).toEqual(false); + + expect(handleValidation({ field_a: 1, field_b: 3 }).formErrors).toEqual(undefined); + expect(handleValidation({ field_a: 1, field_b: null }).formErrors).toEqual({ + field_b: 'Required field', + }); + expect(handleValidation({ field_a: 10, field_b: 3 }).formErrors).toEqual({ + field_c: 'Required field', + }); + expect(handleValidation({ field_a: 10, field_b: 3, field_c: 0 }).formErrors).toEqual( + undefined + ); + }); + + it('A schema with both a `x-jsf-validations` and `properties` check', () => { + const { handleValidation } = createHeadlessForm( + schemaWithPropertiesCheckAndValidationsInAIf, + { strictInputType: false } + ); + expect(handleValidation({ field_a: 1, field_b: 3 }).formErrors).toEqual(undefined); + expect(handleValidation({ field_a: 10, field_b: 3 }).formErrors).toEqual({ + field_c: 'Required field', + }); + expect(handleValidation({ field_a: 5, field_b: 3 }).formErrors).toEqual(undefined); + }); + + it('Conditionally apply a validation on a property depending on values', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaWithChecksAndThenValidationsOnThen, + { strictInputType: false } + ); + const cField = fields.find((i) => i.name === 'field_c'); + expect(cField.isVisible).toEqual(false); + expect(cField.description).toEqual(undefined); + expect(handleValidation({ field_a: 10, field_b: 5 }).formErrors).toEqual({ + field_c: 'Required field', + }); + expect(handleValidation({ field_a: 10, field_b: 5, field_c: 0 }).formErrors).toEqual({ + field_c: 'Needs more numbers', + }); + expect(cField.description).toBe('I am a description!'); + expect(handleValidation({ field_a: 10, field_b: 5, field_c: 201 }).formErrors).toEqual( + undefined + ); + }); + + it('Should apply a conditional based on a true computedValue', () => { + const { fields, handleValidation } = createHeadlessForm(schemaWithComputedValueChecksInIf, { + strictInputType: false, + }); + const cField = fields.find((i) => i.name === 'field_c'); + expect(cField.isVisible).toEqual(false); + expect(cField.description).toEqual(undefined); + expect(handleValidation({ field_a: 10, field_b: 5 }).formErrors).toEqual({ + field_c: 'Required field', + }); + expect(handleValidation({ field_a: 10, field_b: 5, field_c: 201 }).formErrors).toEqual( + undefined + ); + }); + + it('Handle multiple computedValue checks by ANDing them together', () => { + const { handleValidation } = createHeadlessForm(schemaWithMultipleComputedValueChecks, { + strictInputType: false, + }); + expect(handleValidation({}).formErrors).toEqual({ + field_a: 'Required field', + field_b: 'Required field', + }); + expect(handleValidation({ field_a: 10, field_b: 8 }).formErrors).toEqual({ + field_c: 'Required field', + }); + expect(handleValidation({ field_a: 10, field_b: 8, field_c: 0 }).formErrors).toEqual({ + field_c: 'Must be two times B', + }); + expect(handleValidation({ field_a: 10, field_b: 8, field_c: 17 }).formErrors).toEqual( + undefined + ); + }); + + it('Handle having a true condition with both validations and computedValue checks', () => { + const { handleValidation } = createHeadlessForm( + schemaWithIfStatementWithComputedValuesAndValidationChecks, + { strictInputType: false } + ); + expect(handleValidation({ field_a: 1, field_b: 1 }).formErrors).toEqual(undefined); + expect(handleValidation({ field_a: 10, field_b: 20 }).formErrors).toEqual(undefined); + expect(handleValidation({ field_a: 10, field_b: 9 }).formErrors).toEqual({ + field_c: 'Required field', + }); + expect(handleValidation({ field_a: 10, field_b: 9, field_c: 10 }).formErrors).toEqual( + undefined + ); + }); + + it('Apply validations and computed values on normal if statement.', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaWhereValidationAndComputedValueIsAppliedOnNormalThenStatement, + { strictInputType: false } + ); + expect(handleValidation({ field_a: 0, field_b: 0 }).formErrors).toEqual(undefined); + expect(handleValidation({ field_a: 20, field_b: 0 }).formErrors).toEqual({ + field_b: 'Must be greater than Field A + 10', + }); + const [, fieldB] = fields; + expect(fieldB.label).toEqual('Must be greater than 30.'); + expect(handleValidation({ field_a: 20, field_b: 31 }).formErrors).toEqual(undefined); + }); + + it('When we have a required validation on a top level property and another validation is added, both should be accounted for.', () => { + const { handleValidation } = createHeadlessForm( + schemaWithTwoValidationsWhereOneOfThemIsAppliedConditionally, + { strictInputType: false } + ); + expect(handleValidation({ field_a: 10, field_b: 0 }).formErrors).toEqual({ + field_b: 'Must be greater than A', + }); + expect(handleValidation({ field_a: 10, field_b: 20 }).formErrors).toEqual(undefined); + expect(handleValidation({ field_a: 20, field_b: 10 }).formErrors).toEqual({ + field_b: 'Must be greater than A', + }); + expect(handleValidation({ field_a: 20, field_b: 21 }).formErrors).toEqual({ + field_b: 'Must be greater than two times A', + }); + expect(handleValidation({ field_a: 20, field_b: 41 }).formErrors).toEqual(); + }); + }); + + describe('Multiple validations', () => { + it('2 rules where A must be bigger than B and not an even number in another rule', () => { + const { handleValidation } = createHeadlessForm(multiRuleSchema, { strictInputType: false }); + expect(handleValidation({ field_a: 1 }).formErrors).toEqual({ + field_a: 'A must be even', + field_b: 'Required field', + }); + expect(handleValidation({ field_a: 1, field_b: 2 }).formErrors).toEqual({ + field_a: 'A must be bigger than B', + }); + expect(handleValidation({ field_a: 3, field_b: 2 }).formErrors).toEqual({ + field_a: 'A must be even', + }); + expect(handleValidation({ field_a: 4, field_b: 2 }).formErrors).toEqual(undefined); + }); + + it('2 seperate fields with rules failing', () => { + const { handleValidation } = createHeadlessForm(schemaWithTwoRules, { + strictInputType: false, + }); + expect(handleValidation({ field_a: 1, field_b: 3 }).formErrors).toEqual({ + field_a: 'A must be bigger than B', + field_b: 'B must be even', + }); + expect(handleValidation({ field_a: 4, field_b: 2 }).formErrors).toEqual(undefined); + }); + }); + + 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.description).toEqual( + 'This field is 2 times bigger than field_a with value of 4.' + ); + expect(fieldB.default).toEqual(4); + expect(fieldB.value).toEqual(4); + expect(fieldB.label).toEqual('This is 4!'); + }); + + it('Derived errorMessages and statements work', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaWithComputedAttributesAndErrorMessages, + { strictInputType: false } + ); + const fieldB = fields.find((i) => i.name === 'field_b'); + expect(handleValidation({ field_a: 2, field_b: 0 }).formErrors).toEqual({ + field_b: 'Must be bigger than 4', + }); + expect(handleValidation({ field_a: 2, field_b: 100 }).formErrors).toEqual({ + field_b: 'Must be smaller than 8', + }); + expect(fieldB.minimum).toEqual(4); + expect(fieldB.maximum).toEqual(8); + expect(fieldB.statement).toEqual({ description: 'Must be bigger than 4 and smaller than 8' }); + }); + + it('Use a self contained rule in a schema for a title attribute', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaWithInlineRuleForComputedAttributeWithCopy, + { + strictInputType: false, + } + ); + const [, fieldB] = fields; + expect(handleValidation({ field_a: 0, field_b: null }).formErrors).toEqual(undefined); + expect(fieldB.label).toEqual('I need this to work using the 10.'); + expect(handleValidation({ field_a: 10 }).formErrors).toEqual(undefined); + expect(fieldB.label).toEqual('I need this to work using the 20.'); + }); + + it('Use multiple inline rules with different identifiers', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaWithInlineMultipleRulesForComputedAttributes, + { + strictInputType: false, + } + ); + const [, fieldB] = fields; + expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toEqual(undefined); + expect(fieldB.description).toEqual('Must be between 5 and 20.'); + }); + + it('Use a self contained rule in a schema for a title but it just uses the value', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaSelfContainedValueForTitleWithNoTemplate, + { + strictInputType: false, + } + ); + const [, fieldB] = fields; + expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toEqual(undefined); + expect(fieldB.label).toEqual('20'); + }); + + it('Use a self contained rule for a minimum, maximum value', () => { + const { handleValidation } = createHeadlessForm( + schemaSelfContainedValueForMaximumMinimumValues, + { + strictInputType: false, + } + ); + expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toEqual(undefined); + expect(handleValidation({ field_a: 50, field_b: 20 }).formErrors).toEqual({ + field_b: 'Must be greater or equal to 40', + }); + expect(handleValidation({ field_a: 50, field_b: 70 }).formErrors).toEqual({ + field_b: 'Must be smaller or equal to 60', + }); + expect(handleValidation({ field_a: 50, field_b: 50 }).formErrors).toEqual(undefined); + }); + + it('Mix use of multiple inline rules and an external rule', () => { + const { fields, handleValidation } = createHeadlessForm(schemaWithJSFLogicAndInlineRule, { + strictInputType: false, + }); + handleValidation({ field_a: 10 }); + const [, fieldB] = fields; + expect(fieldB.label).toEqual('Going to use 20 and 4'); + }); + }); +}); diff --git a/src/tests/jsonLogicFixtures.js b/src/tests/jsonLogicFixtures.js new file mode 100644 index 000000000..b501b67e3 --- /dev/null +++ b/src/tests/jsonLogicFixtures.js @@ -0,0 +1,961 @@ +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 schemaWithMissingValueInlineRule = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: { + ruleOne: { + '+': [1, 2], + }, + ruleTwo: { + '+': [3, 4], + }, + }, + }, + }, + }, + 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 schemaWithComputedAttributeThatDoesntExist = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + default: 'iDontExist', + }, + }, + }, +}; + +export const schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: { + rule: { + '+': [{ var: 'IdontExist' }], + }, + }, + }, + }, + }, +}; + +export const schemaWithComputedAttributeThatDoesntExistTitle = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: `this doesn't exist {{iDontExist}}`, + }, + }, + }, +}; + +export const schemaWithComputedAttributeThatDoesntExistDescription = { + properties: { + field_a: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + description: `this doesn't exist {{iDontExist}}`, + }, + }, + }, +}; + +export const schemaWithGreaterThanChecksForThreeFields = { + properties: { + field_a: { + type: 'number', + }, + 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'], + }, + }, + }, + }, + ], +}; + +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 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'], +}; + +export const schemaWithInlineRuleForComputedAttributeWithCopy = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: { + value: 'I need this to work using the {{rule}}.', + rule: { + '+': [{ var: 'field_a' }, 10], + }, + }, + }, + }, + }, +}; + +export const schemaWithInlineRuleForComputedAttributeWithoutCopy = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: { + rule: { + '+': [{ var: 'field_a' }, 10], + }, + }, + }, + }, + }, +}; + +export const schemaWithInlineMultipleRulesForComputedAttributes = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + description: { + value: 'Must be between {{half_a}} and {{double_a}}.', + half_a: { + '/': [{ var: 'field_a' }, 2], + }, + double_a: { + '*': [{ var: 'field_a' }, 2], + }, + }, + }, + }, + }, +}; + +export const schemaSelfContainedValueForTitleWithNoTemplate = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: { + value: '{{rule}}', + rule: { + '+': [{ var: 'field_a' }, 10], + }, + }, + }, + }, + }, +}; + +export const schemaSelfContainedValueForMaximumMinimumValues = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + maximum: { + rule: { + '+': [{ var: 'field_a' }, 10], + }, + }, + minimum: { + rule: { + '-': [{ var: 'field_a' }, 10], + }, + }, + }, + }, + }, +}; + +export const schemaWithJSFLogicAndInlineRule = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-computedAttrs': { + title: { + value: 'Going to use {{rule}} and {{not_inline}}', + rule: { + '+': [{ var: 'field_a' }, 10], + }, + }, + }, + }, + }, + 'x-jsf-logic': { + computedValues: { + not_inline: { + rule: { + '+': [1, 3], + }, + }, + }, + }, +}; + +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/yupSchema.js b/src/yupSchema.js index 42f30a30f..0c8bee152 100644 --- a/src/yupSchema.js +++ b/src/yupSchema.js @@ -4,6 +4,7 @@ import { randexp } from 'randexp'; import { string, number, boolean, object, array } from 'yup'; import { supportedTypes } from './internals/fields'; +import { yupSchemaWithCustomJSONLogic } from './jsonLogic'; import { convertDiskSizeFromTo } from './utils'; /** @@ -196,7 +197,7 @@ const getYupSchema = ({ inputType, ...field }) => { * @param {FieldParameters} field Input fields * @returns {Function} Yup schema */ -export function buildYupSchema(field, config) { +export function buildYupSchema(field, config, validations) { const { inputType, jsonType: jsonTypeValue, errorMessage = {}, ...propertyFields } = field; const isCheckboxBoolean = typeof propertyFields.checkboxValue === 'boolean'; let baseSchema; @@ -388,6 +389,13 @@ export function buildYupSchema(field, config) { if (propertyFields.accept) { validators.push(withFileFormat); } + + if (propertyFields.requiredValidations) { + propertyFields.requiredValidations.forEach((id) => + validators.push(yupSchemaWithCustomJSONLogic({ field, id, validations, config })) + ); + } + return flow(validators); }