diff --git a/package-lock.json b/package-lock.json index 35fc90d60..dd933e2d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@remoteoss/json-schema-form", - "version": "0.11.14-beta.0", + "version": "0.11.12-dev.20250411172410", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@remoteoss/json-schema-form", - "version": "0.11.14-beta.0", + "version": "0.11.12-dev.20250411172410", "license": "MIT", "dependencies": { "json-logic-js": "^2.0.2", diff --git a/package.json b/package.json index 6e233fbc5..46fd91a29 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@remoteoss/json-schema-form", - "version": "0.11.14-beta.0", + "version": "0.11.12-dev.20250411172410", "description": "Headless UI form powered by JSON Schemas", "author": "Remote.com (https://remote.com/)", "license": "MIT", diff --git a/src/helpers.js b/src/helpers.js index 96ad87efa..82bdef394 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -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 * @@ -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 a 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(); @@ -331,6 +383,7 @@ export function processNode({ accRequired = new Set(), parentID = 'root', logic, + processingConditional = false, }) { // Set initial required fields const requiredFields = new Set(accRequired); @@ -339,6 +392,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 @@ -361,6 +432,7 @@ export function processNode({ accRequired: requiredFields, parentID, logic, + processingConditional: true, }); branchRequired.forEach((field) => requiredFields.add(field)); @@ -372,6 +444,7 @@ export function processNode({ accRequired: requiredFields, parentID, logic, + processingConditional: true, }); branchRequired.forEach((field) => requiredFields.add(field)); } @@ -391,6 +464,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) => @@ -408,22 +497,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 e93b2c824..de627820c 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 783fcdbf5..3266660fa 100644 --- a/src/tests/helpers.js +++ b/src/tests/helpers.js @@ -1972,6 +1972,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: {