diff --git a/package-lock.json b/package-lock.json index 4137bc28f..0c07f856b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@remoteoss/json-schema-form", - "version": "0.11.10-beta.0", + "version": "0.11.11-dev.20250220174843", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@remoteoss/json-schema-form", - "version": "0.11.10-beta.0", + "version": "0.11.11-dev.20250220174843", "license": "MIT", "dependencies": { "json-logic-js": "^2.0.2", diff --git a/package.json b/package.json index 1b98b1035..6617f2179 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@remoteoss/json-schema-form", - "version": "0.11.10-beta.0", + "version": "0.11.11-dev.20250220174843", "description": "Headless UI form powered by JSON Schemas", "author": "Remote.com (https://remote.com/)", "license": "MIT", diff --git a/src/createHeadlessForm.js b/src/createHeadlessForm.js index e8a767321..4dbebe654 100644 --- a/src/createHeadlessForm.js +++ b/src/createHeadlessForm.js @@ -118,6 +118,19 @@ function buildFieldParameters(name, fieldProperties, required = [], config = {}, ); } + if (inputType === supportedTypes.GROUP_ARRAY) { + // eslint-disable-next-line no-use-before-define + fields = () => + getFieldsFromJSONSchema( + fieldProperties.items, + { + customProperties: get(config, `customProperties.${name}.customProperties`, {}), + parentID: name, + }, + logic + ); + } + const result = { name, inputType, @@ -242,7 +255,6 @@ function buildField(fieldParams, config, scopedJsonSchema, logic) { const calculateConditionalFieldsClosure = fieldParams.isDynamic && calculateConditionalProperties({ fieldParams, customProperties, logic, config }); - const calculateCustomValidationPropertiesClosure = calculateCustomValidationProperties( fieldParams, customProperties @@ -301,14 +313,11 @@ function getFieldsFromJSONSchema(scopedJsonSchema, config, logic) { if (fieldParams.inputType === 'group-array') { const groupArrayItems = convertJSONSchemaPropertiesToFieldParameters(fieldParams.items); const groupArrayFields = groupArrayItems.map((groupArrayItem) => { - groupArrayItem.nameKey = groupArrayItem.name; const customProperties = null; // getCustomPropertiesForField(fieldParams, config); // TODO later support in group-array const composeFn = getComposeFunctionForField(groupArrayItem, !!customProperties); return composeFn(groupArrayItem); }); - fieldParams.nameKey = fieldParams.name; - fieldParams.nthFieldGroup = { name: fieldParams.name, label: fieldParams.label, diff --git a/src/helpers.js b/src/helpers.js index f8d9b088a..b5591d883 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -72,7 +72,11 @@ function hasType(type, typeName) { * @returns */ export function getField(fieldName, fields) { - return fields.find(({ name }) => name === fieldName); + if (Array.isArray(fields)) { + return fields.find(({ name }) => name === fieldName); + } else { + return fields[fieldName]; + } } /** @@ -99,6 +103,7 @@ export function compareFormValueWithSchemaValue(formValue, schemaValue) { // fallback to undefined since JSON-schemas empty values come represented as null const currentPropertyValue = typeof schemaValue === 'number' ? schemaValue : schemaValue || undefined; + // We're using the stringified version of both values since numeric values from forms come represented as Strings. // By doing this, we're sure that we're comparing the same type. return String(formValue) === String(currentPropertyValue); @@ -333,7 +338,6 @@ export function processNode({ }) { // Set initial required fields const requiredFields = new Set(accRequired); - // Go through the node properties definition and update each field accordingly Object.keys(node.properties ?? []).forEach((fieldName) => { const field = getField(fieldName, formFields); @@ -420,6 +424,26 @@ export function processNode({ logic, }); } + if (inputType === supportedTypes.GROUP_ARRAY) { + // It's a group array, which might contain scoped conditions + const values = formValues[name]; + if (Array.isArray(values)) { + const newFields = []; + const field = getField(name, formFields); + values.forEach((value) => { + const fields = field.fields(); + processNode({ + node: nestedNode.items, + formValues: value, + formFields: fields, + parentID: name, + logic, + }); + newFields.push(fields); + }); + field.dynamicFields = newFields; + } + } }); } diff --git a/src/internals/checkIfConditionMatches.js b/src/internals/checkIfConditionMatches.js index 4d1704e71..9d7e39be0 100644 --- a/src/internals/checkIfConditionMatches.js +++ b/src/internals/checkIfConditionMatches.js @@ -30,7 +30,6 @@ export function checkIfConditionMatchesProperties(node, formValues, formFields, // https://json-schema.org/understanding-json-schema/reference/conditionals.html#if-then-else return true; } - if (hasProperty(currentProperty, 'const')) { return compareFormValueWithSchemaValue(value, currentProperty.const); } diff --git a/src/tests/conditions.test.js b/src/tests/conditions.test.js index 9ec72de90..f83444ad6 100644 --- a/src/tests/conditions.test.js +++ b/src/tests/conditions.test.js @@ -422,6 +422,233 @@ describe('Conditional attributes updated', () => { handleValidation({ is_full_time: 'no' }); expect(fields[1].visibilityCondition).toEqual(expect.any(Function)); }); + + it('Group array nested condition', () => { + const { fields, handleValidation } = createHeadlessForm({ + type: 'object', + additionalProperties: false, + properties: { + companies: { + type: 'array', + 'x-jsf-presentation': { + inputType: 'group-array', + }, + items: { + type: 'object', + properties: { + company: { + type: 'string', + oneOf: [ + { + title: 'A', + const: 'A', + }, + { + title: 'B', + const: 'B', + }, + ], + 'x-jsf-presentation': { + inputType: 'select', + }, + }, + role: { + title: 'Role', + oneOf: [], + 'x-jsf-presentation': { + inputType: 'select', + }, + }, + }, + allOf: [ + { + if: { + properties: { + company: { + const: 'A', + }, + }, + required: ['company'], + }, + then: { + properties: { + role: { + oneOf: [ + { + title: 'adminA', + const: 'adminA', + }, + { + title: 'userA', + const: 'userA', + }, + ], + 'x-jsf-presentation': { + inputType: 'select', + }, + }, + }, + required: ['role'], + }, + }, + { + if: { + properties: { + company: { + const: 'B', + }, + }, + required: ['company'], + }, + then: { + properties: { + role: { + oneOf: [ + { + title: 'adminB', + const: 'adminB', + }, + { + title: 'userB', + const: 'userB', + }, + ], + 'x-jsf-presentation': { + inputType: 'select', + }, + }, + }, + required: ['role'], + }, + }, + ], + required: ['company', 'role'], + }, + }, + }, + required: ['companies'], + }); + + handleValidation({ companies: [{ company: 'A' }, { company: 'B' }] }); + expect(fields[0].dynamicFields[0][1].options).toEqual([ + { label: 'adminA', value: 'adminA' }, + { label: 'userA', value: 'userA' }, + ]); + expect(fields[0].dynamicFields[1][1].options).toEqual([ + { label: 'adminB', value: 'adminB' }, + { label: 'userB', value: 'userB' }, + ]); + handleValidation({ companies: [] }); + expect(fields[0].dynamicFields).toEqual([]); + }); + + it('select multiple conditions', () => { + const { fields, handleValidation } = createHeadlessForm({ + type: 'object', + additionalProperties: false, + properties: { + company: { + title: 'Select Company', + type: 'string', + description: 'Choose a company', + oneOf: [ + { + title: 'A', + const: 'A', + }, + { + title: 'B', + const: 'B', + }, + ], + 'x-jsf-presentation': { + inputType: 'select', + }, + }, + role: { + title: 'Role', + oneOf: [], + 'x-jsf-presentation': { + inputType: 'select', + }, + }, + }, + allOf: [ + { + if: { + properties: { + company: { + const: 'A', + }, + }, + required: ['company'], + }, + then: { + properties: { + role: { + oneOf: [ + { + title: 'adminA', + const: 'adminA', + }, + { + title: 'userA', + const: 'userA', + }, + ], + 'x-jsf-presentation': { + inputType: 'select', + }, + }, + }, + required: ['role'], + }, + }, + { + if: { + properties: { + company: { + const: 'B', + }, + }, + required: ['company'], + }, + then: { + properties: { + role: { + oneOf: [ + { + title: 'adminB', + const: 'adminB', + }, + { + title: 'userB', + const: 'userB', + }, + ], + 'x-jsf-presentation': { + inputType: 'select', + }, + }, + }, + required: ['role'], + }, + }, + ], + required: ['company', 'role'], + }); + + handleValidation({ company: 'A' }); + expect(fields[1].options).toEqual([ + { label: 'adminA', value: 'adminA' }, + { label: 'userA', value: 'userA' }, + ]); + handleValidation({ company: 'B' }); + expect(fields[1].options).toEqual([ + { label: 'adminB', value: 'adminB' }, + { label: 'userB', value: 'userB' }, + ]); + }); }); describe('Conditional with a minimum value check', () => { diff --git a/src/tests/createHeadlessForm.test.js b/src/tests/createHeadlessForm.test.js index 5d5e12ba4..875198868 100644 --- a/src/tests/createHeadlessForm.test.js +++ b/src/tests/createHeadlessForm.test.js @@ -63,6 +63,7 @@ import { schemaForErrorMessageSpecificity, jsfConfigForErrorMessageSpecificity, schemaInputTypeFile, + nestedGroupArrayForm, } from './helpers'; import { mockConsole, restoreConsoleAndEnsureItWasNotCalled } from './testUtils'; import { createHeadlessForm } from '@/createHeadlessForm'; @@ -1604,7 +1605,6 @@ describe('createHeadlessForm', () => { type: 'text', description: 'Enter your child’s full name', maxLength: 255, - nameKey: 'full_name', label: 'Child Full Name', name: 'full_name', required: true, @@ -1616,7 +1616,6 @@ describe('createHeadlessForm', () => { required: true, description: 'Enter your child’s date of birth', maxLength: 255, - nameKey: 'birthdate', }, { type: 'radio', @@ -1635,7 +1634,6 @@ describe('createHeadlessForm', () => { required: true, description: 'We know sex is non-binary but for insurance and payroll purposes, we need to collect this information.', - nameKey: 'sex', }, ]); }); @@ -1737,6 +1735,63 @@ describe('createHeadlessForm', () => { }); }); + it('nested "group-array" fields', () => { + const result = createHeadlessForm({ + properties: { + nestedGroupArray: nestedGroupArrayForm, + }, + }); + + expect(result.fields[0]).toMatchObject({ + label: 'Parent object', + name: 'nestedGroupArray', + required: false, + type: 'group-array', + inputType: 'group-array', + jsonType: 'array', + fields: expect.any(Function), + }); + + expect(result.fields[0].fields()).toMatchObject([ + { + type: 'text', + description: 'Simple text field', + maxLength: 255, + label: 'Outer Field', + name: 'notNested', + required: true, + }, + { + label: 'Nested group-array', + name: 'nested', + required: true, + type: 'group-array', + inputType: 'group-array', + jsonType: 'array', + fields: expect.any(Function), + }, + ]); + + expect(result.fields[0].fields()[1].fields()).toMatchObject([ + { + type: 'text', + description: 'First nested text field', + maxLength: 255, + label: 'Inner Field 1', + name: 'nestedField1', + required: true, + }, + { + type: 'text', + description: 'Second nested text field', + maxLength: 255, + label: 'Inner Field 2', + name: 'nestedField2', + required: true, + }, + ]); + }); + it('can pass custom field attributes', () => { const result = createHeadlessForm( { diff --git a/src/tests/helpers.js b/src/tests/helpers.js index e07d3ee7c..a1784a7c7 100644 --- a/src/tests/helpers.js +++ b/src/tests/helpers.js @@ -2327,3 +2327,59 @@ export const schemaWithCustomValidationsAndConditionals = { }, ], }; + +export const nestedGroupArrayForm = { + items: { + properties: { + notNested: { + description: 'Simple text field', + 'x-jsf-presentation': { + inputType: 'text', + }, + title: 'Outer Field', + type: 'string', + maxLength: 255, + }, + nested: { + items: { + properties: { + nestedField1: { + description: 'First nested text field', + 'x-jsf-presentation': { + inputType: 'text', + }, + title: 'Inner Field 1', + type: 'string', + maxLength: 255, + }, + nestedField2: { + description: 'Second nested text field', + 'x-jsf-presentation': { + inputType: 'text', + }, + title: 'Inner Field 2', + type: 'string', + maxLength: 255, + }, + }, + 'x-jsf-order': ['nestedField1', 'nestedField2'], + required: ['nestedField1', 'nestedField2'], + type: 'object', + }, + 'x-jsf-presentation': { + inputType: 'group-array', + }, + title: 'Nested group-array', + type: 'array', + }, + }, + 'x-jsf-order': ['notNested', 'nested'], + required: ['notNested', 'nested'], + type: 'object', + }, + 'x-jsf-presentation': { + inputType: 'group-array', + }, + title: 'Parent object', + type: 'array', +};