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 2: Computed Attribute skeleton #36

Merged
merged 11 commits into from
Sep 12, 2023
20 changes: 17 additions & 3 deletions src/createHeadlessForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
getInputType,
} from './internals/fields';
import { pickXKey } from './internals/helpers';
import { createValidationChecker } from './jsonLogic';
import { calculateComputedAttributes, createValidationChecker } from './jsonLogic';
import { buildYupSchema } from './yupSchema';

// Some type definitions (to be migrated into .d.ts file or TS Interfaces)
Expand Down Expand Up @@ -188,6 +188,10 @@ function applyFieldsDependencies(fieldsParameters, node) {
applyFieldsDependencies(fieldsParameters, condition);
});
}

if (node?.['x-jsf-logic']) {
applyFieldsDependencies(fieldsParameters, node['x-jsf-logic']);
}
}

/**
Expand Down Expand Up @@ -238,6 +242,10 @@ function buildField(fieldParams, config, scopedJsonSchema, logic) {
customProperties
);

const getComputedAttributes =
Object.keys(fieldParams.computedAttributes).length > 0 &&
calculateComputedAttributes(fieldParams, config);

const hasCustomValidations =
!!customProperties &&
size(pick(customProperties, SUPPORTED_CUSTOM_VALIDATION_FIELD_PARAMS)) > 0;
Expand All @@ -253,6 +261,7 @@ function buildField(fieldParams, config, scopedJsonSchema, logic) {
...(hasCustomValidations && {
calculateCustomValidationProperties: calculateCustomValidationPropertiesClosure,
}),
...(getComputedAttributes && { getComputedAttributes }),
// field customization properties
...(customProperties && { fieldCustomization: customProperties }),
// base schema
Expand Down Expand Up @@ -302,7 +311,7 @@ function getFieldsFromJSONSchema(scopedJsonSchema, config, logic) {
addFieldText: fieldParams.addFieldText,
};

buildField(fieldParams, config, scopedJsonSchema).forEach((groupField) => {
buildField(fieldParams, config, scopedJsonSchema, logic).forEach((groupField) => {
fields.push(groupField);
});
} else {
Expand Down Expand Up @@ -331,7 +340,12 @@ export function createHeadlessForm(jsonSchema, customConfig = {}) {

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

updateFieldsProperties(fields, getPrefillValues(fields, config.initialValues), jsonSchema);
updateFieldsProperties(
fields,
getPrefillValues(fields, config.initialValues),
jsonSchema,
logic
);

return {
fields,
Expand Down
42 changes: 35 additions & 7 deletions src/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ export function getPrefillValues(fields, initialValues = {}) {
* @param {Object} node - JSON-schema node
* @returns
*/
function updateField(field, requiredFields, node, formValues) {
function updateField(field, requiredFields, node, formValues, logic, config) {
// If there was an error building the field, it might not exist in the form even though
// it can be mentioned in the schema so we return early in that case
if (!field) {
Expand Down Expand Up @@ -226,6 +226,18 @@ function updateField(field, requiredFields, node, formValues) {
}
});

if (field.getComputedAttributes) {
const computedFieldValues = field.getComputedAttributes({
field,
isRequired: fieldIsRequired,
node,
formValues,
config,
logic,
});
updateValues(computedFieldValues);
}

// If field has a calculateConditionalProperties closure, run it and update the field properties
if (field.calculateConditionalProperties) {
const newFieldValues = field.calculateConditionalProperties(fieldIsRequired, node);
Expand Down Expand Up @@ -270,17 +282,19 @@ export function processNode({
// Go through the node properties definition and update each field accordingly
Object.keys(node.properties ?? []).forEach((fieldName) => {
const field = getField(fieldName, formFields);
updateField(field, requiredFields, node, formValues);
updateField(field, requiredFields, node, formValues, logic, { parentID });
});

// Update required fields based on the `required` property and mutate node if needed
node.required?.forEach((fieldName) => {
requiredFields.add(fieldName);
updateField(getField(fieldName, formFields), requiredFields, node, formValues);
updateField(getField(fieldName, formFields), requiredFields, node, formValues, logic, {
parentID,
});
});

if (node.if) {
const matchesCondition = checkIfConditionMatches(node, formValues, formFields);
const matchesCondition = checkIfConditionMatches(node, formValues, formFields, logic);
// BUG HERE (unreleated) - what if it matches but doesn't has a then,
// it should do nothing, but instead it jumps to node.else when it shouldn't.
if (matchesCondition && node.then) {
Expand Down Expand Up @@ -316,7 +330,7 @@ export function processNode({
node.anyOf.forEach(({ required = [] }) => {
required.forEach((fieldName) => {
const field = getField(fieldName, formFields);
updateField(field, requiredFields, node, formValues);
updateField(field, requiredFields, node, formValues, logic, { parentID });
});
});
}
Expand Down Expand Up @@ -452,7 +466,10 @@ 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 = getDecoratedComputedAttributes(computedAttributes);
const node = omit(schemaNode, ['x-jsf-presentation', 'presentation']);

const description = presentation?.description || node.description;
Expand All @@ -463,6 +480,7 @@ export function extractParametersFromNode(schemaNode) {
return omitBy(
{
const: node.const,
...(node.const && node.default ? { value: node.const } : {}),
label: node.title,
readOnly: node.readOnly,
...(node.deprecated && {
Expand Down Expand Up @@ -499,6 +517,7 @@ export function extractParametersFromNode(schemaNode) {
// Handle [name].presentation
...presentation,
requiredValidations,
computedAttributes: decoratedComputedAttributes,
description: containsHTML(description)
? wrapWithSpan(description, {
class: 'jsf-description',
Expand Down Expand Up @@ -556,8 +575,8 @@ export function yupToFormErrors(yupError) {
* @param {JsfConfig} config - jsf config
* @returns {Function(values: Object): { YupError: YupObject, formErrors: Object }} Callback that returns Yup errors <YupObject>
*/
export const handleValuesChange = (fields, jsonSchema, config) => (values) => {
updateFieldsProperties(fields, values, jsonSchema);
export const handleValuesChange = (fields, jsonSchema, config, logic) => (values) => {
updateFieldsProperties(fields, values, jsonSchema, logic);

const lazySchema = lazy(() => buildCompleteYupSchema(fields, config));
let errors;
Expand All @@ -580,3 +599,12 @@ export const handleValuesChange = (fields, jsonSchema, config) => (values) => {
formErrors: yupToFormErrors(errors),
};
};

function getDecoratedComputedAttributes(computedAttributes) {
return {
...(computedAttributes ?? {}),
...(computedAttributes?.const && computedAttributes?.default
? { value: computedAttributes.const }
: {}),
};
}
24 changes: 24 additions & 0 deletions src/jsonLogic.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,27 @@ export function yupSchemaWithCustomJSONLogic({ field, logic, config, id }) {
}
);
}

export function calculateComputedAttributes(fieldParams, { parentID = 'root' } = {}) {
return ({ logic, formValues }) => {
const { computedAttributes } = fieldParams;
const attributes = Object.fromEntries(
Object.entries(computedAttributes)
.map(handleComputedAttribute(logic, formValues, parentID))
.filter(([, value]) => value !== null)
);

return attributes;
};
}

function handleComputedAttribute(logic, formValues, parentID) {
return ([key, value]) => {
if (key === 'const')
return [key, logic.getScope(parentID).applyComputedValueInField(value, formValues)];

if (typeof value === 'string') {
johnstonbl01 marked this conversation as resolved.
Show resolved Hide resolved
return [key, logic.getScope(parentID).applyComputedValueInField(value, formValues)];
}
};
}
12 changes: 12 additions & 0 deletions src/tests/const.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,16 @@ describe('validations: const', () => {
});
expect(handleValidation({ string: 'hello' }).formErrors).toEqual(undefined);
});

it('Should have value attribute for when const & default is present', () => {
const { fields } = createHeadlessForm(
{
properties: {
ten_only: { type: 'number', const: 10, default: 10 },
},
},
{ strictInputType: false }
);
expect(fields[0]).toMatchObject({ value: 10, const: 10, default: 10 });
});
});
25 changes: 25 additions & 0 deletions src/tests/jsonLogic.fixtures.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,28 @@ export const schemaWithTwoRules = {
},
},
};

export const schemaWithComputedAttributes = {
properties: {
field_a: {
type: 'number',
},
field_b: {
type: 'number',
'x-jsf-logic-computedAttrs': {
const: 'a_times_two',
default: 'a_times_two',
},
},
},
required: ['field_a', 'field_b'],
'x-jsf-logic': {
computedValues: {
a_times_two: {
rule: {
'*': [{ var: 'field_a' }, 2],
},
},
},
},
};
15 changes: 15 additions & 0 deletions src/tests/jsonLogic.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
createSchemaWithRulesOnFieldA,
createSchemaWithThreePropertiesWithRuleOnFieldA,
multiRuleSchema,
schemaWithComputedAttributes,
schemaWithNativeAndJSONLogicChecks,
schemaWithNonRequiredField,
schemaWithTwoRules,
Expand Down Expand Up @@ -212,4 +213,18 @@ describe('jsonLogic: cross-values validations', () => {
expect(handleValidation({ field_a: 4, field_b: 2 }).formErrors).toEqual(undefined);
});
});

describe('Derive values', () => {
it('field_b is field_a * 2', () => {
const { fields, handleValidation } = createHeadlessForm(schemaWithComputedAttributes, {
strictInputType: false,
initialValues: { field_a: 2 },
});
const fieldB = fields.find((i) => i.name === 'field_b');
expect(fieldB.default).toEqual(4);
expect(fieldB.value).toEqual(4);
sandrina-p marked this conversation as resolved.
Show resolved Hide resolved
handleValidation({ field_a: 4 });
expect(fieldB.default).toEqual(8);
});
});
johnstonbl01 marked this conversation as resolved.
Show resolved Hide resolved
});