Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bug: Issue with formData not updating when dependencies change #4388

Merged
merged 19 commits into from
Jan 6, 2025
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ should change the heading of the (upcoming) version to include a major version b

-->

# 5.24.0
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be 5.23.3 since there isn't a new feature?


## @rjsf/utils

- Fixed issue with formData not updating when dependencies change, fixing [#4325](https://github.com/rjsf-team/react-jsonschema-form/issues/4325)

# 5.23.2

## @rjsf/core
Expand Down
48 changes: 37 additions & 11 deletions packages/utils/src/mergeDefaultsWithFormData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import get from 'lodash/get';

import isObject from './isObject';
import { GenericObjectType } from '../src';
import isNil from 'lodash/isNil';

/** Merges the `defaults` object of type `T` into the `formData` of type `T`
*
Expand All @@ -19,47 +20,72 @@ import { GenericObjectType } from '../src';
* @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
* 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.
* @returns - The resulting merged form data with defaults
*/
export default function mergeDefaultsWithFormData<T = any>(
defaults?: T,
formData?: T,
mergeExtraArrayDefaults = false,
defaultSupercedesUndefined = false
defaultSupercedesUndefined = false,
overrideFormDataWithDefaults = false
): T | undefined {
if (Array.isArray(formData)) {
const defaultsArray = Array.isArray(defaults) ? defaults : [];
const mapped = formData.map((value, idx) => {
if (defaultsArray[idx]) {

// If overrideFormDataWithDefaults is true, we want to override the formData with the defaults
const overrideArray = overrideFormDataWithDefaults ? defaultsArray : formData;
const overrideOppositeArray = overrideFormDataWithDefaults ? formData : defaultsArray;

const mapped = overrideArray.map((value, idx) => {
if (overrideOppositeArray[idx]) {
return mergeDefaultsWithFormData<any>(
defaultsArray[idx],
value,
formData[idx],
mergeExtraArrayDefaults,
defaultSupercedesUndefined
defaultSupercedesUndefined,
overrideFormDataWithDefaults
);
}
return value;
});

// Merge any extra defaults when mergeExtraArrayDefaults is true
if (mergeExtraArrayDefaults && mapped.length < defaultsArray.length) {
mapped.push(...defaultsArray.slice(mapped.length));
// Or when overrideFormDataWithDefaults is true and the default array is shorter than the formData array
if ((mergeExtraArrayDefaults || overrideFormDataWithDefaults) && mapped.length < overrideOppositeArray.length) {
mapped.push(...overrideOppositeArray.slice(mapped.length));
}
return mapped as unknown as T;
}
if (isObject(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) => {
const keyValue = get(formData, key);
const keyExistsInDefaults = isObject(defaults) && key in (defaults as GenericObjectType);
const keyExistsInFormData = key in (formData as GenericObjectType);
acc[key as keyof T] = mergeDefaultsWithFormData<T>(
defaults ? get(defaults, key) : {},
get(formData, key),
keyValue,
mergeExtraArrayDefaults,
defaultSupercedesUndefined
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)
);
return acc;
}, acc);
}
if (defaultSupercedesUndefined && formData === undefined) {
if (
defaultSupercedesUndefined &&
((!isNil(defaults) && isNil(formData)) || (typeof formData === 'number' && isNaN(formData)))
) {
return defaults;
} else if (overrideFormDataWithDefaults && isNil(formData)) {
// If the overrideFormDataWithDefaults flag is true and formData is set to undefined or null return formData
return formData;
}
return formData;
return overrideFormDataWithDefaults ? defaults : formData;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way to optimize this logic so that we have:

if (/*conditions for defaults*/) {
  return defaults;
}
return formData;

}
117 changes: 101 additions & 16 deletions packages/utils/src/schema/getDefaultFormState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,12 @@ import {
ValidatorType,
} from '../types';
import isMultiSelect from './isMultiSelect';
import isSelect from './isSelect';
import retrieveSchema, { resolveDependencies } from './retrieveSchema';
import isConstant from '../isConstant';
import { JSONSchema7Object } from 'json-schema';
import isEqual from 'lodash/isEqual';
import optionsList from '../optionsList';

const PRIMITIVE_TYPES = ['string', 'number', 'integer', 'boolean', 'null'];

Expand Down Expand Up @@ -169,6 +173,10 @@ interface ComputeDefaultsProps<T = any, S extends StrictRJSFSchema = RJSFSchema>
experimental_customMergeAllOf?: Experimental_CustomMergeAllOf<S>;
/** Optional flag, if true, indicates this schema was required in the parent schema. */
required?: boolean;
/** Optional flag, if true, It will merge defaults into formData.
* The formData should take precedence unless it's not valid. This is useful when for example the value from formData does not exist in the schema 'enum' property, in such cases we take the value from the defaults because the value from the formData is not valid.
*/
shouldMergeDefaultsIntoFormData?: boolean;
}

/** Computes the defaults for the current `schema` given the `rawFormData` and `parentDefaults` if any. This drills into
Expand All @@ -193,6 +201,7 @@ export function computeDefaults<T = any, S extends StrictRJSFSchema = RJSFSchema
experimental_defaultFormStateBehavior = undefined,
experimental_customMergeAllOf = undefined,
required,
shouldMergeDefaultsIntoFormData = false,
} = computeDefaultsProps;
const formData: T = (isObject(rawFormData) ? rawFormData : {}) as T;
const schema: S = isObject(rawSchema) ? rawSchema : ({} as S);
Expand Down Expand Up @@ -245,6 +254,7 @@ export function computeDefaults<T = any, S extends StrictRJSFSchema = RJSFSchema
parentDefaults: Array.isArray(parentDefaults) ? parentDefaults[idx] : undefined,
rawFormData: formData as T,
required,
shouldMergeDefaultsIntoFormData,
})
) as T[];
} else if (ONE_OF_KEY in schema) {
Expand All @@ -266,7 +276,7 @@ export function computeDefaults<T = any, S extends StrictRJSFSchema = RJSFSchema
getClosestMatchingOption<T, S, F>(
validator,
rootSchema,
isEmpty(formData) ? undefined : formData,
rawFormData,
oneOf as S[],
0,
discriminator,
Expand All @@ -284,7 +294,7 @@ export function computeDefaults<T = any, S extends StrictRJSFSchema = RJSFSchema
getClosestMatchingOption<T, S, F>(
validator,
rootSchema,
isEmpty(formData) ? undefined : formData,
rawFormData,
anyOf as S[],
0,
discriminator,
Expand All @@ -304,6 +314,7 @@ export function computeDefaults<T = any, S extends StrictRJSFSchema = RJSFSchema
parentDefaults: defaults as T | undefined,
rawFormData: formData as T,
required,
shouldMergeDefaultsIntoFormData,
});
}

Expand All @@ -314,7 +325,67 @@ export function computeDefaults<T = any, S extends StrictRJSFSchema = RJSFSchema

const defaultBasedOnSchemaType = getDefaultBasedOnSchemaType(validator, schema, computeDefaultsProps, defaults);

return defaultBasedOnSchemaType ?? defaults;
let defaultsWithFormData = defaultBasedOnSchemaType ?? defaults;
// if shouldMergeDefaultsIntoFormData is true, then merge the defaults into the formData.
if (shouldMergeDefaultsIntoFormData) {
const { arrayMinItems = {} } = experimental_defaultFormStateBehavior || {};
const { mergeExtraDefaults } = arrayMinItems;

const matchingFormData = ensureFormDataMatchingSchema(
validator,
schema,
rootSchema,
rawFormData,
experimental_defaultFormStateBehavior
);
if (!isObject(rawFormData)) {
defaultsWithFormData = mergeDefaultsWithFormData<T>(
defaultsWithFormData as T,
matchingFormData as T,
mergeExtraDefaults,
true
) as T;
}
}

return defaultsWithFormData;
}

/**
* Ensure that the formData matches the given schema. If it's not matching in the case of a selectField, we change it to match the schema.
* @param validator - an implementation of the `ValidatorType` interface that will be used when necessary
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* Ensure that the formData matches the given schema. If it's not matching in the case of a selectField, we change it to match the schema.
* @param validator - an implementation of the `ValidatorType` interface that will be used when necessary
* Ensure that the formData matches the given schema. If it's not matching in the case of a selectField, we change it to match the schema.
*
* @param validator - an implementation of the `ValidatorType` interface that will be used when necessary

* @param schema - The schema for which the formData state is desired
* @param rootSchema - The root schema, used to primarily to look up `$ref`s
* @param formData - The current formData
* @param experimental_defaultFormStateBehavior - Optional configuration object, if provided, allows users to override default form state behavior
* @returns - valid formData that matches schema
*/
export function ensureFormDataMatchingSchema<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any
>(
validator: ValidatorType<T, S, F>,
schema: S,
rootSchema: S,
formData: T | undefined,
experimental_defaultFormStateBehavior?: Experimental_DefaultFormStateBehavior
): T | T[] | undefined {
const isSelectField = !isConstant(schema) && isSelect(validator, schema, rootSchema);
let validFormData: T | T[] | undefined = formData;
if (isSelectField) {
const getOptionsList = optionsList(schema);
const isValid = getOptionsList?.some((option) => isEqual(option.value, formData));
validFormData = isValid ? formData : undefined;
}

// Override the formData with the const if the constAsDefaults is set to always
const constTakesPrecedence = schema[CONST_KEY] && experimental_defaultFormStateBehavior?.constAsDefaults === 'always';
if (constTakesPrecedence) {
validFormData = schema.const as any;
abdalla-rko marked this conversation as resolved.
Show resolved Hide resolved
}

return validFormData;
}

/** Computes the default value for objects.
Expand All @@ -336,6 +407,7 @@ export function getObjectDefaults<T = any, S extends StrictRJSFSchema = RJSFSche
experimental_defaultFormStateBehavior = undefined,
experimental_customMergeAllOf = undefined,
required,
shouldMergeDefaultsIntoFormData,
}: ComputeDefaultsProps<T, S> = {},
defaults?: T | T[] | undefined
): T {
Expand Down Expand Up @@ -369,6 +441,7 @@ export function getObjectDefaults<T = any, S extends StrictRJSFSchema = RJSFSche
parentDefaults: get(defaults, [key]),
rawFormData: get(formData, [key]),
required: retrievedSchema.required?.includes(key),
shouldMergeDefaultsIntoFormData,
});
maybeAddDefaultToObject<T>(
acc,
Expand Down Expand Up @@ -413,6 +486,7 @@ export function getObjectDefaults<T = any, S extends StrictRJSFSchema = RJSFSche
parentDefaults: get(defaults, [key]),
rawFormData: get(formData, [key]),
required: retrievedSchema.required?.includes(key),
shouldMergeDefaultsIntoFormData,
});
// Since these are additional properties we don't need to add the `experimental_defaultFormStateBehavior` prop
maybeAddDefaultToObject<T>(
Expand Down Expand Up @@ -447,6 +521,7 @@ export function getArrayDefaults<T = any, S extends StrictRJSFSchema = RJSFSchem
experimental_defaultFormStateBehavior = undefined,
experimental_customMergeAllOf = undefined,
required,
shouldMergeDefaultsIntoFormData,
}: ComputeDefaultsProps<T, S> = {},
defaults?: T | T[] | undefined
): T | T[] | undefined {
Expand Down Expand Up @@ -474,6 +549,7 @@ export function getArrayDefaults<T = any, S extends StrictRJSFSchema = RJSFSchem
experimental_customMergeAllOf,
parentDefaults: item,
required,
shouldMergeDefaultsIntoFormData,
});
}) as T[];
}
Expand All @@ -493,6 +569,7 @@ export function getArrayDefaults<T = any, S extends StrictRJSFSchema = RJSFSchem
rawFormData: item,
parentDefaults: get(defaults, [idx]),
required,
shouldMergeDefaultsIntoFormData,
});
}) as T[];

Expand Down Expand Up @@ -541,6 +618,7 @@ export function getArrayDefaults<T = any, S extends StrictRJSFSchema = RJSFSchem
experimental_defaultFormStateBehavior,
experimental_customMergeAllOf,
required,
shouldMergeDefaultsIntoFormData,
})
) as T[];
// then fill up the rest with either the item default or empty, up to minItems
Expand Down Expand Up @@ -607,26 +685,33 @@ export default function getDefaultFormState<
throw new Error('Invalid schema: ' + theSchema);
}
const schema = retrieveSchema<T, S, F>(validator, theSchema, rootSchema, formData, experimental_customMergeAllOf);

// Get the computed defaults with 'shouldMergeDefaultsIntoFormData' set to true to merge defaults into formData.
// This is done when for example the value from formData does not exist in the schema 'enum' property, in such
// cases we take the value from the defaults because the value from the formData is not valid.
const defaults = computeDefaults<T, S, F>(validator, schema, {
rootSchema,
includeUndefinedValues,
experimental_defaultFormStateBehavior,
experimental_customMergeAllOf,
rawFormData: formData,
shouldMergeDefaultsIntoFormData: true,
});

if (formData === undefined || formData === null || (typeof formData === 'number' && isNaN(formData))) {
// No form data? Use schema defaults.
return defaults;
}
const { mergeDefaultsIntoFormData, arrayMinItems = {} } = experimental_defaultFormStateBehavior || {};
const { mergeExtraDefaults } = arrayMinItems;
const defaultSupercedesUndefined = mergeDefaultsIntoFormData === 'useDefaultIfFormDataUndefined';
if (isObject(formData)) {
return mergeDefaultsWithFormData<T>(defaults as T, formData, mergeExtraDefaults, defaultSupercedesUndefined);
}
if (Array.isArray(formData)) {
return mergeDefaultsWithFormData<T[]>(defaults as T[], formData, mergeExtraDefaults, defaultSupercedesUndefined);
// If the formData is an object or an array, add additional properties from formData and override formData with
// defaults since the defaults are already merged with formData.
if (isObject(formData) || Array.isArray(formData)) {
const { mergeDefaultsIntoFormData } = experimental_defaultFormStateBehavior || {};
const defaultSupercedesUndefined = mergeDefaultsIntoFormData === 'useDefaultIfFormDataUndefined';
const result = mergeDefaultsWithFormData<T>(
defaults as T,
abdalla-rko marked this conversation as resolved.
Show resolved Hide resolved
formData,
true, // set to true to add any additional default array entries.
defaultSupercedesUndefined,
true // set to true to override formData with defaults if they exist.
);
return result;
}
return formData;

return defaults;
}
Loading
Loading