Skip to content

feat: Support nested fieldset conditionals #156

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

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
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
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@remoteoss/json-schema-form",
"version": "0.11.11-beta.0",
"version": "0.11.12-dev.20250411172410",
"description": "Headless UI form powered by JSON Schemas",
"author": "Remote.com <[email protected]> (https://remote.com/)",
"license": "MIT",
Expand Down
68 changes: 52 additions & 16 deletions src/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -242,10 +242,25 @@ 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 = field.fields?.reduce?.((acc, f) => {
acc[f.name] = f.isVisible;
return acc;
}, {});

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.
if (key === 'fields' && !isNil(nestedFieldsVisibility)) {
field.fields.forEach((f) => {
f.isVisible = nestedFieldsVisibility[f.name];
});
}

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 @@ -330,6 +345,7 @@ export function processNode({
accRequired = new Set(),
parentID = 'root',
logic,
processingConditional = false,
}) {
// Set initial required fields
const requiredFields = new Set(accRequired);
Expand All @@ -338,6 +354,24 @@ export function processNode({
Object.keys(node.properties ?? []).forEach((fieldName) => {
const field = getField(fieldName, formFields);
updateField(field, requiredFields, node, formValues, logic, { parentID });

// If we're processing a conditional field node and it's respective to a fieldset field,
// update the nested fields going through the node recursively.
// As an example, the node here can be:
// 1. { properties: { perks: { properties: { retirement: { const: 'basic' } } } } } }
// 2. { properties: { perks: { required: ['retirement'] } } } }
// where 'perks' is a fieldset field.
const nestedNode = node.properties[fieldName];
const isFieldset = field?.inputType === supportedTypes.FIELDSET;
if (isFieldset && processingConditional) {
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 @@ -360,6 +394,7 @@ export function processNode({
accRequired: requiredFields,
parentID,
logic,
processingConditional: true,
});

branchRequired.forEach((field) => requiredFields.add(field));
Expand All @@ -371,6 +406,7 @@ export function processNode({
accRequired: requiredFields,
parentID,
logic,
processingConditional: true,
});
branchRequired.forEach((field) => requiredFields.add(field));
}
Expand All @@ -390,6 +426,22 @@ 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.allOf) {
node.allOf
.map((allOfNode) =>
Expand All @@ -407,22 +459,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
42 changes: 42 additions & 0 deletions 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,
schemaWithRootFieldsetsConditionals,
} from './helpers';
import { mockConsole, restoreConsoleAndEnsureItWasNotCalled } from './testUtils';
import { createHeadlessForm } from '@/createHeadlessForm';
Expand Down Expand Up @@ -2162,6 +2163,47 @@ describe('createHeadlessForm', () => {
).toBeUndefined();
});
});

describe('supports root fieldsets conditionals', () => {
it('Given a basic retirement, the perks.has_pension is hidden', async () => {
const { fields, handleValidation } = createHeadlessForm(
schemaWithRootFieldsetsConditionals,
{}
);
const validateForm = (vals) => friendlyError(handleValidation(vals));

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

// has_pension is not visible
expect(getField(fields, 'perks', 'has_pension').isVisible).toBe(false);

expect(
validateForm({
perks: { retirement: 'plus' },
})
).toEqual({
perks: {
has_pension: 'Required field',
},
});

// field becomes visible
expect(getField(fields, 'perks', 'has_pension').isVisible).toBe(true);

expect(
validateForm({
perks: { retirement: 'basic' },
})
).toBeUndefined();

// field becomes invisible
expect(getField(fields, 'perks', 'has_pension').isVisible).toBe(false);
});
});
});

it('support "email" field type', () => {
Expand Down
84 changes: 84 additions & 0 deletions src/tests/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -1973,6 +1973,90 @@ export const schemaWithConditionalToFieldset = {
required: ['perks', 'work_hours_per_week'],
};

export const schemaWithRootFieldsetsConditionals = {
additionalProperties: false,
type: 'object',
properties: {
perks: {
additionalProperties: false,
properties: {
retirement: {
oneOf: [
{
const: 'basic',
title: 'Basic',
},
{
const: 'plus',
title: 'Plus',
},
],
title: 'Retirement',
type: 'string',
'x-jsf-presentation': {
inputType: 'radio',
},
},
has_pension: {
oneOf: [
{
const: 'yes',
title: 'Yes',
},
{
const: 'no',
title: 'No',
},
],
title: 'Has pension',
type: 'string',
'x-jsf-presentation': {
inputType: 'radio',
},
},
},
required: ['retirement'],
title: 'Perks',
type: 'object',
'x-jsf-presentation': {
inputType: 'fieldset',
},
},
},
required: ['perks'],
allOf: [
{
if: {
properties: {
perks: {
properties: {
retirement: {
const: 'basic',
},
},
},
},
},
then: {
properties: {
perks: {
properties: {
has_pension: false,
},
},
},
},
else: {
properties: {
perks: {
required: ['has_pension'],
},
},
},
},
],
};

export const schemaWorkSchedule = {
type: 'object',
properties: {
Expand Down