Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion v0/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
},
"lint-staged": {
"*.{js,jsx}": [
"npm run format"
"npm run format --prefix ./v0"
]
},
"dependencies": {
Expand Down
2 changes: 1 addition & 1 deletion v0/scripts/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const esbuild = require('esbuild');

const pkg = require('../package.json');

const licenseContent = fs.readFileSync(path.join(__dirname, '../LICENSE'), 'utf8');
const licenseContent = fs.readFileSync(path.join(__dirname, '../../LICENSE'), 'utf8');
const packageJson = require(path.resolve(__dirname, '../package.json'));
const pkgVersion = packageJson.version;

Expand Down
27 changes: 20 additions & 7 deletions v0/src/calculateConditionalProperties.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,23 @@ function isFieldRequired(node, field) {
/**
* Loops recursively through fieldset fields and returns an copy version of them
* where the required property is updated.
* Since rebuildFieldset is called within a closure, we pass the current fields as parameter
* to restore the computed isVisible property.
*
* @param {Array} fields - list of fields of a fieldset
* @param {Object} property - property that relates with the list of fields
* @returns {Object}
*/
function rebuildFieldset(fields, property) {
function rebuildFieldset(fields, currentFields, property) {
if (property?.properties) {
return fields.map((field) => {
return fields.map((field, index) => {
const propertyConditionals = property.properties[field.name];
const isVisible = currentFields[index].isVisible;
if (!propertyConditionals) {
return field;
return {
...field,
isVisible,
};
}

const newFieldParams = extractParametersFromNode(propertyConditionals);
Expand All @@ -47,19 +53,22 @@ function rebuildFieldset(fields, property) {
return {
...field,
...newFieldParams,
fields: rebuildFieldset(field.fields, propertyConditionals),
isVisible,
fields: rebuildFieldset(field.fields, currentFields[index].fields, propertyConditionals),
};
}
return {
...field,
...newFieldParams,
isVisible,
required: isFieldRequired(property, field),
};
});
}

return fields.map((field) => ({
return fields.map((field, index) => ({
...field,
isVisible: currentFields[index].isVisible,
required: isFieldRequired(property, field),
}));
}
Expand Down Expand Up @@ -92,7 +101,7 @@ export function calculateConditionalProperties({ fieldParams, customProperties,
*
* @returns {calculateConditionalPropertiesReturn}
*/
return ({ isRequired, conditionBranch, formValues }) => {
return ({ isRequired, conditionBranch, formValues, currentField }) => {
// Check if the current field is conditionally declared in the schema
// console.log('::calc (closure original)', fieldParams.description);
const conditionalProperty = conditionBranch?.properties?.[fieldParams.name];
Expand All @@ -110,7 +119,11 @@ export function calculateConditionalProperties({ fieldParams, customProperties,
let fieldSetFields;

if (fieldParams.inputType === supportedTypes.FIELDSET) {
fieldSetFields = rebuildFieldset(fieldParams.fields, conditionalProperty);
fieldSetFields = rebuildFieldset(
fieldParams.fields,
currentField.fields,
conditionalProperty
);
newFieldParams.fields = fieldSetFields;
}

Expand Down
42 changes: 20 additions & 22 deletions v0/src/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import set from 'lodash/set';
import { lazy } from 'yup';

import { checkIfConditionMatchesProperties } from './internals/checkIfConditionMatches';
import { supportedTypes, getInputType } from './internals/fields';
import { supportedTypes } from './internals/fields';
import { pickXKey } from './internals/helpers';
import { processJSONLogicNode } from './jsonLogic';
import { hasProperty } from './utils';
Expand Down Expand Up @@ -161,7 +161,7 @@ function getPrefillSubFieldValues(field, defaultValues, parentFieldKeyPath) {
initialValue[field.name] = subFieldValues;
}
} else {
// getDefaultValues and getPrefillSubFieldValues have a circluar dependency, resulting in one having to be used before defined.
// getDefaultValues and getPrefillSubFieldValues have a circular dependency, resulting in one having to be used before defined.
// As function declarations are hoisted this should not be a problem.
// eslint-disable-next-line no-use-before-define

Expand Down Expand Up @@ -295,6 +295,7 @@ function updateField(field, requiredFields, node, formValues, logic, config) {
isRequired: fieldIsRequired,
conditionBranch: node,
formValues,
currentField: field,
});
updateAttributes(newAttributes);
removeConditionalStaleAttributes(field, newAttributes, rootFieldAttrs);
Expand Down Expand Up @@ -336,9 +337,22 @@ export function processNode({
const requiredFields = new Set(accRequired);

// Go through the node properties definition and update each field accordingly
Object.keys(node.properties ?? []).forEach((fieldName) => {
Object.entries(node.properties ?? []).forEach(([fieldName, nestedNode]) => {
const field = getField(fieldName, formFields);
updateField(field, requiredFields, node, formValues, logic, { parentID });

// If we're processing a fieldset field node
// update the nested fields going through the node recursively.
const isFieldset = field?.inputType === supportedTypes.FIELDSET;
if (isFieldset) {
processNode({
node: nestedNode,
formValues: formValues[fieldName] || {},
formFields: field.fields,
parentID,
logic,
});
}
});

// Update required fields based on the `required` property and mutate node if needed
Expand All @@ -351,7 +365,7 @@ export function processNode({

if (node.if !== undefined) {
const matchesCondition = checkIfConditionMatchesProperties(node, formValues, formFields, logic);
// BUG HERE (unreleated) - what if it matches but doesn't has a then,
// BUG HERE (unrelated) - 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({
Expand Down Expand Up @@ -408,22 +422,6 @@ export function processNode({
});
}

if (node.properties) {
Object.entries(node.properties).forEach(([name, nestedNode]) => {
const inputType = getInputType(nestedNode);
if (inputType === supportedTypes.FIELDSET) {
// It's a fieldset, which might contain scoped conditions
processNode({
node: nestedNode,
formValues: formValues[name] || {},
formFields: getField(name, formFields).fields,
parentID: name,
logic,
});
}
});
}

if (node['x-jsf-logic']) {
const { required: requiredFromLogic } = processJSONLogicNode({
node: node['x-jsf-logic'],
Expand Down Expand Up @@ -451,14 +449,14 @@ export function processNode({
function clearValuesIfNotVisible(fields, formValues) {
fields.forEach(({ isVisible = true, name, inputType, fields: nestedFields }) => {
if (!isVisible) {
// TODO I (Sandrina) think this doesn't work. I didn't find any test covering this scenario. Revisit later.
formValues[name] = null;
}
if (inputType === supportedTypes.FIELDSET && nestedFields && formValues[name]) {
clearValuesIfNotVisible(nestedFields, formValues[name]);
}
});
}

/**
* Updates form fields properties based on the current form state and the JSON schema rules
*
Expand Down Expand Up @@ -500,7 +498,7 @@ function getFieldOptions(node, presentation) {
}));
}

/** @deprecated - takes precendence in case a JSON Schema still has deprecated options */
/** @deprecated - takes precedence in case a JSON Schema still has deprecated options */
if (presentation.options) {
return presentation.options;
}
Expand Down
186 changes: 186 additions & 0 deletions v0/src/tests/createHeadlessForm.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
schemaForErrorMessageSpecificity,
jsfConfigForErrorMessageSpecificity,
schemaInputTypeFile,
schemaWithNestedFieldsetsConditionals,
} from './helpers';
import { mockConsole, restoreConsoleAndEnsureItWasNotCalled } from './testUtils';
import { createHeadlessForm } from '@/createHeadlessForm';
Expand Down Expand Up @@ -2200,6 +2201,191 @@ describe('createHeadlessForm', () => {
).toBeUndefined();
});
});

describe('supports conditionals over nested fieldsets', () => {
it('retirement_plan fieldset is hidden when no values are provided', () => {
const { fields, handleValidation } = createHeadlessForm(
schemaWithNestedFieldsetsConditionals,
{}
);
const validateForm = (vals) => friendlyError(handleValidation(vals));

expect(getField(fields, 'perks', 'retirement_plan').isVisible).toBe(false);

expect(validateForm({ perks: {} })).toEqual({
perks: {
benefits_package: 'Required field',
has_retirement_plan: 'Required field',
},
});

expect(getField(fields, 'perks', 'retirement_plan').isVisible).toBe(false);
});

it("submits without retirement_plan when user selects 'no' for has_retirement_plan", () => {
const { fields, handleValidation } = createHeadlessForm(
schemaWithNestedFieldsetsConditionals,
{}
);
const validateForm = (vals) => friendlyError(handleValidation(vals));

expect(
validateForm({ perks: { benefits_package: 'basic', has_retirement_plan: 'no' } })
).toBeUndefined();

expect(getField(fields, 'perks', 'retirement_plan').isVisible).toBe(false);
});

it("retirement_plan fieldset is visible when user selects 'yes' for has_retirement_plan", () => {
const { fields, handleValidation } = createHeadlessForm(
schemaWithNestedFieldsetsConditionals,
{}
);
const validateForm = (vals) => friendlyError(handleValidation(vals));

expect(
validateForm({
perks: {
benefits_package: 'basic',
has_retirement_plan: 'yes',
declare_amount: 'yes',
retirement_plan: { plan_name: 'test', year: 2025 },
},
})
).toEqual({
perks: {
retirement_plan: {
amount: 'Required field',
},
},
});

expect(getField(fields, 'perks', 'retirement_plan').isVisible).toBe(true);
expect(getField(fields, 'perks', 'declare_amount').isVisible).toBe(true);
expect(getField(fields, 'perks', 'declare_amount').default).toBe('yes');
expect(getField(fields, 'perks', 'retirement_plan', 'amount').isVisible).toBe(true);
});

it("retirement_plan's amount field is hidden when user selects 'no' for declare_amount", () => {
const { fields, handleValidation } = createHeadlessForm(
schemaWithNestedFieldsetsConditionals,
{}
);
const validateForm = (vals) => friendlyError(handleValidation(vals));

expect(
validateForm({
perks: {
benefits_package: 'basic',
has_retirement_plan: 'yes',
declare_amount: 'no',
retirement_plan: { plan_name: 'test', year: 2025 },
},
})
).toBeUndefined();

expect(getField(fields, 'perks', 'retirement_plan').isVisible).toBe(true);
expect(getField(fields, 'perks', 'declare_amount').isVisible).toBe(true);
expect(getField(fields, 'perks', 'retirement_plan', 'amount').isVisible).toBe(false);
});

it('submits with valid retirement_plan', async () => {
const { handleValidation } = createHeadlessForm(
schemaWithNestedFieldsetsConditionals,
{}
);
const validateForm = (vals) => friendlyError(handleValidation(vals));

expect(
validateForm({
perks: {
benefits_package: 'plus',
has_retirement_plan: 'yes',
retirement_plan: { plan_name: 'test', year: 2025, amount: 1000 },
},
})
).toBeUndefined();
});
});

describe('supports computed values based on values from nested fieldsets', () => {
it("computed value for total_contributions is calculated correctly with defaults when user selects 'yes' for has_retirement_plan", () => {
const { fields, handleValidation } = createHeadlessForm(
schemaWithNestedFieldsetsConditionals,
{}
);
const validateForm = (vals) => friendlyError(handleValidation(vals));

expect(
validateForm({
perks: {
benefits_package: 'basic',
has_retirement_plan: 'yes',
declare_amount: 'no',
retirement_plan: {
plan_name: 'test',
create_plan: 'no',
},
},
})
).toEqual({ perks: { retirement_plan: { year: 'Required field' } } });

expect(getField(fields, 'total_contributions').isVisible).toBe(true);
expect(getField(fields, 'total_contributions').default).toBe(0);
expect(getField(fields, 'total_contributions').const).toBe(0);
});

it('computed value for total_contributions is calculated correctly based on the selected months', () => {
const { fields, handleValidation } = createHeadlessForm(
schemaWithNestedFieldsetsConditionals,
{}
);
const validateForm = (vals) => friendlyError(handleValidation(vals));

expect(
validateForm({
perks: {
benefits_package: 'basic',
has_retirement_plan: 'yes',
declare_amount: 'no',
retirement_plan: {
plan_name: 'test',
year: 2025,
create_plan: 'yes',
planned_contributions: {
months: ['january', 'february', 'march', 'april', 'may'],
},
},
},
})
).toBeUndefined();

expect(getField(fields, 'total_contributions').isVisible).toBe(true);
expect(getField(fields, 'total_contributions').default).toBe(5);
expect(getField(fields, 'total_contributions').const).toBe(5);

expect(
validateForm({
perks: {
benefits_package: 'basic',
has_retirement_plan: 'yes',
declare_amount: 'no',
retirement_plan: {
plan_name: 'test',
year: 2025,
create_plan: 'yes',
planned_contributions: {
months: ['january', 'february', 'march'],
},
},
},
})
).toBeUndefined();

expect(getField(fields, 'total_contributions').default).toBe(3);
expect(getField(fields, 'total_contributions').const).toBe(3);
});
});
});

it('support "email" field type', () => {
Expand Down
Loading