Skip to content

Commit 877e82e

Browse files
feat: Support nested fieldset conditionals (#156)
* Support nested fieldset visibility conditionals and validations * Fixes v0 build script and linting
1 parent ecf8f03 commit 877e82e

File tree

8 files changed

+614
-35
lines changed

8 files changed

+614
-35
lines changed

v0/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
},
4444
"lint-staged": {
4545
"*.{js,jsx}": [
46-
"npm run format"
46+
"npm run format --prefix ./v0"
4747
]
4848
},
4949
"dependencies": {

v0/scripts/build.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const esbuild = require('esbuild');
66

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

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

v0/src/calculateConditionalProperties.js

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,23 @@ function isFieldRequired(node, field) {
2828
/**
2929
* Loops recursively through fieldset fields and returns an copy version of them
3030
* where the required property is updated.
31+
* Since rebuildFieldset is called within a closure, we pass the current fields as parameter
32+
* to restore the computed isVisible property.
3133
*
3234
* @param {Array} fields - list of fields of a fieldset
3335
* @param {Object} property - property that relates with the list of fields
3436
* @returns {Object}
3537
*/
36-
function rebuildFieldset(fields, property) {
38+
function rebuildFieldset(fields, currentFields, property) {
3739
if (property?.properties) {
38-
return fields.map((field) => {
40+
return fields.map((field, index) => {
3941
const propertyConditionals = property.properties[field.name];
42+
const isVisible = currentFields[index].isVisible;
4043
if (!propertyConditionals) {
41-
return field;
44+
return {
45+
...field,
46+
isVisible,
47+
};
4248
}
4349

4450
const newFieldParams = extractParametersFromNode(propertyConditionals);
@@ -47,19 +53,22 @@ function rebuildFieldset(fields, property) {
4753
return {
4854
...field,
4955
...newFieldParams,
50-
fields: rebuildFieldset(field.fields, propertyConditionals),
56+
isVisible,
57+
fields: rebuildFieldset(field.fields, currentFields[index].fields, propertyConditionals),
5158
};
5259
}
5360
return {
5461
...field,
5562
...newFieldParams,
63+
isVisible,
5664
required: isFieldRequired(property, field),
5765
};
5866
});
5967
}
6068

61-
return fields.map((field) => ({
69+
return fields.map((field, index) => ({
6270
...field,
71+
isVisible: currentFields[index].isVisible,
6372
required: isFieldRequired(property, field),
6473
}));
6574
}
@@ -92,7 +101,7 @@ export function calculateConditionalProperties({ fieldParams, customProperties,
92101
*
93102
* @returns {calculateConditionalPropertiesReturn}
94103
*/
95-
return ({ isRequired, conditionBranch, formValues }) => {
104+
return ({ isRequired, conditionBranch, formValues, currentField }) => {
96105
// Check if the current field is conditionally declared in the schema
97106
// console.log('::calc (closure original)', fieldParams.description);
98107
const conditionalProperty = conditionBranch?.properties?.[fieldParams.name];
@@ -110,7 +119,11 @@ export function calculateConditionalProperties({ fieldParams, customProperties,
110119
let fieldSetFields;
111120

112121
if (fieldParams.inputType === supportedTypes.FIELDSET) {
113-
fieldSetFields = rebuildFieldset(fieldParams.fields, conditionalProperty);
122+
fieldSetFields = rebuildFieldset(
123+
fieldParams.fields,
124+
currentField.fields,
125+
conditionalProperty
126+
);
114127
newFieldParams.fields = fieldSetFields;
115128
}
116129

v0/src/helpers.js

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import set from 'lodash/set';
77
import { lazy } from 'yup';
88

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

@@ -295,6 +295,7 @@ function updateField(field, requiredFields, node, formValues, logic, config) {
295295
isRequired: fieldIsRequired,
296296
conditionBranch: node,
297297
formValues,
298+
currentField: field,
298299
});
299300
updateAttributes(newAttributes);
300301
removeConditionalStaleAttributes(field, newAttributes, rootFieldAttrs);
@@ -336,9 +337,22 @@ export function processNode({
336337
const requiredFields = new Set(accRequired);
337338

338339
// Go through the node properties definition and update each field accordingly
339-
Object.keys(node.properties ?? []).forEach((fieldName) => {
340+
Object.entries(node.properties ?? []).forEach(([fieldName, nestedNode]) => {
340341
const field = getField(fieldName, formFields);
341342
updateField(field, requiredFields, node, formValues, logic, { parentID });
343+
344+
// If we're processing a fieldset field node
345+
// update the nested fields going through the node recursively.
346+
const isFieldset = field?.inputType === supportedTypes.FIELDSET;
347+
if (isFieldset) {
348+
processNode({
349+
node: nestedNode,
350+
formValues: formValues[fieldName] || {},
351+
formFields: field.fields,
352+
parentID,
353+
logic,
354+
});
355+
}
342356
});
343357

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

352366
if (node.if !== undefined) {
353367
const matchesCondition = checkIfConditionMatchesProperties(node, formValues, formFields, logic);
354-
// BUG HERE (unreleated) - what if it matches but doesn't has a then,
368+
// BUG HERE (unrelated) - what if it matches but doesn't has a then,
355369
// it should do nothing, but instead it jumps to node.else when it shouldn't.
356370
if (matchesCondition && node.then) {
357371
const { required: branchRequired } = processNode({
@@ -408,22 +422,6 @@ export function processNode({
408422
});
409423
}
410424

411-
if (node.properties) {
412-
Object.entries(node.properties).forEach(([name, nestedNode]) => {
413-
const inputType = getInputType(nestedNode);
414-
if (inputType === supportedTypes.FIELDSET) {
415-
// It's a fieldset, which might contain scoped conditions
416-
processNode({
417-
node: nestedNode,
418-
formValues: formValues[name] || {},
419-
formFields: getField(name, formFields).fields,
420-
parentID: name,
421-
logic,
422-
});
423-
}
424-
});
425-
}
426-
427425
if (node['x-jsf-logic']) {
428426
const { required: requiredFromLogic } = processJSONLogicNode({
429427
node: node['x-jsf-logic'],
@@ -451,14 +449,14 @@ export function processNode({
451449
function clearValuesIfNotVisible(fields, formValues) {
452450
fields.forEach(({ isVisible = true, name, inputType, fields: nestedFields }) => {
453451
if (!isVisible) {
454-
// TODO I (Sandrina) think this doesn't work. I didn't find any test covering this scenario. Revisit later.
455452
formValues[name] = null;
456453
}
457454
if (inputType === supportedTypes.FIELDSET && nestedFields && formValues[name]) {
458455
clearValuesIfNotVisible(nestedFields, formValues[name]);
459456
}
460457
});
461458
}
459+
462460
/**
463461
* Updates form fields properties based on the current form state and the JSON schema rules
464462
*
@@ -500,7 +498,7 @@ function getFieldOptions(node, presentation) {
500498
}));
501499
}
502500

503-
/** @deprecated - takes precendence in case a JSON Schema still has deprecated options */
501+
/** @deprecated - takes precedence in case a JSON Schema still has deprecated options */
504502
if (presentation.options) {
505503
return presentation.options;
506504
}

v0/src/tests/createHeadlessForm.test.js

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import {
6363
schemaForErrorMessageSpecificity,
6464
jsfConfigForErrorMessageSpecificity,
6565
schemaInputTypeFile,
66+
schemaWithNestedFieldsetsConditionals,
6667
} from './helpers';
6768
import { mockConsole, restoreConsoleAndEnsureItWasNotCalled } from './testUtils';
6869
import { createHeadlessForm } from '@/createHeadlessForm';
@@ -2200,6 +2201,191 @@ describe('createHeadlessForm', () => {
22002201
).toBeUndefined();
22012202
});
22022203
});
2204+
2205+
describe('supports conditionals over nested fieldsets', () => {
2206+
it('retirement_plan fieldset is hidden when no values are provided', () => {
2207+
const { fields, handleValidation } = createHeadlessForm(
2208+
schemaWithNestedFieldsetsConditionals,
2209+
{}
2210+
);
2211+
const validateForm = (vals) => friendlyError(handleValidation(vals));
2212+
2213+
expect(getField(fields, 'perks', 'retirement_plan').isVisible).toBe(false);
2214+
2215+
expect(validateForm({ perks: {} })).toEqual({
2216+
perks: {
2217+
benefits_package: 'Required field',
2218+
has_retirement_plan: 'Required field',
2219+
},
2220+
});
2221+
2222+
expect(getField(fields, 'perks', 'retirement_plan').isVisible).toBe(false);
2223+
});
2224+
2225+
it("submits without retirement_plan when user selects 'no' for has_retirement_plan", () => {
2226+
const { fields, handleValidation } = createHeadlessForm(
2227+
schemaWithNestedFieldsetsConditionals,
2228+
{}
2229+
);
2230+
const validateForm = (vals) => friendlyError(handleValidation(vals));
2231+
2232+
expect(
2233+
validateForm({ perks: { benefits_package: 'basic', has_retirement_plan: 'no' } })
2234+
).toBeUndefined();
2235+
2236+
expect(getField(fields, 'perks', 'retirement_plan').isVisible).toBe(false);
2237+
});
2238+
2239+
it("retirement_plan fieldset is visible when user selects 'yes' for has_retirement_plan", () => {
2240+
const { fields, handleValidation } = createHeadlessForm(
2241+
schemaWithNestedFieldsetsConditionals,
2242+
{}
2243+
);
2244+
const validateForm = (vals) => friendlyError(handleValidation(vals));
2245+
2246+
expect(
2247+
validateForm({
2248+
perks: {
2249+
benefits_package: 'basic',
2250+
has_retirement_plan: 'yes',
2251+
declare_amount: 'yes',
2252+
retirement_plan: { plan_name: 'test', year: 2025 },
2253+
},
2254+
})
2255+
).toEqual({
2256+
perks: {
2257+
retirement_plan: {
2258+
amount: 'Required field',
2259+
},
2260+
},
2261+
});
2262+
2263+
expect(getField(fields, 'perks', 'retirement_plan').isVisible).toBe(true);
2264+
expect(getField(fields, 'perks', 'declare_amount').isVisible).toBe(true);
2265+
expect(getField(fields, 'perks', 'declare_amount').default).toBe('yes');
2266+
expect(getField(fields, 'perks', 'retirement_plan', 'amount').isVisible).toBe(true);
2267+
});
2268+
2269+
it("retirement_plan's amount field is hidden when user selects 'no' for declare_amount", () => {
2270+
const { fields, handleValidation } = createHeadlessForm(
2271+
schemaWithNestedFieldsetsConditionals,
2272+
{}
2273+
);
2274+
const validateForm = (vals) => friendlyError(handleValidation(vals));
2275+
2276+
expect(
2277+
validateForm({
2278+
perks: {
2279+
benefits_package: 'basic',
2280+
has_retirement_plan: 'yes',
2281+
declare_amount: 'no',
2282+
retirement_plan: { plan_name: 'test', year: 2025 },
2283+
},
2284+
})
2285+
).toBeUndefined();
2286+
2287+
expect(getField(fields, 'perks', 'retirement_plan').isVisible).toBe(true);
2288+
expect(getField(fields, 'perks', 'declare_amount').isVisible).toBe(true);
2289+
expect(getField(fields, 'perks', 'retirement_plan', 'amount').isVisible).toBe(false);
2290+
});
2291+
2292+
it('submits with valid retirement_plan', async () => {
2293+
const { handleValidation } = createHeadlessForm(
2294+
schemaWithNestedFieldsetsConditionals,
2295+
{}
2296+
);
2297+
const validateForm = (vals) => friendlyError(handleValidation(vals));
2298+
2299+
expect(
2300+
validateForm({
2301+
perks: {
2302+
benefits_package: 'plus',
2303+
has_retirement_plan: 'yes',
2304+
retirement_plan: { plan_name: 'test', year: 2025, amount: 1000 },
2305+
},
2306+
})
2307+
).toBeUndefined();
2308+
});
2309+
});
2310+
2311+
describe('supports computed values based on values from nested fieldsets', () => {
2312+
it("computed value for total_contributions is calculated correctly with defaults when user selects 'yes' for has_retirement_plan", () => {
2313+
const { fields, handleValidation } = createHeadlessForm(
2314+
schemaWithNestedFieldsetsConditionals,
2315+
{}
2316+
);
2317+
const validateForm = (vals) => friendlyError(handleValidation(vals));
2318+
2319+
expect(
2320+
validateForm({
2321+
perks: {
2322+
benefits_package: 'basic',
2323+
has_retirement_plan: 'yes',
2324+
declare_amount: 'no',
2325+
retirement_plan: {
2326+
plan_name: 'test',
2327+
create_plan: 'no',
2328+
},
2329+
},
2330+
})
2331+
).toEqual({ perks: { retirement_plan: { year: 'Required field' } } });
2332+
2333+
expect(getField(fields, 'total_contributions').isVisible).toBe(true);
2334+
expect(getField(fields, 'total_contributions').default).toBe(0);
2335+
expect(getField(fields, 'total_contributions').const).toBe(0);
2336+
});
2337+
2338+
it('computed value for total_contributions is calculated correctly based on the selected months', () => {
2339+
const { fields, handleValidation } = createHeadlessForm(
2340+
schemaWithNestedFieldsetsConditionals,
2341+
{}
2342+
);
2343+
const validateForm = (vals) => friendlyError(handleValidation(vals));
2344+
2345+
expect(
2346+
validateForm({
2347+
perks: {
2348+
benefits_package: 'basic',
2349+
has_retirement_plan: 'yes',
2350+
declare_amount: 'no',
2351+
retirement_plan: {
2352+
plan_name: 'test',
2353+
year: 2025,
2354+
create_plan: 'yes',
2355+
planned_contributions: {
2356+
months: ['january', 'february', 'march', 'april', 'may'],
2357+
},
2358+
},
2359+
},
2360+
})
2361+
).toBeUndefined();
2362+
2363+
expect(getField(fields, 'total_contributions').isVisible).toBe(true);
2364+
expect(getField(fields, 'total_contributions').default).toBe(5);
2365+
expect(getField(fields, 'total_contributions').const).toBe(5);
2366+
2367+
expect(
2368+
validateForm({
2369+
perks: {
2370+
benefits_package: 'basic',
2371+
has_retirement_plan: 'yes',
2372+
declare_amount: 'no',
2373+
retirement_plan: {
2374+
plan_name: 'test',
2375+
year: 2025,
2376+
create_plan: 'yes',
2377+
planned_contributions: {
2378+
months: ['january', 'february', 'march'],
2379+
},
2380+
},
2381+
},
2382+
})
2383+
).toBeUndefined();
2384+
2385+
expect(getField(fields, 'total_contributions').default).toBe(3);
2386+
expect(getField(fields, 'total_contributions').const).toBe(3);
2387+
});
2388+
});
22032389
});
22042390

22052391
it('support "email" field type', () => {

0 commit comments

Comments
 (0)