Skip to content

Commit

Permalink
fix: bug fix and new feature for experimental_defaultFormStateBehavior
Browse files Browse the repository at this point in the history
Fixed a bug with `experimental_defaultFormStateBehavior.emptyObjectFields='populateRequiredDefaults'` and added new feature for merging `minItems` array defaults into `formData`
- In `@rjsf/utils`, fixed the bug and added the new feature as follows:
  - Updated the `mergeDefaultsWithFormData()` function to take a new optional `mergeExtraArrayDefaults=false` parameter that causes array defaults to append onto `formData` values when true
  - Added a new `Experimental_ArrayMinItems` type containing `populate` and `mergeExtraDefaults`
  - Updated the `Experimental_DefaultFormStateBehavior` to switch `arrayMinItems` to be of the `Experimental_ArrayMinItems` type
    - Updated all tests to switch to use this new object structure
  - Updated the `computeDefaults()` function to not default `required` and to pass it to all of the recursive calls to itself where it was missing
  - Updated the `maybeAddDefaultToObject()` function to use the required state of the field itself when the (now optional) `isParentRequired` is not specified
  - Updated the `getDefaultFormState()` function to pass the value of `experimental_defaultFormStateBehavior.arrayMinItems.mergeExtraDefaults` to `mergeDefaultsWithFormData()`
    - Added additional tests that verify the bug fix and new behavior, while uncommenting a skipped test after fixing the expected result
- Updated the documentation for the new features in `mergeDefaultsWithFormData()` and `experimental_defaultFormStateBehavior.arrayMinItems`
- Updated the `CHANGELOG.md` accordingly
  • Loading branch information
heath-freenome committed Jun 27, 2023
1 parent 2b1c56b commit 337c12a
Show file tree
Hide file tree
Showing 11 changed files with 362 additions and 53 deletions.
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,23 @@ 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.
-->
# 5.8.3

## @rjsf/utils

- Updated `getDefaultFormState()` to fix a bug where `experimental_defaultFormStateBehavior: { emptyObjectFields: 'populateRequiredDefaults' }` wasn't working for object properties with `$ref`s
- Experimental feature **breaking change**:
- Updated the `experimental_defaultFormStateBehavior.arrayMinItems` from simple flag to an object containing two optional fields, `populate` and `mergeExtraDefaults`
- The new `arrayMinItems.mergeExtraDefaults` flag, when "true", allows users to merge defaults onto the end of `formData` arrays when `minItems` is specified
- If you were previously passing `experimental_defaultFormStateBehavior` as `{ arrayMinItems = 'requiredOnly }` on the `Form`, now you would pass `{ arrayMinItems: { populate: 'requiredOnly' } }`
- Added a new, optional `mergeExtraArrayDefaults=false` flag to the `mergeDefaultWithFormData()` utility function to support the new `arrayMinItems.mergeExtraDefaults` experimental feature

## Dev / docs / playground

- Updated the `utility-functions` documentation to add the new `mergeExtraArrayDefaults` flag for the `mergeDefaultWithFormData()` function
- Updated the `form-props` documentation to update the `arrayMinItems` documentation for the new object behavior
- Updated the `playground` to add a checkbox for the new `arrayMinItems.mergeExtraDefaults` flag

# 5.8.2

