diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 97fb739f..cbbe226d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -104,7 +104,7 @@ Once you have a `dist` folder being created, you can either: + import { createHeadlessForm } from '../../path/to/repo/json-schema-form/dist' ``` -- Optpion B: Use [npm link](https://docs.npmjs.com/cli/v9/commands/npm-link) or [yarn link](https://classic.yarnpkg.com/lang/en/docs/cli/link/): +- Option B: Use [npm link](https://docs.npmjs.com/cli/v9/commands/npm-link) or [yarn link](https://classic.yarnpkg.com/lang/en/docs/cli/link/): ```bash # in json-schema-form repo: diff --git a/src/field/schema.ts b/src/field/schema.ts index c4e52f6f..efbff7a6 100644 --- a/src/field/schema.ts +++ b/src/field/schema.ts @@ -277,6 +277,7 @@ function getArrayFields(schema: NonBooleanJsfSchema, originalSchema: NonBooleanJ if (schema.items?.type === 'object') { const objectSchema = schema.items as JsfObjectSchema + const originalItemsSchema = originalSchema.items as JsfObjectSchema | undefined for (const key in objectSchema.properties) { const isFieldRequired = objectSchema.required?.includes(key) || false @@ -284,7 +285,9 @@ function getArrayFields(schema: NonBooleanJsfSchema, originalSchema: NonBooleanJ schema: objectSchema.properties[key], name: key, required: isFieldRequired, - originalSchema, + // Use the inner field's original schema, not the GROUP_ARRAY schema + // This prevents the GROUP_ARRAY's default from being copied to inner fields + originalSchema: originalItemsSchema?.properties?.[key] || objectSchema.properties[key], strictInputType, }) if (field) { @@ -298,7 +301,8 @@ function getArrayFields(schema: NonBooleanJsfSchema, originalSchema: NonBooleanJ schema: schema.items, name: 'item', required: false, - originalSchema, + // Use the items schema from originalSchema, not the GROUP_ARRAY schema itself + originalSchema: originalSchema.items || schema.items, strictInputType, }) if (field) { @@ -376,7 +380,7 @@ export function buildFieldSchema({ const inputType = getInputType(type, name, originalSchema, strictInputType) const inputHasInnerFields = ['fieldset', 'group-array'].includes(inputType) - return { + const hiddenField: Field = { type: inputType, name, inputType, @@ -385,6 +389,13 @@ export function buildFieldSchema({ isVisible: false, ...(inputHasInnerFields && { fields: [] }), } + + // Preserve default from originalSchema for hidden fields (important for GROUP_ARRAY fields) + if (originalSchema.default !== undefined) { + hiddenField.default = originalSchema.default + } + + return hiddenField } // If schema is any other boolean (true), just return null @@ -418,6 +429,12 @@ export function buildFieldSchema({ ...(errorMessage && { errorMessage }), } + // Preserve default from originalSchema if it's missing in the processed schema + // This is important for GROUP_ARRAY fields where defaults can be lost during conditional merging + if (field.default === undefined && originalSchema.default !== undefined) { + field.default = originalSchema.default + } + if (inputType === 'checkbox') { addCheckboxAttributes(inputType, field, schema) } diff --git a/test/fields/array.test.ts b/test/fields/array.test.ts index afedebee..0c6e9a55 100644 --- a/test/fields/array.test.ts +++ b/test/fields/array.test.ts @@ -217,8 +217,6 @@ describe('buildFieldArray', () => { isVisible: true, nameKey: 'title', required: false, - foo: 'bar', - bar: 'baz', }, ], items: expect.any(Object), @@ -1093,4 +1091,245 @@ describe('buildFieldArray', () => { // as both will be rendered from the same fields in the `fields` property. }) }) + + describe('default values', () => { + it('should preserve default values for GROUP_ARRAY fields', () => { + const schema: JsfSchema = { + type: 'array', + default: [ + { title: 'Book 1', pages: 100 }, + { title: 'Book 2', pages: 200 }, + ], + items: { + type: 'object', + properties: { + title: { type: 'string' }, + pages: { type: 'number' }, + }, + }, + } + + const field = buildFieldSchema(schema, 'books', false) + + expect(field?.default).toEqual([ + { title: 'Book 1', pages: 100 }, + { title: 'Book 2', pages: 200 }, + ]) + }) + + it('should preserve default values from originalSchema when processed schema loses it', () => { + const originalSchema: JsfSchema = { + type: 'array', + default: [{ title: 'Test Book', pages: 300 }], + items: { + type: 'object', + properties: { + title: { type: 'string' }, + pages: { type: 'number' }, + }, + }, + } + + // Simulate a processed schema where default was lost (e.g., during conditional merging) + const processedSchema: JsfSchema = { + type: 'array', + // default is missing here + items: { + type: 'object', + properties: { + title: { type: 'string' }, + pages: { type: 'number' }, + }, + }, + } + + const field = buildField({ + schema: processedSchema, + name: 'books', + required: false, + originalSchema, + strictInputType: false, + }) + + expect(field?.default).toEqual([{ title: 'Test Book', pages: 300 }]) + }) + + it('should use processed schema default if both exist', () => { + const originalSchema: JsfSchema = { + type: 'array', + default: [{ title: 'Original Book', pages: 150 }], + items: { + type: 'object', + properties: { + title: { type: 'string' }, + pages: { type: 'number' }, + }, + }, + } + + const processedSchema: JsfSchema = { + type: 'array', + default: [{ title: 'Updated Book', pages: 250 }], + items: { + type: 'object', + properties: { + title: { type: 'string' }, + pages: { type: 'number' }, + }, + }, + } + + const field = buildField({ + schema: processedSchema, + name: 'books', + required: false, + originalSchema, + strictInputType: false, + }) + + // Should use processed schema's default, not originalSchema's + expect(field?.default).toEqual([{ title: 'Updated Book', pages: 250 }]) + }) + + it('should preserve default values for GROUP_ARRAY fields that become visible conditionally', () => { + const schema: JsfObjectSchema = { + type: 'object', + properties: { + has_books: { + type: 'string', + default: 'no', + oneOf: [ + { const: 'yes', title: 'Yes' }, + { const: 'no', title: 'No' }, + ], + }, + books: { + type: 'array', + default: [ + { title: 'Book 1', pages: 100 }, + { title: 'Book 2', pages: 200 }, + ], + items: { + type: 'object', + properties: { + title: { type: 'string' }, + pages: { type: 'number' }, + }, + }, + }, + }, + allOf: [ + { + if: { + properties: { + has_books: { const: 'yes' }, + }, + }, + then: { + required: ['books'], + }, + else: { + properties: { + books: false, + }, + }, + }, + ], + } + + // Initially, field should be hidden but still have default + const formInitial = createHeadlessForm(schema, { initialValues: { has_books: 'no' } }) + const fieldInitial = formInitial.fields.find(f => f.name === 'books') + expect(fieldInitial?.isVisible).toBe(false) + // Default should be preserved even when hidden + expect(fieldInitial?.default).toEqual([ + { title: 'Book 1', pages: 100 }, + { title: 'Book 2', pages: 200 }, + ]) + + // When field becomes visible, default should still be available + formInitial.handleValidation({ has_books: 'yes' }) + const fieldVisible = formInitial.fields.find(f => f.name === 'books') + expect(fieldVisible?.isVisible).toBe(true) + expect(fieldVisible?.default).toEqual([ + { title: 'Book 1', pages: 100 }, + { title: 'Book 2', pages: 200 }, + ]) + }) + }) + + describe('inner field defaults', () => { + it('should NOT copy GROUP_ARRAY default to inner fields', () => { + const schema: JsfSchema = { + type: 'array', + default: [ + { title: 'Book 1', pages: 100 }, + { title: 'Book 2', pages: 200 }, + ], + items: { + type: 'object', + properties: { + title: { + type: 'string', + title: 'Title', + }, + pages: { + type: 'number', + }, + }, + }, + } + + const field = buildFieldSchema(schema, 'books', false) + + // Parent GROUP_ARRAY field should have the default array + expect(field?.default).toEqual([ + { title: 'Book 1', pages: 100 }, + { title: 'Book 2', pages: 200 }, + ]) + + // Inner fields should NOT have the parent's default array + const titleField = field?.fields?.find(f => f.name === 'title') + const pagesField = field?.fields?.find(f => f.name === 'pages') + + expect(titleField?.default).toBeUndefined() + expect(pagesField?.default).toBeUndefined() + }) + + it('should preserve inner field defaults when specified', () => { + const schema: JsfSchema = { + type: 'array', + default: [ + { title: 'Book 1', pages: 300 }, + ], + items: { + type: 'object', + properties: { + title: { + type: 'string', + default: 'Untitled', + }, + pages: { + type: 'number', + default: 0, + }, + }, + }, + } + + const field = buildFieldSchema(schema, 'books', false) + + // Parent GROUP_ARRAY field should have the default array + expect(field?.default).toEqual([ + { title: 'Book 1', pages: 300 }, + ]) + + // Inner fields should have their own defaults, not the parent's array + const titleField = field?.fields?.find(f => f.name === 'title') + const pagesField = field?.fields?.find(f => f.name === 'pages') + + expect(titleField?.default).toBe('Untitled') + expect(pagesField?.default).toBe(0) + }) + }) })