diff --git a/src/calculateConditionalProperties.js b/src/calculateConditionalProperties.js index 385ca3484..5471c9876 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 @@ -64,19 +65,29 @@ function rebuildFieldset(fields, property) { } /** - * Builds a function that updates the fields properties based on the form values and the - * dependencies the field has on the current schema. - * @param {FieldParameters} fieldParams - field parameters - * @returns {Function} + * Builds a function that updates the field properties based on the form values, + * schema dependencies, and conditional logic. + * + * @param {Object} params - Parameters + * @param {Object} params.fieldParams - Current field parameters + * @param {Object} params.customProperties - Custom field properties from schema + * @param {Object} params.logic - JSON-logic + * @param {Object} params.config - Form configuration + * + * @returns {Function} A function that calculates conditional properties */ -export function calculateConditionalProperties(fieldParams, customProperties) { +export function calculateConditionalProperties({ fieldParams, customProperties, logic, config }) { /** * Runs dynamic property calculation on a field based on a conditional that has been calculated - * @param {Boolean} isRequired - if the field is required - * @param {Object} conditionBranch - condition branch being applied - * @returns {Object} updated field parameters + * + * @param {Object} params - Parameters + * @param {Boolean} params.isRequired - If field is required + * @param {Object} params.conditionBranch - Condition branch + * @param {Object} params.formValues - Current form values + * + * @returns {Object} Updated field parameters */ - return (isRequired, conditionBranch) => { + return ({ isRequired, conditionBranch, formValues }) => { // Check if the current field is conditionally declared in the schema const conditionalProperty = conditionBranch?.properties?.[fieldParams.name]; @@ -98,17 +109,37 @@ export function calculateConditionalProperties(fieldParams, customProperties) { newFieldParams.fields = fieldSetFields; } + const { computedAttributes, ...restNewFieldParams } = newFieldParams; + const calculatedComputedAttributes = computedAttributes + ? calculateComputedAttributes(newFieldParams, config)({ logic, formValues }) + : {}; + + const jsonLogicValidations = [ + ...(fieldParams.jsonLogicValidations ?? []), + ...(restNewFieldParams.jsonLogicValidations ?? []), + ]; + 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, + jsonLogicValidations, + // If there are inner fields (case of fieldset) they need to be updated based on the condition + fields: fieldSetFields, + required: isRequired, + }, + config, + logic + ), }; return omit(merge(base, presentation, newFieldParams), ['inputType']); diff --git a/src/checkIfConditionMatches.js b/src/checkIfConditionMatches.js index 6108b60a8..cb7c50cbc 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 checkIfConditionMatchesProperties(node, formValues, formFields, logic) { + return Object.keys(node.if.properties ?? {}).every((name) => { const currentProperty = node.if.properties[name]; const value = formValues[name]; const hasEmptyValue = @@ -47,10 +47,11 @@ export function checkIfConditionMatches(node, formValues, formFields) { } if (currentProperty.properties) { - return checkIfConditionMatches( + return checkIfConditionMatchesProperties( { if: currentProperty }, formValues[name], - getField(name, formFields).fields + getField(name, formFields).fields, + logic ); } @@ -68,3 +69,23 @@ export function checkIfConditionMatches(node, formValues, formFields) { ); }); } + +export function checkIfMatchesValidationsAndComputedValues(node, formValues, logic, parentID) { + const validationsMatch = Object.entries(node.if.validations ?? {}).every(([name, property]) => { + const currentValue = logic.getScope(parentID).applyValidationRuleInCondition(name, formValues); + if (Object.hasOwn(property, 'const') && currentValue === property.const) return true; + return false; + }); + + const computedValuesMatch = Object.entries(node.if.computedValues ?? {}).every( + ([name, property]) => { + const currentValue = logic + .getScope(parentID) + .applyComputedValueRuleInCondition(name, formValues); + if (Object.hasOwn(property, 'const') && currentValue === property.const) return true; + return false; + } + ); + + return computedValuesMatch && validationsMatch; +} diff --git a/src/createHeadlessForm.js b/src/createHeadlessForm.js index d97643085..d76183534 100644 --- a/src/createHeadlessForm.js +++ b/src/createHeadlessForm.js @@ -100,7 +100,7 @@ function removeInvalidAttributes(fields) { * * @returns {FieldParameters} */ -function buildFieldParameters(name, fieldProperties, required = [], config = {}) { +function buildFieldParameters(name, fieldProperties, required = [], config = {}, logic) { const { position } = pickXKey(fieldProperties, 'presentation') ?? {}; let fields; @@ -108,9 +108,14 @@ function buildFieldParameters(name, fieldProperties, required = [], config = {}) if (inputType === supportedTypes.FIELDSET) { // eslint-disable-next-line no-use-before-define - fields = getFieldsFromJSONSchema(fieldProperties, { - customProperties: get(config, `customProperties.${name}`, {}), - }); + fields = getFieldsFromJSONSchema( + fieldProperties, + { + customProperties: get(config, `customProperties.${name}`, {}), + parentID: name, + }, + logic + ); } const result = { @@ -235,7 +240,8 @@ function buildField(fieldParams, config, scopedJsonSchema, logic) { const yupSchema = buildYupSchema(fieldParams, config, logic); const calculateConditionalFieldsClosure = - fieldParams.isDynamic && calculateConditionalProperties(fieldParams, customProperties); + fieldParams.isDynamic && + calculateConditionalProperties({ fieldParams, customProperties, logic, config }); const calculateCustomValidationPropertiesClosure = calculateCustomValidationProperties( fieldParams, diff --git a/src/helpers.js b/src/helpers.js index 4078f3e79..5577c7ca2 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -5,9 +5,10 @@ import omitBy from 'lodash/omitBy'; import set from 'lodash/set'; import { lazy } from 'yup'; -import { checkIfConditionMatches } from './checkIfConditionMatches'; +import { checkIfConditionMatchesProperties } from './checkIfConditionMatches'; import { supportedTypes, getInputType } from './internals/fields'; import { pickXKey } from './internals/helpers'; +import { processJSONLogicNode } from './jsonLogic'; import { containsHTML, hasProperty, wrapWithSpan } from './utils'; import { buildCompleteYupSchema, buildYupSchema } from './yupSchema'; @@ -240,7 +241,11 @@ function updateField(field, requiredFields, node, formValues, logic, config) { // If field has a calculateConditionalProperties closure, run it and update the field properties if (field.calculateConditionalProperties) { - const newFieldValues = field.calculateConditionalProperties(fieldIsRequired, node); + const newFieldValues = field.calculateConditionalProperties({ + isRequired: fieldIsRequired, + conditionBranch: node, + formValues, + }); updateValues(newFieldValues); } @@ -294,7 +299,7 @@ export function processNode({ }); if (node.if) { - const matchesCondition = checkIfConditionMatches(node, formValues, formFields, logic); + const matchesCondition = checkIfConditionMatchesProperties(node, formValues, formFields, logic); // BUG HERE (unreleated) - what if it matches but doesn't has a then, // it should do nothing, but instead it jumps to node.else when it shouldn't. if (matchesCondition && node.then) { @@ -368,6 +373,18 @@ export function processNode({ }); } + if (node['x-jsf-logic']) { + const { required: requiredFromLogic } = processJSONLogicNode({ + node: node['x-jsf-logic'], + formValues, + formFields, + accRequired: requiredFields, + parentID, + logic, + }); + requiredFromLogic.forEach((field) => requiredFields.add(field)); + } + return { required: requiredFields, }; @@ -465,7 +482,7 @@ export function extractParametersFromNode(schemaNode) { const presentation = pickXKey(schemaNode, 'presentation') ?? {}; const errorMessage = pickXKey(schemaNode, 'errorMessage') ?? {}; - const requiredValidations = schemaNode['x-jsf-logic-validations']; + const jsonLogicValidations = schemaNode['x-jsf-logic-validations']; const computedAttributes = schemaNode['x-jsf-logic-computedAttrs']; // This is when a forced value is computed. @@ -516,7 +533,7 @@ export function extractParametersFromNode(schemaNode) { // Handle [name].presentation ...presentation, - requiredValidations, + jsonLogicValidations, computedAttributes: decoratedComputedAttributes, description: containsHTML(description) ? wrapWithSpan(description, { diff --git a/src/jsonLogic.js b/src/jsonLogic.js index 2cd9a2f24..27e90d3b3 100644 --- a/src/jsonLogic.js +++ b/src/jsonLogic.js @@ -1,5 +1,10 @@ import jsonLogic from 'json-logic-js'; +import { + checkIfConditionMatchesProperties, + checkIfMatchesValidationsAndComputedValues, +} from './checkIfConditionMatches'; +import { processNode } from './helpers'; import { buildYupSchema } from './yupSchema'; /** @@ -90,7 +95,10 @@ function createValidationsScope(schema) { }); function validate(rule, values) { - return jsonLogic.apply(rule, replaceUndefinedValuesWithNulls(values)); + return jsonLogic.apply( + rule, + replaceUndefinedValuesWithNulls({ ...sampleEmptyObject, ...values }) + ); } return { @@ -126,7 +134,7 @@ function createValidationsScope(schema) { */ function replaceUndefinedValuesWithNulls(values = {}) { return Object.entries(values).reduce((prev, [key, value]) => { - return { ...prev, [key]: value === undefined ? null : value }; + return { ...prev, [key]: value === undefined || value === null ? NaN : value }; }, {}); } @@ -428,3 +436,59 @@ function removeIndicesFromPath(path) { const intermediatePath = path.replace(regexToGetIndices, '.'); return intermediatePath.replace(/\.\d+$/, ''); } + +export function processJSONLogicNode({ + node, + formFields, + formValues, + accRequired, + parentID, + logic, +}) { + const requiredFields = new Set(accRequired); + + if (node.allOf) { + node.allOf + .map((allOfNode) => + processJSONLogicNode({ node: allOfNode, formValues, formFields, logic, parentID }) + ) + .forEach(({ required: allOfItemRequired }) => { + allOfItemRequired.forEach(requiredFields.add, requiredFields); + }); + } + + if (node.if) { + const matchesPropertyCondition = checkIfConditionMatchesProperties( + node, + formValues, + formFields, + logic + ); + const matchesValidationsAndComputedValues = + matchesPropertyCondition && + checkIfMatchesValidationsAndComputedValues(node, formValues, logic, parentID); + + const isConditionMatch = matchesPropertyCondition && matchesValidationsAndComputedValues; + + let nextNode; + if (isConditionMatch && node.then) { + nextNode = node.then; + } + if (!isConditionMatch && node.else) { + nextNode = node.else; + } + if (nextNode) { + const { required: branchRequired } = processNode({ + node: nextNode, + formValues, + formFields, + accRequired, + logic, + parentID, + }); + branchRequired.forEach((field) => requiredFields.add(field)); + } + } + + return { required: requiredFields }; +} diff --git a/src/tests/checkIfConditionMatches.test.js b/src/tests/checkIfConditionMatches.test.js index 1859cfca8..02467a93f 100644 --- a/src/tests/checkIfConditionMatches.test.js +++ b/src/tests/checkIfConditionMatches.test.js @@ -1,12 +1,12 @@ -import { checkIfConditionMatches } from '../checkIfConditionMatches'; +import { checkIfConditionMatchesProperties } from '../checkIfConditionMatches'; it('Empty if is always going to be true', () => { - expect(checkIfConditionMatches({ if: { properties: {} } })).toBe(true); + expect(checkIfConditionMatchesProperties({ if: { properties: {} } })).toBe(true); }); it('Basic if check passes with correct value', () => { expect( - checkIfConditionMatches( + checkIfConditionMatchesProperties( { if: { properties: { a: { const: 'hello' } } } }, { a: 'hello', @@ -17,7 +17,7 @@ it('Basic if check passes with correct value', () => { it('Basic if check fails with incorrect value', () => { expect( - checkIfConditionMatches( + checkIfConditionMatchesProperties( { if: { properties: { a: { const: 'hello' } } } }, { a: 'goodbye', @@ -28,7 +28,7 @@ it('Basic if check fails with incorrect value', () => { it('Nested properties check passes with correct value', () => { expect( - checkIfConditionMatches( + checkIfConditionMatchesProperties( { if: { properties: { parent: { properties: { child: { const: 'hello from child' } } } } } }, { parent: { child: 'hello from child' }, @@ -40,7 +40,7 @@ it('Nested properties check passes with correct value', () => { it('Nested properties check passes with correct value', () => { expect( - checkIfConditionMatches( + checkIfConditionMatchesProperties( { if: { properties: { parent: { properties: { child: { const: 'hello from child' } } } } } }, { parent: { child: 'goodbye from child' }, diff --git a/src/tests/jsonLogic.fixtures.js b/src/tests/jsonLogic.fixtures.js index e36365b8a..11c749224 100644 --- a/src/tests/jsonLogic.fixtures.js +++ b/src/tests/jsonLogic.fixtures.js @@ -504,7 +504,6 @@ export const schemaInlineComputedAttrForMaximumMinimumValues = { properties: { field_a: { type: 'number', - default: 0, }, field_b: { type: 'number', @@ -551,3 +550,408 @@ export const schemaWithJSFLogicAndInlineRule = { }, }, }; + +export const schemaWithGreaterThanChecksForThreeFields = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + }, + field_c: { + type: 'number', + }, + }, + required: ['field_a', 'field_b'], + 'x-jsf-logic': { + validations: { + require_c: { + rule: { + and: [{ '>': [{ var: 'field_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_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_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_a' }, { var: 'field_b' }] }], + }, + }, + }, + allOf: [ + { + if: { + computedValues: { + require_c: { + const: true, + }, + }, + }, + then: { + required: ['field_c'], + }, + else: { + properties: { + field_c: false, + }, + }, + }, + ], + }, +}; + +export const schemaWithMultipleComputedValueChecks = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + }, + field_c: { + type: 'number', + }, + }, + required: ['field_a', 'field_b'], + 'x-jsf-logic': { + validations: { + double_b: { + errorMessage: 'Must be two times B', + rule: { + '>': [{ var: 'field_c' }, { '*': [{ var: 'field_b' }, 2] }], + }, + }, + }, + computedValues: { + a_times_two: { + rule: { + '*': [{ var: 'field_a' }, 2], + }, + }, + mod_by_five: { + rule: { + '%': [{ var: 'field_b' }, 5], + }, + }, + }, + allOf: [ + { + if: { + computedValues: { + a_times_two: { + const: 20, + }, + mod_by_five: { + const: 3, + }, + }, + }, + then: { + required: ['field_c'], + properties: { + field_c: { + 'x-jsf-logic-validations': ['double_b'], + title: 'Adding a title.', + }, + }, + }, + else: { + properties: { + field_c: false, + }, + }, + }, + ], + }, +}; + +export const schemaWithIfStatementWithComputedValuesAndValidationChecks = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + }, + field_c: { + type: 'number', + }, + }, + required: ['field_a', 'field_b'], + 'x-jsf-logic': { + validations: { + greater_than_b: { + rule: { + '>': [{ var: 'field_a' }, { var: 'field_b' }], + }, + }, + }, + computedValues: { + a_times_two: { + rule: { + '*': [{ var: 'field_a' }, 2], + }, + }, + }, + allOf: [ + { + if: { + computedValues: { + a_times_two: { + const: 20, + }, + }, + validations: { + greater_than_b: { + const: true, + }, + }, + }, + then: { + required: ['field_c'], + }, + else: { + properties: { + field_c: false, + }, + }, + }, + ], + }, +}; + +export const schemaWhereValidationAndComputedValueIsAppliedOnNormalThenStatement = { + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + }, + }, + 'x-jsf-logic': { + computedValues: { + a_plus_ten: { + rule: { + '+': [{ var: 'field_a' }, 10], + }, + }, + }, + validations: { + greater_than_a_plus_ten: { + errorMessage: 'Must be greater than Field A + 10', + rule: { + '>': [{ var: 'field_b' }, { '+': [{ var: 'field_a' }, 10] }], + }, + }, + }, + }, + allOf: [ + { + if: { + properties: { + field_a: { + const: 20, + }, + }, + }, + then: { + properties: { + field_b: { + 'x-jsf-logic-computedAttrs': { + title: 'Must be greater than {{a_plus_ten}}.', + }, + 'x-jsf-logic-validations': ['greater_than_a_plus_ten'], + }, + }, + }, + }, + ], +}; + +export const schemaWithTwoValidationsWhereOneOfThemIsAppliedConditionally = { + required: ['field_a', 'field_b'], + properties: { + field_a: { + type: 'number', + }, + field_b: { + type: 'number', + 'x-jsf-logic-validations': ['greater_than_field_a'], + }, + }, + 'x-jsf-logic': { + validations: { + greater_than_field_a: { + errorMessage: 'Must be greater than A', + rule: { + '>': [{ var: 'field_b' }, { var: 'field_a' }], + }, + }, + greater_than_two_times_a: { + errorMessage: 'Must be greater than two times A', + rule: { + '>': [{ var: 'field_b' }, { '*': [{ var: 'field_a' }, 2] }], + }, + }, + }, + }, + allOf: [ + { + if: { + properties: { + field_a: { + const: 20, + }, + }, + }, + then: { + properties: { + field_b: { + 'x-jsf-logic-validations': ['greater_than_two_times_a'], + }, + }, + }, + }, + ], +}; diff --git a/src/tests/jsonLogic.test.js b/src/tests/jsonLogic.test.js index e78d8dabe..56d84557b 100644 --- a/src/tests/jsonLogic.test.js +++ b/src/tests/jsonLogic.test.js @@ -4,26 +4,34 @@ import { createSchemaWithRulesOnFieldA, createSchemaWithThreePropertiesWithRuleOnFieldA, multiRuleSchema, + schemaWhereValidationAndComputedValueIsAppliedOnNormalThenStatement, schemaInlineComputedAttrForMaximumMinimumValues, schemaInlineComputedAttrForTitle, schemaWithBadOperation, + schemaWithChecksAndThenValidationsOnThen, schemaWithComputedAttributeThatDoesntExist, schemaWithComputedAttributeThatDoesntExistDescription, schemaWithComputedAttributeThatDoesntExistTitle, schemaWithComputedAttributes, schemaWithComputedAttributesAndErrorMessages, + schemaWithComputedValueChecksInIf, schemaWithDeepVarThatDoesNotExist, schemaWithDeepVarThatDoesNotExistOnFieldset, + schemaWithGreaterThanChecksForThreeFields, + schemaWithIfStatementWithComputedValuesAndValidationChecks, schemaWithInlineMultipleRulesForComputedAttributes, schemaWithInlineRuleForComputedAttributeWithCopy, schemaWithInlinedRuleOnComputedAttributeThatReferencesUnknownVar, schemaWithJSFLogicAndInlineRule, schemaWithMissingComputedValue, schemaWithMissingRule, + schemaWithMultipleComputedValueChecks, schemaWithNativeAndJSONLogicChecks, schemaWithNonRequiredField, + schemaWithPropertiesCheckAndValidationsInAIf, schemaWithPropertyThatDoesNotExistInThatLevelButDoesInFieldset, schemaWithTwoRules, + schemaWithTwoValidationsWhereOneOfThemIsAppliedConditionally, schemaWithUnknownVariableInComputedValues, schemaWithUnknownVariableInValidations, schemaWithValidationThatDoesNotExistOnProperty, @@ -363,58 +371,209 @@ describe('jsonLogic: cross-values validations', () => { expect(handleValidation({ field_a: 10 }).formErrors).toEqual(undefined); expect(fieldB.label).toEqual('I need this to work using the 20.'); }); - }); - it('Use multiple inline rules with different identifiers', () => { - const { fields, handleValidation } = createHeadlessForm( - schemaWithInlineMultipleRulesForComputedAttributes, - { + it('Use multiple inline rules with different identifiers', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaWithInlineMultipleRulesForComputedAttributes, + { + strictInputType: false, + } + ); + const [, fieldB] = fields; + expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toEqual(undefined); + expect(fieldB.description).toEqual('Must be between 5 and 20.'); + }); + + it('Use an inline rule in a schema for a title but it just uses the value', () => { + const { fields, handleValidation } = createHeadlessForm(schemaInlineComputedAttrForTitle, { strictInputType: false, - } - ); - const [, fieldB] = fields; - expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toEqual(undefined); - expect(fieldB.description).toEqual('Must be between 5 and 20.'); - }); + }); + const [, fieldB] = fields; + expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toEqual(undefined); + expect(fieldB.label).toEqual('20'); + }); + + it('Use an inline rule for a minimum, maximum value', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaInlineComputedAttrForMaximumMinimumValues, + { + strictInputType: false, + } + ); + const [, fieldB] = fields; + + // FIXME: We are currently setting NaN here because of how the data clean up works for json-logic + // We should probably set this as undefined when theres no values set? + // tracked in INF-53. + expect(fieldB).toMatchObject({ minimum: NaN, maximum: NaN }); + expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toBeUndefined(); + expect(fieldB).toMatchObject({ minimum: 0, maximum: 20 }); + expect(handleValidation({ field_a: 50, field_b: 20 }).formErrors).toEqual({ + field_b: 'Must be greater or equal to 40', + }); + expect(fieldB).toMatchObject({ minimum: 40, maximum: 60 }); + expect(handleValidation({ field_a: 50, field_b: 70 }).formErrors).toEqual({ + field_b: 'Must be smaller or equal to 60', + }); + expect(fieldB).toMatchObject({ minimum: 40, maximum: 60 }); + expect(handleValidation({ field_a: 50, field_b: 50 }).formErrors).toBeUndefined(); + expect(fieldB).toMatchObject({ minimum: 40, maximum: 60 }); + }); - it('Use an inline rule in a schema for a title but it just uses the value', () => { - const { fields, handleValidation } = createHeadlessForm(schemaInlineComputedAttrForTitle, { - strictInputType: false, + it('Mix use of multiple inline rules and an external rule', () => { + const { fields, handleValidation } = createHeadlessForm(schemaWithJSFLogicAndInlineRule, { + strictInputType: false, + }); + handleValidation({ field_a: 10 }); + const [, fieldB] = fields; + expect(fieldB.label).toEqual('Going to use 20 and 4'); }); - const [, fieldB] = fields; - expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toEqual(undefined); - expect(fieldB.label).toEqual('20'); }); - it('Use an inline rule for a minimum, maximum value', () => { - const { fields, handleValidation } = createHeadlessForm( - schemaInlineComputedAttrForMaximumMinimumValues, - { + describe('Conditionals', () => { + it('when field_a > field_b, show field_c', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaWithGreaterThanChecksForThreeFields, + { strictInputType: false } + ); + const fieldC = fields.find((i) => i.name === 'field_c'); + expect(fieldC.isVisible).toEqual(false); + + expect(handleValidation({ field_a: 1, field_b: 3 }).formErrors).toEqual(undefined); + expect(handleValidation({ field_a: 1 }).formErrors).toEqual({ + field_b: 'Required field', + }); + expect(handleValidation({ field_a: 1, field_b: undefined }).formErrors).toEqual({ + field_b: 'Required field', + }); + expect(handleValidation({ field_a: 10, field_b: 3 }).formErrors).toEqual({ + field_c: 'Required field', + }); + expect(fieldC.isVisible).toEqual(true); + expect(handleValidation({ field_a: 10, field_b: 3, field_c: 0 }).formErrors).toEqual( + undefined + ); + }); + + it('A schema with both a `x-jsf-validations` and `properties` check', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaWithPropertiesCheckAndValidationsInAIf, + { strictInputType: false } + ); + const fieldC = fields.find((i) => i.name === 'field_c'); + expect(handleValidation({ field_a: 1, field_b: 3 }).formErrors).toEqual(undefined); + expect(fieldC.isVisible).toEqual(false); + expect(handleValidation({ field_a: 10, field_b: 3 }).formErrors).toEqual({ + field_c: 'Required field', + }); + expect(fieldC.isVisible).toEqual(true); + expect(handleValidation({ field_a: 5, field_b: 3 }).formErrors).toEqual(undefined); + }); + + 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 + ); + expect(handleValidation({ field_a: 5, field_b: 10 }).formErrors).toBeUndefined(); + expect(cField).toMatchObject({ + isVisible: false, + // description: null, the description will currently be `I am a description!`, how do we change it back to null from here? Needs to be fixed by RMT-58 + }); + }); + + it('Should apply a conditional based on a true computedValue', () => { + const { fields, handleValidation } = createHeadlessForm(schemaWithComputedValueChecksInIf, { strictInputType: false, - } - ); - const [, fieldB] = fields; - expect(fieldB).toMatchObject({ minimum: -10, maximum: 10 }); - expect(handleValidation({ field_a: 10, field_b: null }).formErrors).toBeUndefined(); - expect(fieldB).toMatchObject({ minimum: 0, maximum: 20 }); - expect(handleValidation({ field_a: 50, field_b: 20 }).formErrors).toEqual({ - field_b: 'Must be greater or equal to 40', + }); + const cField = fields.find((i) => i.name === 'field_c'); + expect(cField.isVisible).toEqual(false); + expect(cField.description).toEqual(undefined); + expect(handleValidation({ field_a: 10, field_b: 5 }).formErrors).toEqual({ + field_c: 'Required field', + }); + expect(handleValidation({ field_a: 10, field_b: 5, field_c: 201 }).formErrors).toEqual( + undefined + ); + }); + + it('Handle multiple computedValue checks by ANDing them together', () => { + const { handleValidation } = createHeadlessForm(schemaWithMultipleComputedValueChecks, { + strictInputType: false, + }); + expect(handleValidation({}).formErrors).toEqual({ + field_a: 'Required field', + field_b: 'Required field', + }); + expect(handleValidation({ field_a: 10, field_b: 8 }).formErrors).toEqual({ + field_c: 'Required field', + }); + expect(handleValidation({ field_a: 10, field_b: 8, field_c: 0 }).formErrors).toEqual({ + field_c: 'Must be two times B', + }); + expect(handleValidation({ field_a: 10, field_b: 8, field_c: 17 }).formErrors).toEqual( + undefined + ); }); - expect(fieldB).toMatchObject({ minimum: 40, maximum: 60 }); - expect(handleValidation({ field_a: 50, field_b: 70 }).formErrors).toEqual({ - field_b: 'Must be smaller or equal to 60', + + 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 + ); }); - expect(fieldB).toMatchObject({ minimum: 40, maximum: 60 }); - expect(handleValidation({ field_a: 50, field_b: 50 }).formErrors).toBeUndefined(); - expect(fieldB).toMatchObject({ minimum: 40, maximum: 60 }); - }); - it('Mix use of multiple inline rules and an external rule', () => { - const { fields, handleValidation } = createHeadlessForm(schemaWithJSFLogicAndInlineRule, { - strictInputType: false, + it('Apply validations and computed values on normal if statement.', () => { + const { fields, handleValidation } = createHeadlessForm( + schemaWhereValidationAndComputedValueIsAppliedOnNormalThenStatement, + { strictInputType: false } + ); + expect(handleValidation({ field_a: 0, field_b: 0 }).formErrors).toEqual(undefined); + expect(handleValidation({ field_a: 20, field_b: 0 }).formErrors).toEqual({ + field_b: 'Must be greater than Field A + 10', + }); + const [, fieldB] = fields; + expect(fieldB.label).toEqual('Must be greater than 30.'); + expect(handleValidation({ field_a: 20, field_b: 31 }).formErrors).toEqual(undefined); + }); + + it('When we have a required validation on a top level property and another validation is added, both should be accounted for.', () => { + const { handleValidation } = createHeadlessForm( + schemaWithTwoValidationsWhereOneOfThemIsAppliedConditionally, + { strictInputType: false } + ); + expect(handleValidation({ field_a: 10, field_b: 0 }).formErrors).toEqual({ + field_b: 'Must be greater than A', + }); + expect(handleValidation({ field_a: 10, field_b: 20 }).formErrors).toEqual(undefined); + expect(handleValidation({ field_a: 20, field_b: 10 }).formErrors).toEqual({ + field_b: 'Must be greater than A', + }); + expect(handleValidation({ field_a: 20, field_b: 21 }).formErrors).toEqual({ + field_b: 'Must be greater than two times A', + }); + expect(handleValidation({ field_a: 20, field_b: 41 }).formErrors).toEqual(); }); - handleValidation({ field_a: 10 }); - const [, fieldB] = fields; - expect(fieldB.label).toEqual('Going to use 20 and 4'); }); }); diff --git a/src/yupSchema.js b/src/yupSchema.js index c2659f36c..94f5e0aa0 100644 --- a/src/yupSchema.js +++ b/src/yupSchema.js @@ -407,8 +407,8 @@ export function buildYupSchema(field, config, logic) { validators.push(withConst); } - if (propertyFields.requiredValidations) { - propertyFields.requiredValidations.forEach((id) => + if (propertyFields.jsonLogicValidations) { + propertyFields.jsonLogicValidations.forEach((id) => validators.push(yupSchemaWithCustomJSONLogic({ field, id, logic, config })) ); }