Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[JSON Logic] Part 1: JSON Logic Skeleton #35

Merged
merged 16 commits into from
Aug 31, 2023
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
]
},
"dependencies": {
"json-logic-js": "^2.0.2",
"lodash": "^4.17.21",
"randexp": "^0.5.3",
"yup": "^0.30.0"
Expand Down
16 changes: 10 additions & 6 deletions src/createHeadlessForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
getInputType,
} from './internals/fields';
import { pickXKey } from './internals/helpers';
import { createValidationChecker } from './jsonLogic';
import { buildYupSchema } from './yupSchema';

// Some type definitions (to be migrated into .d.ts file or TS Interfaces)
Expand Down Expand Up @@ -220,13 +221,15 @@ function getComposeFunctionForField(fieldParams, hasCustomizations) {
* Create field object using a compose function
* @param {FieldParameters} fieldParams - field parameters
* @param {JsfConfig} config - parser config
* @param {Object} scopedJsonSchema - the matching JSON schema
* @param {Object} logic - logic used for validation json-logic
* @returns {Object} field object
*/
function buildField(fieldParams, config, scopedJsonSchema) {
function buildField(fieldParams, config, scopedJsonSchema, logic) {
const customProperties = getCustomPropertiesForField(fieldParams, config);
const composeFn = getComposeFunctionForField(fieldParams, !!customProperties);

const yupSchema = buildYupSchema(fieldParams, config);
const yupSchema = buildYupSchema(fieldParams, config, logic);
const calculateConditionalFieldsClosure =
fieldParams.isDynamic && calculateConditionalProperties(fieldParams, customProperties);

Expand Down Expand Up @@ -267,7 +270,7 @@ function buildField(fieldParams, config, scopedJsonSchema) {
* @param {JsfConfig} config - JSON-schema-form config
* @returns {ParserFields} ParserFields
*/
function getFieldsFromJSONSchema(scopedJsonSchema, config) {
function getFieldsFromJSONSchema(scopedJsonSchema, config, logic) {
if (!scopedJsonSchema) {
// NOTE: other type of verifications might be needed.
return [];
Expand Down Expand Up @@ -303,7 +306,7 @@ function getFieldsFromJSONSchema(scopedJsonSchema, config) {
fields.push(groupField);
dilvane marked this conversation as resolved.
Show resolved Hide resolved
});
} else {
fields.push(buildField(fieldParams, config, scopedJsonSchema));
fields.push(buildField(fieldParams, config, scopedJsonSchema, logic));
}
});

Expand All @@ -323,9 +326,10 @@ export function createHeadlessForm(jsonSchema, customConfig = {}) {
};

try {
const fields = getFieldsFromJSONSchema(jsonSchema, config);
const logic = createValidationChecker(jsonSchema);
const fields = getFieldsFromJSONSchema(jsonSchema, config, logic);

const handleValidation = handleValuesChange(fields, jsonSchema, config);
const handleValidation = handleValuesChange(fields, jsonSchema, config, logic);

updateFieldsProperties(fields, getPrefillValues(fields, config.initialValues), jsonSchema);
johnstonbl01 marked this conversation as resolved.
Show resolved Hide resolved

Expand Down
58 changes: 43 additions & 15 deletions src/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ export function getField(fieldName, fields) {
* @param {any} value
* @returns
*/
export function validateFieldSchema(field, value) {
const validator = buildYupSchema(field);
export function validateFieldSchema(field, value, logic) {
const validator = buildYupSchema(field, {}, logic);
return validator().isValidSync(value);
}

Expand Down Expand Up @@ -256,7 +256,14 @@ 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,
brennj marked this conversation as resolved.
Show resolved Hide resolved
formValues,
formFields,
accRequired = new Set(),
parentID = 'root',
logic,
}) {
// Set initial required fields
const requiredFields = new Set(accRequired);

Expand All @@ -277,21 +284,25 @@ function processNode(node, formValues, formFields, accRequired = new Set()) {
// 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,
logic,
});

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,
logic,
});
branchRequired.forEach((field) => requiredFields.add(field));
}
}
Expand All @@ -312,7 +323,16 @@ function processNode(node, formValues, formFields, accRequired = new Set()) {

if (node.allOf) {
node.allOf
.map((allOfNode) => processNode(allOfNode, formValues, formFields, requiredFields))
.map((allOfNode) =>
processNode({
node: allOfNode,
formValues,
formFields,
accRequired: requiredFields,
parentID,
logic,
})
)
.forEach(({ required: allOfItemRequired }) => {
allOfItemRequired.forEach(requiredFields.add, requiredFields);
});
Expand All @@ -323,7 +343,13 @@ 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,
parentID: name,
logic,
});
}
});
}
Expand Down Expand Up @@ -358,11 +384,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, logic) {
if (!jsonSchema?.properties) {
return;
}
processNode(jsonSchema, formValues, fields);
processNode({ node: jsonSchema, formValues, formFields: fields, logic });
clearValuesIfNotVisible(fields, formValues);
}