## @rjsf/validator-ajv8
Expand Down
4 changes: 2 additions & 2 deletions packages/core/test/Form.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -1416,7 +1416,7 @@ describeRepeated('Form common', (createFormComponent) => {
formData: {
albums: ['Until We Have Faces'],
},
experimental_defaultFormStateBehavior: { arrayMinItems: 'requiredOnly' },
experimental_defaultFormStateBehavior: { arrayMinItems: { populate: 'requiredOnly' } },
});
submitForm(node);
sinon.assert.calledWithMatch(onError.lastCall, [
Expand All @@ -1434,7 +1434,7 @@ describeRepeated('Form common', (createFormComponent) => {
const { node, onSubmit } = createFormComponent({
schema,
formData: {},
experimental_defaultFormStateBehavior: { arrayMinItems: 'requiredOnly' },
experimental_defaultFormStateBehavior: { arrayMinItems: { populate: 'requiredOnly' } },
});
submitForm(node);
sinon.assert.calledWithMatch(onSubmit.lastCall, { formData: {} });
Expand Down
41 changes: 29 additions & 12 deletions packages/docs/docs/api-reference/form-props.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,24 +61,41 @@ See [Validation](../usage/validation.md) for more information.

## experimental_defaultFormStateBehavior

Experimental features to specify different form state behavior. Currently, this only affects the handling of optional array fields where `minItems` is set.
Experimental features to specify different form state behavior.
Currently, this only affects the handling of optional array fields where `minItems` is set and handling of setting defaults based on the value of `emptyObjectFields`.

The following sub-sections represent the different keys in this object, with the tables explaining the values and their meanings.
The following subsections represent the different keys in this object, with the tables explaining the values and their meanings.

### `arrayMinItems`

| Flag Value | Description |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `populate` | Legacy behavior - populate minItems entries with default values initially and include empty array when no values have been defined |
| `requiredOnly` | Ignore `minItems` on a field when calculating defaults unless the field is required |
This optional subsection is an object with two optional fields, `populate` and `mergeExtraDefaults`.
When not specified, it defaults to `{ populate: 'all', mergeExtraDefaults: false }`.

#### `arrayMinItems.populate`

Optional enumerated flag controlling how array minItems are populated, defaulting to `all`:

| Flag Value | Description |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `all` | Legacy behavior - populate minItems entries with default values initially and include empty array when no values have been defined. |
| `requiredOnly` | Ignore `minItems` on a field when calculating defaults unless the field is required. |

#### `arrayMinItems.mergeExtraDefaults`

Optional boolean flag, defaulting to `false` when not specified.
When `formData` is provided and does not contain `minItems` worth of data, this flag controls whether the extra data provided by the defaults is appended onto the existing `formData` items to ensure the `minItems` condition is met.
When `false`, only the `formData` provided is merged into the default form state, even if there are fewer than the `minItems`.
When `true`, the defaults are appended onto the end of the `formData` until the `minItems` condition is met.

### `emptyObjectFields`

| Flag Value | Description |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `populateAllDefaults` | Legacy behavior - set default when there is a primitive value, an non-empty object field, or the field itself is required |
| `populateRequiredDefaults` | Only sets default when a value is an object and its parent field is required or it is a primitive value and it is required |
| `skipDefaults` | Does not set defaults |
Optional enumerated flag controlling how empty object fields are populated, defaulting to `populateAllDefaults`:

| Flag Value | Description |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `populateAllDefaults` | Legacy behavior - set default when there is a primitive value, an non-empty object field, or the field itself is required |
| `populateRequiredDefaults` | Only sets default when a value is an object and its parent field is required, or it is a primitive value and it is required |
| `skipDefaults` | Does not set defaults |

```tsx
import { RJSFSchema } from '@rjsf/utils';
Expand All @@ -95,7 +112,7 @@ render(
schema={schema}
validator={validator}
experimental_defaultFormStateBehavior={{
arrayMinItems: 'requiredOnly',
arrayMinItems: { populate: 'requiredOnly' },
}}
/>,
document.getElementById('app')
Expand Down
3 changes: 2 additions & 1 deletion packages/docs/docs/api-reference/utility-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -516,14 +516,15 @@ 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:
- 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
- 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

#### Parameters

- [defaults]: T | undefined - The defaults to merge
- [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

#### Returns

Expand Down
39 changes: 27 additions & 12 deletions packages/playground/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,21 +79,31 @@ const liveSettingsSelectSchema: RJSFSchema = {
type: 'object',
properties: {
arrayMinItems: {
type: 'string',
title: 'minItems behavior for array field',
default: 'populate',
oneOf: [
{
type: 'object',
properties: {
populate: {
type: 'string',
title: 'Populate remaining minItems with default values (legacy behavior)',
enum: ['populate'],
default: 'populate',
title: 'Populate minItems in arrays',
oneOf: [
{
type: 'string',
title: 'Populate remaining minItems with default values (legacy behavior)',
enum: ['all'],
},
{
type: 'string',
title: 'Ignore minItems unless field is required',
enum: ['requiredOnly'],
},
],
},
{
type: 'string',
title: 'Ignore minItems unless field is required',
enum: ['requiredOnly'],
mergeExtraDefaults: {
title: 'Merge array defaults with formData',
type: 'boolean',
default: false,
},
],
},
},
emptyObjectFields: {
type: 'string',
Expand Down Expand Up @@ -129,6 +139,11 @@ const liveSettingsSelectUiSchema: UiSchema = {
'ui:options': {
label: false,
},
arrayMinItems: {
'ui:options': {
label: false,
},
},
},
};

Expand Down
22 changes: 18 additions & 4 deletions packages/utils/src/mergeDefaultsWithFormData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,29 +9,43 @@ import { GenericObjectType } from '../src';
* - objects are deeply merged
* - arrays are 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
* 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
*
* @param [defaults] - The defaults to merge
* @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
* @returns - The resulting merged form data with defaults
*/
export default function mergeDefaultsWithFormData<T = any>(defaults?: T, formData?: T): T | undefined {
export default function mergeDefaultsWithFormData<T = any>(
defaults?: T,
formData?: T,
mergeExtraArrayDefaults = false
): T | undefined {
if (Array.isArray(formData)) {
const defaultsArray = Array.isArray(defaults) ? defaults : [];
const mapped = formData.map((value, idx) => {
if (defaultsArray[idx]) {
return mergeDefaultsWithFormData<any>(defaultsArray[idx], value);
return mergeDefaultsWithFormData<any>(defaultsArray[idx], value, mergeExtraArrayDefaults);
}
return value;
});
// Merge any extra defaults when mergeExtraArrayDefaults is true
if (mergeExtraArrayDefaults && mapped.length < defaultsArray.length) {
mapped.push(...defaultsArray.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) => {
acc[key as keyof T] = mergeDefaultsWithFormData<T>(defaults ? get(defaults, key) : {}, get(formData, key));
acc[key as keyof T] = mergeDefaultsWithFormData<T>(
defaults ? get(defaults, key) : {},
get(formData, key),
mergeExtraArrayDefaults
);
return acc;
}, acc);
}
Expand Down
21 changes: 15 additions & 6 deletions packages/utils/src/schema/getDefaultFormState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ function maybeAddDefaultToObject<T = any>(
key: string,
computedDefault: T | T[] | undefined,
includeUndefinedValues: boolean | 'excludeObjectChildren',
isParentRequired: boolean,
isParentRequired?: boolean,
requiredFields: string[] = [],
experimental_defaultFormStateBehavior: Experimental_DefaultFormStateBehavior = {}
) {
Expand All @@ -98,12 +98,15 @@ function maybeAddDefaultToObject<T = any>(
obj[key] = computedDefault;
} else if (emptyObjectFields !== 'skipDefaults') {
if (isObject(computedDefault)) {
// If isParentRequired is undefined, then we are at the root level of the schema so defer to the requiredness of
// the field key itself in the `requiredField` list
const isSelfOrParentRequired = isParentRequired === undefined ? requiredFields.includes(key) : isParentRequired;
// Store computedDefault if it's a non-empty object(e.g. not {}) and satisfies certain conditions
// Condition 1: If computedDefault is not empty or if the key is a required field
// Condition 2: If the parent object is required or emptyObjectFields is not 'populateRequiredDefaults'
if (
(!isEmpty(computedDefault) || requiredFields.includes(key)) &&
(isParentRequired || emptyObjectFields !== 'populateRequiredDefaults')
(isSelfOrParentRequired || emptyObjectFields !== 'populateRequiredDefaults')
) {
obj[key] = computedDefault;
}
Expand Down Expand Up @@ -156,7 +159,7 @@ export function computeDefaults<T = any, S extends StrictRJSFSchema = RJSFSchema
includeUndefinedValues = false,
_recurseList = [],
experimental_defaultFormStateBehavior = undefined,
required = false,
required,
}: ComputeDefaultsProps<T, S> = {}
): T | T[] | undefined {
const formData: T = (isObject(rawFormData) ? rawFormData : {}) as T;
Expand Down Expand Up @@ -192,6 +195,7 @@ export function computeDefaults<T = any, S extends StrictRJSFSchema = RJSFSchema
experimental_defaultFormStateBehavior,
parentDefaults: Array.isArray(parentDefaults) ? parentDefaults[idx] : undefined,
rawFormData: formData as T,
required,
})
) as T[];
} else if (ONE_OF_KEY in schema) {
Expand Down Expand Up @@ -234,6 +238,7 @@ export function computeDefaults<T = any, S extends StrictRJSFSchema = RJSFSchema
experimental_defaultFormStateBehavior,
parentDefaults: defaults as T | undefined,
rawFormData: formData as T,
required,
});
}

Expand Down Expand Up @@ -320,6 +325,7 @@ export function computeDefaults<T = any, S extends StrictRJSFSchema = RJSFSchema
_recurseList,
experimental_defaultFormStateBehavior,
parentDefaults: item,
required,
});
}) as T[];
}
Expand All @@ -334,11 +340,12 @@ export function computeDefaults<T = any, S extends StrictRJSFSchema = RJSFSchema
experimental_defaultFormStateBehavior,
rawFormData: item,
parentDefaults: get(defaults, [idx]),
required,
});
}) as T[];
}

const ignoreMinItemsFlagSet = experimental_defaultFormStateBehavior?.arrayMinItems === 'requiredOnly';
const ignoreMinItemsFlagSet = experimental_defaultFormStateBehavior?.arrayMinItems?.populate === 'requiredOnly';
if (ignoreMinItemsFlagSet && !required) {
// If no form data exists or defaults are set leave the field empty/non-existent, otherwise
// return form data/defaults
Expand All @@ -365,6 +372,7 @@ export function computeDefaults<T = any, S extends StrictRJSFSchema = RJSFSchema
rootSchema,
_recurseList,
experimental_defaultFormStateBehavior,
required,
})
) as T[];
// then fill up the rest with either the item default or empty, up to minItems
Expand Down Expand Up @@ -414,11 +422,12 @@ export default function getDefaultFormState<
// No form data? Use schema defaults.
return defaults;
}
const { mergeExtraDefaults } = experimental_defaultFormStateBehavior?.arrayMinItems || {};
if (isObject(formData)) {
return mergeDefaultsWithFormData<T>(defaults as T, formData);
return mergeDefaultsWithFormData<T>(defaults as T, formData, mergeExtraDefaults);
}
if (Array.isArray(formData)) {
return mergeDefaultsWithFormData<T[]>(defaults as T[], formData);
return mergeDefaultsWithFormData<T[]>(defaults as T[], formData, mergeExtraDefaults);
}
return formData;
}
34 changes: 31 additions & 3 deletions packages/utils/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,40 @@ export type RJSFSchema = StrictRJSFSchema & GenericObjectType;
*/
export type FormContextType = GenericObjectType;

/** Experimental features to specify different form state behavior. Currently, this affects the
/** Experimental feature that specifies the Array `minItems` default form state behavior
*/
export type Experimental_ArrayMinItems = {
/** Optional enumerated flag controlling how array minItems are populated, defaulting to `all`:
* - `all`: Legacy behavior, populate minItems entries with default values initially and include an empty array when
* no values have been defined.
* - `requiredOnly`: Ignore `minItems` on a field when calculating defaults unless the field is required.
*/
populate?: 'all' | 'requiredOnly';
/** When `formData` is provided and does not contain `minItems` worth of data, this flag (`false` by default) controls
* whether the extra data provided by the defaults is appended onto the existing `formData` items to ensure the
* `minItems` condition is met. When false, only the `formData` provided is merged into the default form state, even
* if there are fewer than the `minItems`. When true, the defaults are appended onto the end of the `formData` until
* the `minItems` condition is met.
*/
mergeExtraDefaults?: boolean;
};

/** Experimental features to specify different default form state behaviors. Currently, this affects the
* handling of optional array fields where `minItems` is set and handling of setting defaults based on the
* value of `emptyObjectFields`
* value of `emptyObjectFields`.
*/
export type Experimental_DefaultFormStateBehavior = {
arrayMinItems?: 'populate' | 'requiredOnly';
/** Optional object, that controls how the default form state for arrays with `minItems` is handled. When not provided
* it defaults to `{ populate: 'all' }`.
*/
arrayMinItems?: Experimental_ArrayMinItems;
/** Optional enumerated flag controlling how empty object fields are populated, defaulting to `populateAllDefaults`:
* - `populateAllDefaults`: Legacy behavior - set default when there is a primitive value, an non-empty object field,
* or the field itself is required |
* - `populateRequiredDefaults`: Only sets default when a value is an object and its parent field is required, or it
* is a primitive value and it is required |
* - `skipDefaults`: Does not set defaults |
*/
emptyObjectFields?: 'populateAllDefaults' | 'populateRequiredDefaults' | 'skipDefaults';
};

Expand Down
Loading

0 comments on commit 337c12a

Please sign in to comment.