From 4a6651493cda56ba554dbb26760516a23e0502d4 Mon Sep 17 00:00:00 2001 From: Daniel Cardoso Date: Fri, 11 Apr 2025 17:46:12 +0100 Subject: [PATCH 1/4] Support nested fieldset conditionals --- src/helpers.js | 53 ++++++++++++------ src/tests/createHeadlessForm.test.js | 42 ++++++++++++++ src/tests/helpers.js | 84 ++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 16 deletions(-) diff --git a/src/helpers.js b/src/helpers.js index f8d9b088..8dfaaaa9 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -330,6 +330,7 @@ export function processNode({ accRequired = new Set(), parentID = 'root', logic, + processingConditional = false, }) { // Set initial required fields const requiredFields = new Set(accRequired); @@ -338,6 +339,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 @@ -360,6 +379,7 @@ export function processNode({ accRequired: requiredFields, parentID, logic, + processingConditional: true, }); branchRequired.forEach((field) => requiredFields.add(field)); @@ -371,6 +391,7 @@ export function processNode({ accRequired: requiredFields, parentID, logic, + processingConditional: true, }); branchRequired.forEach((field) => requiredFields.add(field)); } @@ -390,6 +411,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) => @@ -407,22 +444,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'], diff --git a/src/tests/createHeadlessForm.test.js b/src/tests/createHeadlessForm.test.js index 5d5e12ba..6aa5271b 100644 --- a/src/tests/createHeadlessForm.test.js +++ b/src/tests/createHeadlessForm.test.js @@ -63,6 +63,7 @@ import { schemaForErrorMessageSpecificity, jsfConfigForErrorMessageSpecificity, schemaInputTypeFile, + schemaWithRootFieldsetsConditionals, } from './helpers'; import { mockConsole, restoreConsoleAndEnsureItWasNotCalled } from './testUtils'; import { createHeadlessForm } from '@/createHeadlessForm'; @@ -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', () => { diff --git a/src/tests/helpers.js b/src/tests/helpers.js index e07d3ee7..4ebf049a 100644 --- a/src/tests/helpers.js +++ b/src/tests/helpers.js @@ -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: { From 26b46828c7006fb7509392eea0c0b65d5bd98ff4 Mon Sep 17 00:00:00 2001 From: Daniel Cardoso Date: Fri, 11 Apr 2025 18:21:25 +0100 Subject: [PATCH 2/4] Release 0.11.12-dev.20250411172108 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6b4afe9a..383c73b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@remoteoss/json-schema-form", - "version": "0.11.11-beta.0", + "version": "0.11.12-dev.20250411172108", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@remoteoss/json-schema-form", - "version": "0.11.11-beta.0", + "version": "0.11.12-dev.20250411172108", "license": "MIT", "dependencies": { "json-logic-js": "^2.0.2", diff --git a/package.json b/package.json index 06e29069..19d3be33 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@remoteoss/json-schema-form", - "version": "0.11.11-beta.0", + "version": "0.11.12-dev.20250411172108", "description": "Headless UI form powered by JSON Schemas", "author": "Remote.com (https://remote.com/)", "license": "MIT", From c670cbe487f38ef60dc93d4b5004bed99c6d2a96 Mon Sep 17 00:00:00 2001 From: Daniel Cardoso Date: Fri, 11 Apr 2025 18:24:23 +0100 Subject: [PATCH 3/4] Release 0.11.12-dev.20250411172410 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 383c73b9..dd933e2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@remoteoss/json-schema-form", - "version": "0.11.12-dev.20250411172108", + "version": "0.11.12-dev.20250411172410", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@remoteoss/json-schema-form", - "version": "0.11.12-dev.20250411172108", + "version": "0.11.12-dev.20250411172410", "license": "MIT", "dependencies": { "json-logic-js": "^2.0.2", diff --git a/package.json b/package.json index 19d3be33..46fd91a2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@remoteoss/json-schema-form", - "version": "0.11.12-dev.20250411172108", + "version": "0.11.12-dev.20250411172410", "description": "Headless UI form powered by JSON Schemas", "author": "Remote.com (https://remote.com/)", "license": "MIT", From b3295f60055e778f6156f28f349f3d9ee796aeda Mon Sep 17 00:00:00 2001 From: Daniel Cardoso Date: Tue, 15 Apr 2025 19:07:05 +0100 Subject: [PATCH 4/4] Preserve fieldset nested fields visibility --- src/helpers.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/helpers.js b/src/helpers.js index 8dfaaaa9..20f9a798 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -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();