From f359660cd7d7125749d2fa8fcfe9ed69635eda7d Mon Sep 17 00:00:00 2001 From: Lonli-Lokli Date: Thu, 15 May 2025 13:57:18 +0100 Subject: [PATCH 1/5] feat: allow default value overrides form data --- .../docs/docs/api-reference/form-props.md | 3 + .../docs/api-reference/utility-functions.md | 3 +- packages/playground/src/components/Header.tsx | 5 + .../utils/src/mergeDefaultsWithFormData.ts | 38 +++--- .../utils/src/schema/getDefaultFormState.ts | 3 +- packages/utils/src/types.ts | 3 +- .../test/mergeDefaultsWithFormData.test.ts | 118 +++++++++++++++--- .../test/schema/getDefaultFormStateTest.ts | 59 +++++++++ 8 files changed, 201 insertions(+), 31 deletions(-) diff --git a/packages/docs/docs/api-reference/form-props.md b/packages/docs/docs/api-reference/form-props.md index 7b18d0dc21..cda56172b6 100644 --- a/packages/docs/docs/api-reference/form-props.md +++ b/packages/docs/docs/api-reference/form-props.md @@ -269,6 +269,9 @@ NOTE: If there is a default for a field and the `formData` is unspecified, the d | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `useFormDataIfPresent` | Legacy behavior - Do not merge defaults if there is a value for a field in `formData` even if that value is explicitly set to `undefined` | | `useDefaultIfFormDataUndefined` | If the value of a field within the `formData` is `undefined`, then use the default value instead | +| `useDefault` | Always use the default value instead of form data | + +| ## experimental_customMergeAllOf diff --git a/packages/docs/docs/api-reference/utility-functions.md b/packages/docs/docs/api-reference/utility-functions.md index db06a5b305..2021fbd4f0 100644 --- a/packages/docs/docs/api-reference/utility-functions.md +++ b/packages/docs/docs/api-reference/utility-functions.md @@ -661,7 +661,7 @@ Merges the `defaults` object of type `T` into the `formData` of type `T` When merging defaults and form data, we want to merge in this specific way: - objects are deeply merged -- arrays are merged in such a way that: +- arrays are either replaced (when `defaultSupercedes` is true) or merged in such a way that: - when the array is set in form data, only array entries set in form data are deeply merged; additional entries from the defaults are ignored unless `mergeExtraArrayDefaults` is true, in which case the extras are appended onto the end of the form data - when the array is not set in form data, the default is copied over - scalars are overwritten/set by form data @@ -672,6 +672,7 @@ When merging defaults and form data, we want to merge in this specific way: - [formData]: T | undefined - The form data into which the defaults will be merged - [mergeExtraArrayDefaults=false]: boolean - If true, any additional default array entries are appended onto the formData - [defaultSupercedesUndefined=false]: boolean - If true, an explicit undefined value will be overwritten by the default value +- [defaultSupercedes=false]: boolean - If true, a value will be overwritten by the default value #### Returns diff --git a/packages/playground/src/components/Header.tsx b/packages/playground/src/components/Header.tsx index 350b5393dc..669fdfab04 100644 --- a/packages/playground/src/components/Header.tsx +++ b/packages/playground/src/components/Header.tsx @@ -201,6 +201,11 @@ const liveSettingsSelectSchema: RJSFSchema = { title: 'Use default for undefined field value', enum: ['useDefaultIfFormDataUndefined'], }, + { + type: 'string', + title: 'Always use default for field value', + enum: ['useDefault'], + }, ], }, }, diff --git a/packages/utils/src/mergeDefaultsWithFormData.ts b/packages/utils/src/mergeDefaultsWithFormData.ts index 11664db5e7..3a8650ece2 100644 --- a/packages/utils/src/mergeDefaultsWithFormData.ts +++ b/packages/utils/src/mergeDefaultsWithFormData.ts @@ -20,7 +20,7 @@ import isNil from 'lodash/isNil'; * @param [formData] - The form data into which the defaults will be merged * @param [mergeExtraArrayDefaults=false] - If true, any additional default array entries are appended onto the formData * @param [defaultSupercedesUndefined=false] - If true, an explicit undefined value will be overwritten by the default value - * @param [overrideFormDataWithDefaults=false] - If true, the default value will overwrite the form data value. If the value + * @param [overrideFormDataWithDefaultsStrategy='noop'] - If not 'noop', the default value will overwrite the form data value. Values can be either replaced or merged, if the value * doesn't exist in the default, we take it from formData and in the case where the value is set to undefined in formData. * This is useful when we have already merged formData with defaults and want to add an additional field from formData * that does not exist in defaults. @@ -31,14 +31,14 @@ export default function mergeDefaultsWithFormData( formData?: T, mergeExtraArrayDefaults = false, defaultSupercedesUndefined = false, - overrideFormDataWithDefaults = false, + overrideFormDataWithDefaultsStrategy: 'noop' | 'replace' | 'merge' = 'noop', ): T | undefined { if (Array.isArray(formData)) { const defaultsArray = Array.isArray(defaults) ? defaults : []; - // If overrideFormDataWithDefaults is true, we want to override the formData with the defaults - const overrideArray = overrideFormDataWithDefaults ? defaultsArray : formData; - const overrideOppositeArray = overrideFormDataWithDefaults ? formData : defaultsArray; + // If overrideFormDataWithDefaultsStrategy is not noop, we want to override the formData with the defaults + const overrideArray = overrideFormDataWithDefaultsStrategy !== 'noop' ? defaultsArray : formData; + const overrideOppositeArray = overrideFormDataWithDefaultsStrategy !== 'noop' ? formData : defaultsArray; const mapped = overrideArray.map((value, idx) => { // We want to explicitly make sure that the value is NOT undefined since null, 0 and empty space are valid values @@ -48,33 +48,43 @@ export default function mergeDefaultsWithFormData( formData[idx], mergeExtraArrayDefaults, defaultSupercedesUndefined, - overrideFormDataWithDefaults, + overrideFormDataWithDefaultsStrategy, ); } return value; }); // Merge any extra defaults when mergeExtraArrayDefaults is true - // Or when overrideFormDataWithDefaults is true and the default array is shorter than the formData array - if ((mergeExtraArrayDefaults || overrideFormDataWithDefaults) && mapped.length < overrideOppositeArray.length) { + // Or when overrideFormDataWithDefaults is not noop and the default array is shorter than the formData array + if ( + (mergeExtraArrayDefaults || overrideFormDataWithDefaultsStrategy === 'merge') && + mapped.length < overrideOppositeArray.length + ) { mapped.push(...overrideOppositeArray.slice(mapped.length)); } return mapped as unknown as T; } if (isObject(formData)) { + const iterationSource = overrideFormDataWithDefaultsStrategy === 'replace' ? (defaults ?? {}) : formData; const acc: { [key in keyof T]: any } = Object.assign({}, defaults); // Prevent mutation of source object. - return Object.keys(formData as GenericObjectType).reduce((acc, key) => { + return Object.keys(iterationSource as GenericObjectType).reduce((acc, key) => { const keyValue = get(formData, key); const keyExistsInDefaults = isObject(defaults) && key in (defaults as GenericObjectType); const keyExistsInFormData = key in (formData as GenericObjectType); + // overrideFormDataWithDefaultsStrategy can be 'merge' only when the key value exists in defaults + // Or if the key value doesn't exist in formData + const keyOverrideDefaultStrategy = + overrideFormDataWithDefaultsStrategy === 'replace' + ? 'replace' + : keyExistsInDefaults || !keyExistsInFormData + ? overrideFormDataWithDefaultsStrategy + : 'noop'; acc[key as keyof T] = mergeDefaultsWithFormData( defaults ? get(defaults, key) : {}, keyValue, mergeExtraArrayDefaults, defaultSupercedesUndefined, - // overrideFormDataWithDefaults can be true only when the key value exists in defaults - // Or if the key value doesn't exist in formData - overrideFormDataWithDefaults && (keyExistsInDefaults || !keyExistsInFormData), + keyOverrideDefaultStrategy, ); return acc; }, acc); @@ -89,10 +99,10 @@ export default function mergeDefaultsWithFormData( if ( (defaultSupercedesUndefined && ((!isNil(defaults) && isNil(formData)) || (typeof formData === 'number' && isNaN(formData)))) || - (overrideFormDataWithDefaults && !isNil(formData)) + (overrideFormDataWithDefaultsStrategy === 'merge' && !isNil(formData)) ) { return defaults; } - return formData; + return overrideFormDataWithDefaultsStrategy === 'replace' ? defaults : formData; } diff --git a/packages/utils/src/schema/getDefaultFormState.ts b/packages/utils/src/schema/getDefaultFormState.ts index 079f11992a..fbd4d44548 100644 --- a/packages/utils/src/schema/getDefaultFormState.ts +++ b/packages/utils/src/schema/getDefaultFormState.ts @@ -363,6 +363,7 @@ export function computeDefaults { expect(mergeDefaultsWithFormData(obj1, obj2)?.a).toBeInstanceOf(File); }); - describe('test with overrideFormDataWithDefaults set to true', () => { + describe('test with overrideFormDataWithDefaults set to `merge`', () => { it('should return data in formData when no defaults', () => { - expect(mergeDefaultsWithFormData(undefined, [2], undefined, undefined, true)).toEqual([2]); + expect(mergeDefaultsWithFormData(undefined, [2], undefined, undefined, 'merge')).toEqual([2]); }); it('should return formData when formData is undefined', () => { - expect(mergeDefaultsWithFormData({}, undefined, undefined, undefined, true)).toEqual(undefined); + expect(mergeDefaultsWithFormData({}, undefined, undefined, undefined, 'merge')).toEqual(undefined); }); it('should deeply merge and return formData when formData is undefined and defaultSupercedesUndefined false', () => { @@ -170,7 +170,7 @@ describe('mergeDefaultsWithFormData()', () => { }, undefined, undefined, - true, + 'merge', ), ).toEqual({ arrayWithDefaults: [null], @@ -183,42 +183,42 @@ describe('mergeDefaultsWithFormData()', () => { }); it('should return default when formData is undefined and defaultSupercedesUndefined true', () => { - expect(mergeDefaultsWithFormData({}, undefined, undefined, true, true)).toEqual({}); + expect(mergeDefaultsWithFormData({}, undefined, undefined, true, 'merge')).toEqual({}); }); it('should return default when formData is null and defaultSupercedesUndefined true', () => { - expect(mergeDefaultsWithFormData({}, null, undefined, true, true)).toEqual({}); + expect(mergeDefaultsWithFormData({}, null, undefined, true, 'merge')).toEqual({}); }); it('should merge two one-level deep objects', () => { - expect(mergeDefaultsWithFormData({ a: 1 }, { b: 2 }, undefined, undefined, true)).toEqual({ + expect(mergeDefaultsWithFormData({ a: 1 }, { b: 2 }, undefined, undefined, 'merge')).toEqual({ a: 1, b: 2, }); }); it('should override the first object with the values from the second', () => { - expect(mergeDefaultsWithFormData({ a: 1 }, { a: 2 }, undefined, undefined, true)).toEqual({ a: 1 }); + expect(mergeDefaultsWithFormData({ a: 1 }, { a: 2 }, undefined, undefined, 'merge')).toEqual({ a: 1 }); }); it('should override non-existing values of the first object with the values from the second', () => { expect( - mergeDefaultsWithFormData({ a: { b: undefined } }, { a: { b: { c: 1 } } }, undefined, undefined, true), + mergeDefaultsWithFormData({ a: { b: undefined } }, { a: { b: { c: 1 } } }, undefined, undefined, 'merge'), ).toEqual({ a: { b: { c: 1 } }, }); }); it('should merge arrays using entries from second', () => { - expect(mergeDefaultsWithFormData([1, 2, 3], [4, 5], undefined, undefined, true)).toEqual([1, 2, 3]); + expect(mergeDefaultsWithFormData([1, 2, 3], [4, 5], undefined, undefined, 'merge')).toEqual([1, 2, 3]); }); it('should merge arrays using entries from second and extra from the first', () => { - expect(mergeDefaultsWithFormData([1, 2], [4, 5, 6], undefined, undefined, true)).toEqual([1, 2, 6]); + expect(mergeDefaultsWithFormData([1, 2], [4, 5, 6], undefined, undefined, 'merge')).toEqual([1, 2, 6]); }); it('should deeply merge arrays with overlapping entries', () => { - expect(mergeDefaultsWithFormData([{ a: 1 }], [{ b: 2 }, { c: 3 }], undefined, undefined, true)).toEqual([ + expect(mergeDefaultsWithFormData([{ a: 1 }], [{ b: 2 }, { c: 3 }], undefined, undefined, 'merge')).toEqual([ { a: 1, b: 2 }, { c: 3 }, ]); @@ -256,7 +256,7 @@ describe('mergeDefaultsWithFormData()', () => { }, c: 2, }; - expect(mergeDefaultsWithFormData(obj1, obj2, undefined, undefined, true)).toEqual(expected); + expect(mergeDefaultsWithFormData(obj1, obj2, undefined, undefined, 'merge')).toEqual(expected); }); it('should recursively merge deeply nested objects, including extra array data', () => { @@ -293,7 +293,7 @@ describe('mergeDefaultsWithFormData()', () => { c: 2, d: 4, }; - expect(mergeDefaultsWithFormData(obj1, obj2, undefined, undefined, true)).toEqual(expected); + expect(mergeDefaultsWithFormData(obj1, obj2, undefined, undefined, 'merge')).toEqual(expected); }); it('should recursively merge File objects', () => { @@ -307,4 +307,94 @@ describe('mergeDefaultsWithFormData()', () => { expect(mergeDefaultsWithFormData(obj1, obj2)?.a).toBeInstanceOf(File); }); }); + + describe('test with overrideFormDataWithDefaults set to `replace`', () => { + it('should return empty array even when no defaults', () => { + expect(mergeDefaultsWithFormData(undefined, [2], undefined, undefined, 'replace')).toEqual([]); + }); + + it('should return default when formData is undefined', () => { + expect(mergeDefaultsWithFormData({}, undefined, undefined, undefined, 'replace')).toEqual({}); + }); + + it('should return default when formData is undefined and defaultSupercedesUndefined true', () => { + expect(mergeDefaultsWithFormData({}, undefined, undefined, true, 'replace')).toEqual({}); + }); + + it('should return default when formData is null and defaultSupercedesUndefined true', () => { + expect(mergeDefaultsWithFormData({}, null, undefined, true, 'replace')).toEqual({}); + }); + + it('should not merge two one-level deep objects', () => { + expect(mergeDefaultsWithFormData({ a: 1 }, { b: 2 }, undefined, undefined, 'replace')).toEqual({ + a: 1, + }); + }); + + it('should not override the first object with the values from the second', () => { + expect(mergeDefaultsWithFormData({ a: 1 }, { a: 2 }, undefined, undefined, 'replace')).toEqual({ a: 1 }); + }); + + it('should not return undefined from defaults', () => { + expect( + mergeDefaultsWithFormData({ a: { b: undefined } }, { a: { b: { c: 1 } } }, undefined, undefined, 'replace'), + ).toEqual({ + a: { b: {} }, + }); + }); + + it('should not merge arrays using entries from second', () => { + expect(mergeDefaultsWithFormData([1, 2, 3], [4, 5], undefined, undefined, 'replace')).toEqual([1, 2, 3]); + }); + + it('should not deeply merge arrays with overlapping entries', () => { + expect(mergeDefaultsWithFormData([{ a: 1 }], [{ b: 2 }, { c: 3 }], undefined, undefined, 'replace')).toEqual([ + { a: 1 }, + ]); + }); + + it('should replace objects', () => { + const obj1 = { + a: 1, + b: { + c: 3, + d: [1, 2, 3], + e: { f: { g: 1 } }, + h: [{ i: 1 }, { i: 2 }], + }, + c: 2, + }; + const obj2 = { + a: 1, + b: { + d: [3], + e: { f: { h: 2 } }, + g: 1, + h: [{ i: 3 }, { i: 4 }, { i: 5 }], + }, + c: 3, + d: 4, + }; + expect(mergeDefaultsWithFormData(obj1, obj2, undefined, undefined, 'replace')).toEqual({ + a: 1, + b: { + c: 3, + d: [1, 2, 3], + e: { f: { g: 1 } }, + h: [{ i: 1 }, { i: 2 }], + }, + c: 2, + }); + }); + + it('should replace arrays', () => { + expect(mergeDefaultsWithFormData([1, 2], [4, 5, 6], undefined, undefined, 'replace')).toEqual([1, 2]); + }); + + it('should replace objects', () => { + expect(mergeDefaultsWithFormData({ a: { b: 1 } }, { a: { b: 2 } }, undefined, undefined, 'replace')).toEqual({ + a: { b: 1 }, + }); + }); + }); }); diff --git a/packages/utils/test/schema/getDefaultFormStateTest.ts b/packages/utils/test/schema/getDefaultFormStateTest.ts index 1c5b4c5694..b05ecf2c19 100644 --- a/packages/utils/test/schema/getDefaultFormStateTest.ts +++ b/packages/utils/test/schema/getDefaultFormStateTest.ts @@ -1340,6 +1340,65 @@ export default function getDefaultFormStateTest(testValidator: TestValidatorType }); }); }); + + describe('mergeDefaultsIntoFormData set to "useDefault"', () => { + const experimental_defaultFormStateBehavior: Experimental_DefaultFormStateBehavior = { + mergeDefaultsIntoFormData: 'useDefault', + }; + + test('getDefaultFormState', () => { + expect( + getDefaultFormState( + testValidator, + schema, + rawFormData, + schema, + undefined, + experimental_defaultFormStateBehavior, + ), + ).toEqual({ + animal: 'Fish', + food: 'worms', + water: null, + }); + }); + + test('computeDefaults', () => { + expect( + computeDefaults(testValidator, schema, { + rootSchema: schema, + rawFormData, + experimental_defaultFormStateBehavior, + shouldMergeDefaultsIntoFormData, + }), + ).toEqual({ + food: 'worms', + water: 'sea', + }); + }); + + test('getDefaultBasedOnSchemaType', () => { + expect( + getDefaultBasedOnSchemaType(testValidator, schema, { + rootSchema: schema, + rawFormData, + shouldMergeDefaultsIntoFormData, + experimental_defaultFormStateBehavior, + }), + ).toEqual({}); + }); + + test('getObjectDefaults', () => { + expect( + getObjectDefaults(testValidator, schema, { + rootSchema: schema, + rawFormData, + shouldMergeDefaultsIntoFormData, + experimental_defaultFormStateBehavior, + }), + ).toEqual({}); + }); + }); }); describe('oneOf with const values', () => { From 43371663a11bc5c2ae5d7134d893cfa8e1e222e0 Mon Sep 17 00:00:00 2001 From: Lonli-Lokli Date: Thu, 15 May 2025 14:56:58 +0100 Subject: [PATCH 2/5] feat: allow default value overrides form data --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f095326f6..4cc309ec79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ should change the heading of the (upcoming) version to include a major version b --> +## @rjsf/util + +- Allow form value overrides with defaults [#4625](https://github.com/rjsf-team/react-jsonschema-form/pull/4625 + # 6.0.0-beta.8 ## @rjsf/util From 3591e801e17a30261f19cfce316c683a1e0caf92 Mon Sep 17 00:00:00 2001 From: Lonli-Lokli Date: Mon, 19 May 2025 12:31:29 +0100 Subject: [PATCH 3/5] feat: allow default value overrides form data --- CHANGELOG.md | 2 ++ .../docs/docs/api-reference/form-props.md | 2 +- packages/playground/src/components/Header.tsx | 2 +- .../utils/src/mergeDefaultsWithFormData.ts | 32 +++++++++---------- .../utils/src/schema/getDefaultFormState.ts | 7 ++-- packages/utils/src/types.ts | 15 +++++++-- .../test/schema/getDefaultFormStateTest.ts | 4 +-- 7 files changed, 40 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cc309ec79..dcba27cc84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ should change the heading of the (upcoming) version to include a major version b --> +# 6.0.0-beta.9 + ## @rjsf/util - Allow form value overrides with defaults [#4625](https://github.com/rjsf-team/react-jsonschema-form/pull/4625 diff --git a/packages/docs/docs/api-reference/form-props.md b/packages/docs/docs/api-reference/form-props.md index cda56172b6..bb3733a884 100644 --- a/packages/docs/docs/api-reference/form-props.md +++ b/packages/docs/docs/api-reference/form-props.md @@ -269,7 +269,7 @@ NOTE: If there is a default for a field and the `formData` is unspecified, the d | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `useFormDataIfPresent` | Legacy behavior - Do not merge defaults if there is a value for a field in `formData` even if that value is explicitly set to `undefined` | | `useDefaultIfFormDataUndefined` | If the value of a field within the `formData` is `undefined`, then use the default value instead | -| `useDefault` | Always use the default value instead of form data | +| `useDefaultAlways` | Always use the default value instead of form data | | diff --git a/packages/playground/src/components/Header.tsx b/packages/playground/src/components/Header.tsx index 669fdfab04..205be03af7 100644 --- a/packages/playground/src/components/Header.tsx +++ b/packages/playground/src/components/Header.tsx @@ -204,7 +204,7 @@ const liveSettingsSelectSchema: RJSFSchema = { { type: 'string', title: 'Always use default for field value', - enum: ['useDefault'], + enum: ['useDefaultAlways'], }, ], }, diff --git a/packages/utils/src/mergeDefaultsWithFormData.ts b/packages/utils/src/mergeDefaultsWithFormData.ts index 3a8650ece2..f2e437dd08 100644 --- a/packages/utils/src/mergeDefaultsWithFormData.ts +++ b/packages/utils/src/mergeDefaultsWithFormData.ts @@ -1,7 +1,7 @@ import get from 'lodash/get'; import isObject from './isObject'; -import { GenericObjectType } from '../src'; +import { GenericObjectType, OverrideFormDataStrategy } from '../src'; import isNil from 'lodash/isNil'; /** Merges the `defaults` object of type `T` into the `formData` of type `T` @@ -20,10 +20,7 @@ import isNil from 'lodash/isNil'; * @param [formData] - The form data into which the defaults will be merged * @param [mergeExtraArrayDefaults=false] - If true, any additional default array entries are appended onto the formData * @param [defaultSupercedesUndefined=false] - If true, an explicit undefined value will be overwritten by the default value - * @param [overrideFormDataWithDefaultsStrategy='noop'] - If not 'noop', the default value will overwrite the form data value. Values can be either replaced or merged, if the value - * doesn't exist in the default, we take it from formData and in the case where the value is set to undefined in formData. - * This is useful when we have already merged formData with defaults and want to add an additional field from formData - * that does not exist in defaults. + * @param [overrideFormDataWithDefaultsStrategy=OverrideFormDataStrategy.noop] - Strategy for merging defaults and form data * @returns - The resulting merged form data with defaults */ export default function mergeDefaultsWithFormData( @@ -31,14 +28,16 @@ export default function mergeDefaultsWithFormData( formData?: T, mergeExtraArrayDefaults = false, defaultSupercedesUndefined = false, - overrideFormDataWithDefaultsStrategy: 'noop' | 'replace' | 'merge' = 'noop', + overrideFormDataWithDefaultsStrategy: OverrideFormDataStrategy = OverrideFormDataStrategy.noop, ): T | undefined { if (Array.isArray(formData)) { const defaultsArray = Array.isArray(defaults) ? defaults : []; // If overrideFormDataWithDefaultsStrategy is not noop, we want to override the formData with the defaults - const overrideArray = overrideFormDataWithDefaultsStrategy !== 'noop' ? defaultsArray : formData; - const overrideOppositeArray = overrideFormDataWithDefaultsStrategy !== 'noop' ? formData : defaultsArray; + const overrideArray = + overrideFormDataWithDefaultsStrategy !== OverrideFormDataStrategy.noop ? defaultsArray : formData; + const overrideOppositeArray = + overrideFormDataWithDefaultsStrategy !== OverrideFormDataStrategy.noop ? formData : defaultsArray; const mapped = overrideArray.map((value, idx) => { // We want to explicitly make sure that the value is NOT undefined since null, 0 and empty space are valid values @@ -55,9 +54,9 @@ export default function mergeDefaultsWithFormData( }); // Merge any extra defaults when mergeExtraArrayDefaults is true - // Or when overrideFormDataWithDefaults is not noop and the default array is shorter than the formData array + // Or when overrideFormDataWithDefaults is 'merge' and the default array is shorter than the formData array if ( - (mergeExtraArrayDefaults || overrideFormDataWithDefaultsStrategy === 'merge') && + (mergeExtraArrayDefaults || overrideFormDataWithDefaultsStrategy === OverrideFormDataStrategy.merge) && mapped.length < overrideOppositeArray.length ) { mapped.push(...overrideOppositeArray.slice(mapped.length)); @@ -65,7 +64,8 @@ export default function mergeDefaultsWithFormData( return mapped as unknown as T; } if (isObject(formData)) { - const iterationSource = overrideFormDataWithDefaultsStrategy === 'replace' ? (defaults ?? {}) : formData; + const iterationSource = + overrideFormDataWithDefaultsStrategy === OverrideFormDataStrategy.replace ? (defaults ?? {}) : formData; const acc: { [key in keyof T]: any } = Object.assign({}, defaults); // Prevent mutation of source object. return Object.keys(iterationSource as GenericObjectType).reduce((acc, key) => { const keyValue = get(formData, key); @@ -74,11 +74,11 @@ export default function mergeDefaultsWithFormData( // overrideFormDataWithDefaultsStrategy can be 'merge' only when the key value exists in defaults // Or if the key value doesn't exist in formData const keyOverrideDefaultStrategy = - overrideFormDataWithDefaultsStrategy === 'replace' - ? 'replace' + overrideFormDataWithDefaultsStrategy === OverrideFormDataStrategy.replace + ? OverrideFormDataStrategy.replace : keyExistsInDefaults || !keyExistsInFormData ? overrideFormDataWithDefaultsStrategy - : 'noop'; + : OverrideFormDataStrategy.noop; acc[key as keyof T] = mergeDefaultsWithFormData( defaults ? get(defaults, key) : {}, keyValue, @@ -99,10 +99,10 @@ export default function mergeDefaultsWithFormData( if ( (defaultSupercedesUndefined && ((!isNil(defaults) && isNil(formData)) || (typeof formData === 'number' && isNaN(formData)))) || - (overrideFormDataWithDefaultsStrategy === 'merge' && !isNil(formData)) + (overrideFormDataWithDefaultsStrategy === OverrideFormDataStrategy.merge && !isNil(formData)) ) { return defaults; } - return overrideFormDataWithDefaultsStrategy === 'replace' ? defaults : formData; + return overrideFormDataWithDefaultsStrategy === OverrideFormDataStrategy.replace ? defaults : formData; } diff --git a/packages/utils/src/schema/getDefaultFormState.ts b/packages/utils/src/schema/getDefaultFormState.ts index fbd4d44548..88f8b6695c 100644 --- a/packages/utils/src/schema/getDefaultFormState.ts +++ b/packages/utils/src/schema/getDefaultFormState.ts @@ -26,6 +26,7 @@ import { Experimental_DefaultFormStateBehavior, FormContextType, GenericObjectType, + OverrideFormDataStrategy, RJSFSchema, StrictRJSFSchema, ValidatorType, @@ -363,7 +364,9 @@ export function computeDefaults; } + +/** Strategy for merging defaults with existing form data */ +export enum OverrideFormDataStrategy { + /** No merge or override applied */ + noop, + /** If the value doesn't exist in the default, we take it from formData and in the case where the value is set to undefined in formData. + * This is useful when we have already merged formData with defaults and want to add an additional field from formData that does not exist in defaults */ + merge, + /** Replace form data with defined default */ + replace, +} diff --git a/packages/utils/test/schema/getDefaultFormStateTest.ts b/packages/utils/test/schema/getDefaultFormStateTest.ts index b05ecf2c19..e18af6dcf6 100644 --- a/packages/utils/test/schema/getDefaultFormStateTest.ts +++ b/packages/utils/test/schema/getDefaultFormStateTest.ts @@ -1341,9 +1341,9 @@ export default function getDefaultFormStateTest(testValidator: TestValidatorType }); }); - describe('mergeDefaultsIntoFormData set to "useDefault"', () => { + describe('mergeDefaultsIntoFormData set to "useDefaultAlways"', () => { const experimental_defaultFormStateBehavior: Experimental_DefaultFormStateBehavior = { - mergeDefaultsIntoFormData: 'useDefault', + mergeDefaultsIntoFormData: 'useDefaultAlways', }; test('getDefaultFormState', () => { From f93b507961885a810bdaae19f181b557d05e87de Mon Sep 17 00:00:00 2001 From: Lonli-Lokli Date: Wed, 21 May 2025 16:27:08 +0100 Subject: [PATCH 4/5] build fixed --- .../test/mergeDefaultsWithFormData.test.ts | 123 +++++++++++++----- 1 file changed, 92 insertions(+), 31 deletions(-) diff --git a/packages/utils/test/mergeDefaultsWithFormData.test.ts b/packages/utils/test/mergeDefaultsWithFormData.test.ts index 66fd03d9c1..7a2d5a5076 100644 --- a/packages/utils/test/mergeDefaultsWithFormData.test.ts +++ b/packages/utils/test/mergeDefaultsWithFormData.test.ts @@ -1,4 +1,4 @@ -import { mergeDefaultsWithFormData } from '../src'; +import { mergeDefaultsWithFormData, OverrideFormDataStrategy } from '../src'; describe('mergeDefaultsWithFormData()', () => { it('shouldn`t mutate the provided objects', () => { @@ -143,11 +143,15 @@ describe('mergeDefaultsWithFormData()', () => { describe('test with overrideFormDataWithDefaults set to `merge`', () => { it('should return data in formData when no defaults', () => { - expect(mergeDefaultsWithFormData(undefined, [2], undefined, undefined, 'merge')).toEqual([2]); + expect(mergeDefaultsWithFormData(undefined, [2], undefined, undefined, OverrideFormDataStrategy.merge)).toEqual([ + 2, + ]); }); it('should return formData when formData is undefined', () => { - expect(mergeDefaultsWithFormData({}, undefined, undefined, undefined, 'merge')).toEqual(undefined); + expect(mergeDefaultsWithFormData({}, undefined, undefined, undefined, OverrideFormDataStrategy.merge)).toEqual( + undefined, + ); }); it('should deeply merge and return formData when formData is undefined and defaultSupercedesUndefined false', () => { @@ -170,7 +174,7 @@ describe('mergeDefaultsWithFormData()', () => { }, undefined, undefined, - 'merge', + OverrideFormDataStrategy.merge, ), ).toEqual({ arrayWithDefaults: [null], @@ -183,45 +187,64 @@ describe('mergeDefaultsWithFormData()', () => { }); it('should return default when formData is undefined and defaultSupercedesUndefined true', () => { - expect(mergeDefaultsWithFormData({}, undefined, undefined, true, 'merge')).toEqual({}); + expect(mergeDefaultsWithFormData({}, undefined, undefined, true, OverrideFormDataStrategy.merge)).toEqual({}); }); it('should return default when formData is null and defaultSupercedesUndefined true', () => { - expect(mergeDefaultsWithFormData({}, null, undefined, true, 'merge')).toEqual({}); + expect(mergeDefaultsWithFormData({}, null, undefined, true, OverrideFormDataStrategy.merge)).toEqual({}); }); it('should merge two one-level deep objects', () => { - expect(mergeDefaultsWithFormData({ a: 1 }, { b: 2 }, undefined, undefined, 'merge')).toEqual({ + expect( + mergeDefaultsWithFormData({ a: 1 }, { b: 2 }, undefined, undefined, OverrideFormDataStrategy.merge), + ).toEqual({ a: 1, b: 2, }); }); it('should override the first object with the values from the second', () => { - expect(mergeDefaultsWithFormData({ a: 1 }, { a: 2 }, undefined, undefined, 'merge')).toEqual({ a: 1 }); + expect( + mergeDefaultsWithFormData({ a: 1 }, { a: 2 }, undefined, undefined, OverrideFormDataStrategy.merge), + ).toEqual({ a: 1 }); }); it('should override non-existing values of the first object with the values from the second', () => { expect( - mergeDefaultsWithFormData({ a: { b: undefined } }, { a: { b: { c: 1 } } }, undefined, undefined, 'merge'), + mergeDefaultsWithFormData( + { a: { b: undefined } }, + { a: { b: { c: 1 } } }, + undefined, + undefined, + OverrideFormDataStrategy.merge, + ), ).toEqual({ a: { b: { c: 1 } }, }); }); it('should merge arrays using entries from second', () => { - expect(mergeDefaultsWithFormData([1, 2, 3], [4, 5], undefined, undefined, 'merge')).toEqual([1, 2, 3]); + expect( + mergeDefaultsWithFormData([1, 2, 3], [4, 5], undefined, undefined, OverrideFormDataStrategy.merge), + ).toEqual([1, 2, 3]); }); it('should merge arrays using entries from second and extra from the first', () => { - expect(mergeDefaultsWithFormData([1, 2], [4, 5, 6], undefined, undefined, 'merge')).toEqual([1, 2, 6]); + expect( + mergeDefaultsWithFormData([1, 2], [4, 5, 6], undefined, undefined, OverrideFormDataStrategy.merge), + ).toEqual([1, 2, 6]); }); it('should deeply merge arrays with overlapping entries', () => { - expect(mergeDefaultsWithFormData([{ a: 1 }], [{ b: 2 }, { c: 3 }], undefined, undefined, 'merge')).toEqual([ - { a: 1, b: 2 }, - { c: 3 }, - ]); + expect( + mergeDefaultsWithFormData( + [{ a: 1 }], + [{ b: 2 }, { c: 3 }], + undefined, + undefined, + OverrideFormDataStrategy.merge, + ), + ).toEqual([{ a: 1, b: 2 }, { c: 3 }]); }); it('should recursively merge deeply nested objects', () => { @@ -256,7 +279,9 @@ describe('mergeDefaultsWithFormData()', () => { }, c: 2, }; - expect(mergeDefaultsWithFormData(obj1, obj2, undefined, undefined, 'merge')).toEqual(expected); + expect(mergeDefaultsWithFormData(obj1, obj2, undefined, undefined, OverrideFormDataStrategy.merge)).toEqual( + expected, + ); }); it('should recursively merge deeply nested objects, including extra array data', () => { @@ -293,7 +318,9 @@ describe('mergeDefaultsWithFormData()', () => { c: 2, d: 4, }; - expect(mergeDefaultsWithFormData(obj1, obj2, undefined, undefined, 'merge')).toEqual(expected); + expect(mergeDefaultsWithFormData(obj1, obj2, undefined, undefined, OverrideFormDataStrategy.merge)).toEqual( + expected, + ); }); it('should recursively merge File objects', () => { @@ -310,47 +337,69 @@ describe('mergeDefaultsWithFormData()', () => { describe('test with overrideFormDataWithDefaults set to `replace`', () => { it('should return empty array even when no defaults', () => { - expect(mergeDefaultsWithFormData(undefined, [2], undefined, undefined, 'replace')).toEqual([]); + expect(mergeDefaultsWithFormData(undefined, [2], undefined, undefined, OverrideFormDataStrategy.replace)).toEqual( + [], + ); }); it('should return default when formData is undefined', () => { - expect(mergeDefaultsWithFormData({}, undefined, undefined, undefined, 'replace')).toEqual({}); + expect(mergeDefaultsWithFormData({}, undefined, undefined, undefined, OverrideFormDataStrategy.replace)).toEqual( + {}, + ); }); it('should return default when formData is undefined and defaultSupercedesUndefined true', () => { - expect(mergeDefaultsWithFormData({}, undefined, undefined, true, 'replace')).toEqual({}); + expect(mergeDefaultsWithFormData({}, undefined, undefined, true, OverrideFormDataStrategy.replace)).toEqual({}); }); it('should return default when formData is null and defaultSupercedesUndefined true', () => { - expect(mergeDefaultsWithFormData({}, null, undefined, true, 'replace')).toEqual({}); + expect(mergeDefaultsWithFormData({}, null, undefined, true, OverrideFormDataStrategy.replace)).toEqual({}); }); it('should not merge two one-level deep objects', () => { - expect(mergeDefaultsWithFormData({ a: 1 }, { b: 2 }, undefined, undefined, 'replace')).toEqual({ + expect( + mergeDefaultsWithFormData({ a: 1 }, { b: 2 }, undefined, undefined, OverrideFormDataStrategy.replace), + ).toEqual({ a: 1, }); }); it('should not override the first object with the values from the second', () => { - expect(mergeDefaultsWithFormData({ a: 1 }, { a: 2 }, undefined, undefined, 'replace')).toEqual({ a: 1 }); + expect( + mergeDefaultsWithFormData({ a: 1 }, { a: 2 }, undefined, undefined, OverrideFormDataStrategy.replace), + ).toEqual({ a: 1 }); }); it('should not return undefined from defaults', () => { expect( - mergeDefaultsWithFormData({ a: { b: undefined } }, { a: { b: { c: 1 } } }, undefined, undefined, 'replace'), + mergeDefaultsWithFormData( + { a: { b: undefined } }, + { a: { b: { c: 1 } } }, + undefined, + undefined, + OverrideFormDataStrategy.replace, + ), ).toEqual({ a: { b: {} }, }); }); it('should not merge arrays using entries from second', () => { - expect(mergeDefaultsWithFormData([1, 2, 3], [4, 5], undefined, undefined, 'replace')).toEqual([1, 2, 3]); + expect( + mergeDefaultsWithFormData([1, 2, 3], [4, 5], undefined, undefined, OverrideFormDataStrategy.replace), + ).toEqual([1, 2, 3]); }); it('should not deeply merge arrays with overlapping entries', () => { - expect(mergeDefaultsWithFormData([{ a: 1 }], [{ b: 2 }, { c: 3 }], undefined, undefined, 'replace')).toEqual([ - { a: 1 }, - ]); + expect( + mergeDefaultsWithFormData( + [{ a: 1 }], + [{ b: 2 }, { c: 3 }], + undefined, + undefined, + OverrideFormDataStrategy.replace, + ), + ).toEqual([{ a: 1 }]); }); it('should replace objects', () => { @@ -375,7 +424,9 @@ describe('mergeDefaultsWithFormData()', () => { c: 3, d: 4, }; - expect(mergeDefaultsWithFormData(obj1, obj2, undefined, undefined, 'replace')).toEqual({ + expect( + mergeDefaultsWithFormData(obj1, obj2, undefined, undefined, OverrideFormDataStrategy.replace), + ).toEqual({ a: 1, b: { c: 3, @@ -388,11 +439,21 @@ describe('mergeDefaultsWithFormData()', () => { }); it('should replace arrays', () => { - expect(mergeDefaultsWithFormData([1, 2], [4, 5, 6], undefined, undefined, 'replace')).toEqual([1, 2]); + expect( + mergeDefaultsWithFormData([1, 2], [4, 5, 6], undefined, undefined, OverrideFormDataStrategy.replace), + ).toEqual([1, 2]); }); it('should replace objects', () => { - expect(mergeDefaultsWithFormData({ a: { b: 1 } }, { a: { b: 2 } }, undefined, undefined, 'replace')).toEqual({ + expect( + mergeDefaultsWithFormData( + { a: { b: 1 } }, + { a: { b: 2 } }, + undefined, + undefined, + OverrideFormDataStrategy.replace, + ), + ).toEqual({ a: { b: 1 }, }); }); From a2971e053397e8e3df70262afaa8a1d46ce43ebd Mon Sep 17 00:00:00 2001 From: Lonli-Lokli Date: Tue, 3 Jun 2025 22:25:06 +0100 Subject: [PATCH 5/5] updated code & tests --- CHANGELOG.md | 6 ++- .../utils/src/mergeDefaultsWithFormData.ts | 14 ++++--- .../test/mergeDefaultsWithFormData.test.ts | 42 +++++++++++++++---- .../test/schema/getDefaultFormStateTest.ts | 32 ++++++++++++-- 4 files changed, 76 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ce1b4c084..195c0bf6cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ it according to semantic versioning. For example, if your PR adds a breaking cha should change the heading of the (upcoming) version to include a major version bump. --> +# 6.0.0-beta.12 + +## @rjsf/utils + +- Allow form value overrides with defaults [#4625](https://github.com/rjsf-team/react-jsonschema-form/pull/4625 # 6.0.0-beta.11 @@ -80,7 +85,6 @@ should change the heading of the (upcoming) version to include a major version b - Fixed form data propagation with `patternProperties` [#4617](https://github.com/rjsf-team/react-jsonschema-form/pull/4617) - Updated the `GlobalUISchemaOptions` types to extend `GenericObjectType` to support user-defined values for their extensions -- Allow form value overrides with defaults [#4625](https://github.com/rjsf-team/react-jsonschema-form/pull/4625 ## Dev / docs / playground diff --git a/packages/utils/src/mergeDefaultsWithFormData.ts b/packages/utils/src/mergeDefaultsWithFormData.ts index 14a30cb591..f60a851a9d 100644 --- a/packages/utils/src/mergeDefaultsWithFormData.ts +++ b/packages/utils/src/mergeDefaultsWithFormData.ts @@ -77,11 +77,13 @@ export default function mergeDefaultsWithFormData( const keyDefaultIsObject = keyExistsInDefaults && isObject(get(defaults, key)); const keyHasFormDataObject = keyExistsInFormData && isObject(keyValue); - if (keyDefaultIsObject && keyHasFormDataObject && !defaultValueIsNestedObject) { - acc[key as keyof T] = { - ...get(defaults, key), - ...keyValue, - }; + if ( + keyDefaultIsObject && + keyHasFormDataObject && + !defaultValueIsNestedObject && + overrideFormDataWithDefaultsStrategy !== OverrideFormDataStrategy.replace + ) { + acc[key as keyof T] = { ...keyDefault, ...keyValue }; return acc; } @@ -95,7 +97,7 @@ export default function mergeDefaultsWithFormData( : OverrideFormDataStrategy.noop; acc[key as keyof T] = mergeDefaultsWithFormData( - get(defaults, key) ?? {}, + keyDefault, keyValue, mergeExtraArrayDefaults, defaultSupercedesUndefined, diff --git a/packages/utils/test/mergeDefaultsWithFormData.test.ts b/packages/utils/test/mergeDefaultsWithFormData.test.ts index 7a2d5a5076..9cc3694169 100644 --- a/packages/utils/test/mergeDefaultsWithFormData.test.ts +++ b/packages/utils/test/mergeDefaultsWithFormData.test.ts @@ -335,6 +335,28 @@ describe('mergeDefaultsWithFormData()', () => { }); }); + describe('test with overrideFormDataWithDefaults set to `noop`', () => { + it('should return form data when no defaults', () => { + expect(mergeDefaultsWithFormData(undefined, [2], undefined, undefined, OverrideFormDataStrategy.noop)).toEqual([ + 2, + ]); + }); + + it('should return default when formData is undefined and defaultSupercedesUndefined true', () => { + expect(mergeDefaultsWithFormData({}, undefined, undefined, true, OverrideFormDataStrategy.noop)).toEqual({}); + }); + + it('should return default when formData is null and defaultSupercedesUndefined true', () => { + expect(mergeDefaultsWithFormData({}, null, undefined, true, OverrideFormDataStrategy.noop)).toEqual({}); + }); + + it('should use values from second object', () => { + expect( + mergeDefaultsWithFormData({ a: 1 }, { a: 2 }, undefined, undefined, OverrideFormDataStrategy.noop), + ).toEqual({ a: 2 }); + }); + }); + describe('test with overrideFormDataWithDefaults set to `replace`', () => { it('should return empty array even when no defaults', () => { expect(mergeDefaultsWithFormData(undefined, [2], undefined, undefined, OverrideFormDataStrategy.replace)).toEqual( @@ -356,6 +378,12 @@ describe('mergeDefaultsWithFormData()', () => { expect(mergeDefaultsWithFormData({}, null, undefined, true, OverrideFormDataStrategy.replace)).toEqual({}); }); + it('should return empty object when formData is defined and default is not', () => { + expect( + mergeDefaultsWithFormData(undefined, { a: 1 }, undefined, undefined, OverrideFormDataStrategy.replace), + ).toEqual({}); + }); + it('should not merge two one-level deep objects', () => { expect( mergeDefaultsWithFormData({ a: 1 }, { b: 2 }, undefined, undefined, OverrideFormDataStrategy.replace), @@ -402,18 +430,18 @@ describe('mergeDefaultsWithFormData()', () => { ).toEqual([{ a: 1 }]); }); - it('should replace objects', () => { - const obj1 = { + it('should replace deep objects', () => { + const defaultObj = { a: 1, b: { c: 3, - d: [1, 2, 3], + d: [0, 1, 2], e: { f: { g: 1 } }, h: [{ i: 1 }, { i: 2 }], }, c: 2, }; - const obj2 = { + const formObj = { a: 1, b: { d: [3], @@ -425,12 +453,12 @@ describe('mergeDefaultsWithFormData()', () => { d: 4, }; expect( - mergeDefaultsWithFormData(obj1, obj2, undefined, undefined, OverrideFormDataStrategy.replace), + mergeDefaultsWithFormData(defaultObj, formObj, undefined, undefined, OverrideFormDataStrategy.replace), ).toEqual({ a: 1, b: { c: 3, - d: [1, 2, 3], + d: [0, 1, 2], e: { f: { g: 1 } }, h: [{ i: 1 }, { i: 2 }], }, @@ -444,7 +472,7 @@ describe('mergeDefaultsWithFormData()', () => { ).toEqual([1, 2]); }); - it('should replace objects', () => { + it('should replace simple object', () => { expect( mergeDefaultsWithFormData( { a: { b: 1 } }, diff --git a/packages/utils/test/schema/getDefaultFormStateTest.ts b/packages/utils/test/schema/getDefaultFormStateTest.ts index a85dd9876f..66df0c598b 100644 --- a/packages/utils/test/schema/getDefaultFormStateTest.ts +++ b/packages/utils/test/schema/getDefaultFormStateTest.ts @@ -5052,11 +5052,12 @@ export default function getDefaultFormStateTest(testValidator: TestValidatorType }), ).toEqual({ stringArray: [undefined], numberArray: [] }); }); - describe('defaults with oneOf and useDefaultAlways as', () => { + describe('defaults with oneOf and useDefaultAlways', () => { const schema: RJSFSchema = { type: 'object', properties: { country: { + type: 'string', enum: ['UK', 'France', 'Spain'], }, rating: { @@ -5074,7 +5075,10 @@ export default function getDefaultFormStateTest(testValidator: TestValidatorType city: { type: 'array', uniqueItems: true, - enum: ['London', 'Birmingham', 'Liverpool'], + items: { + type: 'string', + enum: ['London', 'Birmingham', 'Liverpool'], + }, default: ['London'], }, }, @@ -5087,7 +5091,10 @@ export default function getDefaultFormStateTest(testValidator: TestValidatorType city: { type: 'array', uniqueItems: true, - enum: ['Paris', 'Marseille', 'Lyon'], + items: { + type: 'string', + enum: ['Paris', 'Marseille', 'Lyon'], + }, default: ['Paris'], }, }, @@ -5100,7 +5107,10 @@ export default function getDefaultFormStateTest(testValidator: TestValidatorType city: { type: 'array', uniqueItems: true, - enum: ['Madrid', 'Barcelona', 'Valencia'], + items: { + type: 'string', + enum: ['Madrid', 'Barcelona', 'Valencia'], + }, default: ['Madrid'], }, }, @@ -5116,6 +5126,13 @@ export default function getDefaultFormStateTest(testValidator: TestValidatorType } }); it('should populate empty defaults for oneOf + dependencies', () => { + testValidator.setReturnValues({ + isValid: [ + true, // First oneOf... first === first + false, // Second oneOf... second !== first + false, + ], + }); expect( getDefaultFormState(testValidator, schema, { country: 'UK', rating: '6.0' }, undefined, undefined, { mergeDefaultsIntoFormData: 'useDefaultAlways', @@ -5127,6 +5144,13 @@ export default function getDefaultFormStateTest(testValidator: TestValidatorType }); }); it('should replace defaults for oneOf + dependencies', () => { + testValidator.setReturnValues({ + isValid: [ + false, // First oneOf... first === first + true, // Second oneOf... second !== first + false, + ], + }); expect( getDefaultFormState( testValidator,