Expand Down Expand Up @@ -425,6 +451,7 @@ export function extractParametersFromNode(schemaNode) {

const presentation = pickXKey(schemaNode, 'presentation') ?? {};
const errorMessage = pickXKey(schemaNode, 'errorMessage') ?? {};
const requiredValidations = schemaNode['x-jsf-logic-validations'];

const node = omit(schemaNode, ['x-jsf-presentation', 'presentation']);

Expand Down Expand Up @@ -471,6 +498,7 @@ export function extractParametersFromNode(schemaNode) {

// Handle [name].presentation
...presentation,
requiredValidations,
description: containsHTML(description)
? wrapWithSpan(description, {
class: 'jsf-description',
Expand Down
124 changes: 124 additions & 0 deletions src/jsonLogic.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import jsonLogic from 'json-logic-js';

/**
* Parses the JSON schema to extract the json-logic rules and returns an object
* containing the validation scopes, functions to retrieve the scopes, and evaluate the
* validation rules.
*
* @param {Object} schema - JSON schema node
* @returns {Object} An object containing:
* - scopes {Map} - A Map of the validation scopes (with IDs as keys)
* - getScope {Function} - Function to retrieve a scope by name/ID
* - validate {Function} - Function to evaluate a validation rule
* - applyValidationRuleInCondition {Function} - Evaluate a validation rule used in a condition
* - applyComputedValueInField {Function} - Evaluate a computed value rule for a field
* - applyComputedValueRuleInCondition {Function} - Evaluate a computed value rule used in a condition
*/
export function createValidationChecker(schema) {
const scopes = new Map();

function createScopes(jsonSchema, key = 'root') {
johnstonbl01 marked this conversation as resolved.
Show resolved Hide resolved
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}[]`);
} else {
createScopes(property, key);
}
});
brennj marked this conversation as resolved.
Show resolved Hide resolved
}

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 ?? {});

validations.forEach(([id, validation]) => {
validationMap.set(id, validation);
});

computedValues.forEach(([id, computedValue]) => {
computedValuesMap.set(id, computedValue);
});

function validate(rule, values) {
return jsonLogic.apply(rule, replaceUndefinedValuesWithNulls(values));
}

return {
validationMap,
computedValuesMap,
validate,
applyValidationRuleInCondition(id, values) {
const validation = validationMap.get(id);
return validate(validation.rule, values);
},
applyComputedValueInField(id, values) {
const validation = computedValuesMap.get(id);
return validate(validation.rule, values);
},
applyComputedValueRuleInCondition(id, values) {
const validation = computedValuesMap.get(id);
return validate(validation.rule, values);
},
};
}

/**
* We removed undefined values in this function as `json-logic` ignores them.
* Means we will always check against a value for validations.
*
* @param {Object} values - a set of values from a form
* @returns {Object} values object without any undefined
*/
function replaceUndefinedValuesWithNulls(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.logic - The logic object containing validation scopes and rules.
* @param {Object} options.config - Additional configuration options.
* @param {string} options.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, logic, config, id }) {
const { parentID = 'root' } = config;
const validation = logic.getScope(parentID).validationMap.get(id);

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);
}
);
}
Loading