Skip to content
Draft
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
85 changes: 67 additions & 18 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 @@ -213,6 +213,46 @@ export function getPrefillValues(fields, initialValues = {}) {
return initialValues;
}

/**
* Preserves the visibility of nested fields in a fieldset
* @param {Object} field - field object
* @param {String} parentPath - path to the parent field
* @returns {Object} - object with a map of the visibility of the nested fields, e.g. { 'parent.child': true }
*/
function preserveNestedFieldsVisibility(field, parentPath = '') {
return field.fields?.reduce?.((acc, f) => {
const path = parentPath ? `${parentPath}.${f.name}` : f.name;
if (!isNil(f.isVisible)) {
acc[path] = f.isVisible;
}

if (f.fields) {
Object.assign(acc, preserveNestedFieldsVisibility(f, path));
}
return acc;
}, {});
}

/**
* Restores the visibility of nested fields in a fieldset
* @param {Object} field - field object
* @param {Object} nestedFieldsVisibility - object with a map of the visibility of the nested fields, e.g. { 'parent.child': true }
* @param {String} parentPath - path to the parent field
*/
function restoreNestedFieldsVisibility(field, nestedFieldsVisibility, parentPath = '') {
field.fields.forEach((f) => {
const path = parentPath ? `${parentPath}.${f.name}` : f.name;
const visibility = get(nestedFieldsVisibility, path);
if (!isNil(visibility)) {
f.isVisible = visibility;
}

if (f.fields) {
restoreNestedFieldsVisibility(f, nestedFieldsVisibility, path);
}
});
}

/**
* Updates field properties based on the current JSON-schema node and the required fields
*
Expand Down Expand Up @@ -243,10 +283,22 @@ function updateField(field, requiredFields, node, formValues, logic, config) {
field.isVisible = true;
}

// Store current visibility of fields within a fieldset before updating its attributes
const nestedFieldsVisibility = preserveNestedFieldsVisibility(field);

const updateAttributes = (fieldAttrs) => {
Object.entries(fieldAttrs).forEach(([key, value]) => {
field[key] = value;

// If the field is a fieldset, restore the visibility of the fields within it.
// If this is not in place, calling updateField for multiple conditionals touching
// the same fieldset will unset previously calculated visibility for the nested fields.
// This is because rebuildFieldset is called via the calculateConditionalProperties closure
// created at the time of building the fields, and it returns a new fieldset.fields array
if (key === 'fields' && !isNil(nestedFieldsVisibility)) {
restoreNestedFieldsVisibility(field, nestedFieldsVisibility);
}

if (key === 'schema' && typeof value === 'function') {
// key "schema" refers to YupSchema that needs to be processed for validations.
field[key] = value();
Expand Down Expand Up @@ -336,9 +388,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 Down Expand Up @@ -408,22 +473,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
107 changes: 107 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,112 @@ 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();
});
});
});

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