diff --git a/.eslintrc.json b/.eslintrc.json index 1d2e5995fc..02db95dfe0 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -19,6 +19,7 @@ "beforeSelfClosing": "always" } ], + "react-hooks/exhaustive-deps": "error", "curly": [ 2 ], diff --git a/CHANGELOG.md b/CHANGELOG.md index f1dbce403d..7e6a1ba07b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ should change the heading of the (upcoming) version to include a major version b - Added support for dynamic UI schema in array fields - the `items` property in `uiSchema` can now accept a function that returns a UI schema based on the array item's data, index, and form context ([#4706](https://github.com/rjsf-team/react-jsonschema-form/pull/4706)) - Fixed checkbox widget to use current value instead of event target in onFocus/onBlur handlers, fixing [#4704](https://github.com/rjsf-team/react-jsonschema-form/issues/4704) +- Updated all of the `XxxxField` components and `Form` to handle the new `path` parameter in `FieldProps.onChange`, making `Form` queue up changes so that they are all processed and no data is lost, fixing [#3367](https://github.com/rjsf-team/react-jsonschema-form/issues/3367) +- Updated a bug in `AltDateWidget` related to the `clear` button not working after the fix for [#3367](https://github.com/rjsf-team/react-jsonschema-form/issues/3367) +- Fixed the missing hook dependencies for the `CheckboxesWidget` so that they work properly ## @rjsf/chakra-ui @@ -29,6 +32,7 @@ should change the heading of the (upcoming) version to include a major version b ## @rjsf/daisyui - Fixed checkbox widget to use current value instead of event target in onFocus/onBlur handlers, fixing [#4704](https://github.com/rjsf-team/react-jsonschema-form/issues/4704) +- Fixed the missing hook dependencies in the `DateTimeWidget` and `DateWidget` so that they work properly ## @rjsf/fluentui-rc @@ -62,6 +66,7 @@ should change the heading of the (upcoming) version to include a major version b - Updated `UiSchema` type to support dynamic array item UI schemas - the `items` property can now be either a `UiSchema` object or a function that returns a `UiSchema` ([#4706](https://github.com/rjsf-team/react-jsonschema-form/pull/4706)) - Added `title` property to `RJSFValidationError` [PR](https://github.com/rjsf-team/react-jsonschema-form/pull/4700) +- BREAKING CHANGE: Updated the `FieldProps` interface's `onChange` handler to inject a new optional `path` before the `ErrorSchema` parameter as part of the fix for [#3367](https://github.com/rjsf-team/react-jsonschema-form/issues/3367) ## @rjsf/validator-ajv8 @@ -72,6 +77,9 @@ should change the heading of the (upcoming) version to include a major version b - Added comprehensive documentation for dynamic UI schema feature with TypeScript examples ([#4706](https://github.com/rjsf-team/react-jsonschema-form/pull/4706)) - Updated array documentation to reference the new dynamic UI schema capabilities ([#4706](https://github.com/rjsf-team/react-jsonschema-form/pull/4706)) - Updated nearly all of the libraries in the `package.json` files to the latest non-breaking versions +- Fixed the broken `Custom Array` sample +- Improved the `Any Of with Custom Field` sample so that it renders using the appropriate theme components +- Updated the `custom-widgets-fields.md` and `v6.x upgrade guide.md` to document the BREAKING CHANGE to the `FieldProps.onChange` behavior # 6.0.0-beta.13 diff --git a/docs/index.md b/docs/index.md index 59fb619f0e..8ec331d504 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,5 +1,3 @@ # react-jsonschema-form The react-jsonschema-form docs have been moved [here](https://rjsf-team.github.io/react-jsonschema-form/docs). - -We are in the process of migrating our versioned documentation. For documentation prior to version 5.0.0, please select the version in the bottom-right corner of this page. diff --git a/package-lock.json b/package-lock.json index 947b28599b..05f7028e28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,6 +63,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", "husky": "^9.1.7", + "is-ci": "^4.1.0", "jest": "^30.0.5", "jest-environment-jsdom": "^30.0.5", "jest-watch-typeahead": "^3.0.1", @@ -22718,21 +22719,10 @@ } }, "node_modules/is-ci": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", - "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", - "license": "MIT", - "dependencies": { - "ci-info": "^3.2.0" - }, - "bin": { - "is-ci": "bin.js" - } - }, - "node_modules/is-ci/node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-4.1.0.tgz", + "integrity": "sha512-Ab9bQDQ11lWootZUI5qxgN2ZXwxNI5hTwnsvOc1wyxQ7zQ8OkEDw79mI0+9jI3x432NfwbVRru+3noJfXF6lSQ==", + "dev": true, "funding": [ { "type": "github", @@ -22740,8 +22730,11 @@ } ], "license": "MIT", - "engines": { - "node": ">=8" + "dependencies": { + "ci-info": "^4.1.0" + }, + "bin": { + "is-ci": "bin.js" } }, "node_modules/is-core-module": { @@ -36855,6 +36848,33 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/update-notifier/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/update-notifier/node_modules/is-ci": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", + "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", + "license": "MIT", + "dependencies": { + "ci-info": "^3.2.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, "node_modules/update-notifier/node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", diff --git a/package.json b/package.json index c7d5e2f84a..b03382f3e9 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "build-serial": "nx run-many --target=build --parallel=1", "start": "echo 'use \"npm run build\" from main directory and then \"npm start\" in the playground package'", "pre-commit:husky": "nx run-many --parallel=1 --target=precommit", - "prepare": "husky install", + "prepare": "is-ci || husky", "format": "prettier --write .", "format-check": "prettier --check .", "bump-all-packages": "echo 'NOTE: Make sure to sanity check the playground locally before commiting changes' && npm update --save && npm install && npm run lint && npm run build && npm run test", @@ -69,6 +69,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", "husky": "^9.1.7", + "is-ci": "^4.1.0", "jest": "^30.0.5", "jest-environment-jsdom": "^30.0.5", "jest-watch-typeahead": "^3.0.1", diff --git a/packages/core/src/components/Form.tsx b/packages/core/src/components/Form.tsx index 05d9509987..afd24f29f6 100644 --- a/packages/core/src/components/Form.tsx +++ b/packages/core/src/components/Form.tsx @@ -38,11 +38,13 @@ import { createErrorHandler, unwrapErrorHandler, } from '@rjsf/utils'; +import _cloneDeep from 'lodash/cloneDeep'; import _forEach from 'lodash/forEach'; import _get from 'lodash/get'; import _isEmpty from 'lodash/isEmpty'; import _isNil from 'lodash/isNil'; import _pick from 'lodash/pick'; +import _set from 'lodash/set'; import _toPath from 'lodash/toPath'; import getDefaultRegistry from '../getDefaultRegistry'; @@ -272,6 +274,19 @@ export interface IChangeEvent { + /** The path into the formData/errorSchema at which the `newValue`/`newErrorSchema` will be set */ + path?: (number | string)[]; + /** The new value to set into the formData */ + newValue?: T; + /** The new errors to be set into the errorSchema, if any */ + newErrorSchema?: ErrorSchema; + /** The optional id of the field for which the change is being made */ + id?: string; +} + /** The `Form` component renders the outer form and all the fields defined in the `schema` */ export default class Form< T = any, @@ -283,6 +298,10 @@ export default class Form< */ formElement: RefObject; + /** The list of pending changes + */ + pendingChanges: PendingChange[] = []; + /** Constructs the `Form` from the `props`. Will setup the initial state from the props. It will also call the * `onChange` handler if the initially provided `formData` is modified to add missing default values as part of the * state construction. @@ -539,8 +558,7 @@ export default class Form< let customValidateErrors = {}; if (typeof customValidate === 'function') { const errorHandler = customValidate(prevFormData, createErrorHandler(prevFormData), uiSchema); - const userErrorSchema = unwrapErrorHandler(errorHandler); - customValidateErrors = userErrorSchema; + customValidateErrors = unwrapErrorHandler(errorHandler); } return customValidateErrors; } @@ -550,7 +568,8 @@ export default class Form< * * @param formData - The new form data to validate * @param schema - The schema used to validate against - * @param altSchemaUtils - The alternate schemaUtils to use for validation + * @param [altSchemaUtils] - The alternate schemaUtils to use for validation + * @param [retrievedSchema] - An optionally retrieved schema for per */ validate( formData: T | undefined, @@ -655,11 +674,16 @@ export default class Form< const retrievedSchema = schemaUtils.retrieveSchema(schema, formData); const pathSchema = schemaUtils.toPathSchema(retrievedSchema, '', formData); const fieldNames = this.getFieldNames(pathSchema, formData); - const newFormData = this.getUsedFormData(formData, fieldNames); - return newFormData; + return this.getUsedFormData(formData, fieldNames); }; - // Filtering errors based on your retrieved schema to only show errors for properties in the selected branch. + /** Filtering errors based on your retrieved schema to only show errors for properties in the selected branch. + * + * @param schemaErrors - The schema errors to filter + * @param [resolvedSchema] - An optionally resolved schema to use for performance reasons + * @param [formData] - The formData to help filter errors + * @private + */ private filterErrorsBasedOnSchema(schemaErrors: ErrorSchema, resolvedSchema?: S, formData?: any): ErrorSchema { const { retrievedSchema, schemaUtils } = this.state; const _retrievedSchema = resolvedSchema ?? retrievedSchema; @@ -705,23 +729,47 @@ export default class Form< return filterNilOrEmptyErrors(filteredErrors, prevCustomValidateErrors); } - /** Function to handle changes made to a field in the `Form`. This handler receives an entirely new copy of the - * `formData` along with a new `ErrorSchema`. It will first update the `formData` with any missing default fields and - * then, if `omitExtraData` and `liveOmit` are turned on, the `formData` will be filtered to remove any extra data not - * in a form field. Then, the resulting formData will be validated if required. The state will be updated with the new - * updated (potentially filtered) `formData`, any errors that resulted from validation. Finally the `onChange` - * callback will be called if specified with the updated state. + /** Pushes the given change information into the `pendingChanges` array and then calls `processPendingChanges()` if + * the array only contains a single pending change. * - * @param formData - The new form data from a change to a field - * @param newErrorSchema - The new `ErrorSchema` based on the field change - * @param id - The id of the field that caused the change + * @param newValue - The new form data from a change to a field + * @param [path] - The path to the change into which to set the formData + * @param [newErrorSchema] - The new `ErrorSchema` based on the field change + * @param [id] - The id of the field that caused the change + */ + onChange = (newValue: T | undefined, path?: (number | string)[], newErrorSchema?: ErrorSchema, id?: string) => { + this.pendingChanges.push({ newValue, path, newErrorSchema, id }); + if (this.pendingChanges.length === 1) { + this.processPendingChange(); + } + }; + + /** Function to handle changes made to a field in the `Form`. This handler gets the first change from the + * `pendingChanges` list, containing the `newValue` for the `formData` and the `path` at which the `newValue` is to be + * updated, along with a new, optional `ErrorSchema` for that same `path` and potentially the `id` of the field being + * changed. It will first update the `formData` with any missing default fields and then, if `omitExtraData` and + * `liveOmit` are turned on, the `formData` will be filtered to remove any extra data not in a form field. Then, the + * resulting `formData` will be validated if required. The state will be updated with the new updated (potentially + * filtered) `formData`, any errors that resulted from validation. Finally the `onChange` callback will be called, if + * specified, with the updated state and the `processPendingChange()` function is called again. */ - onChange = (formData: T | undefined, newErrorSchema?: ErrorSchema, id?: string) => { - const { extraErrors, omitExtraData, liveOmit, noValidate, liveValidate, onChange } = this.props; - const { schemaUtils, schema } = this.state; + processPendingChange() { + if (this.pendingChanges.length === 0) { + return; + } + const { newValue, path, id } = this.pendingChanges[0]; + let { newErrorSchema } = this.pendingChanges[0]; + const { extraErrors, omitExtraData, liveOmit, noValidate, liveValidate, onChange, idPrefix = '' } = this.props; + const { formData: oldFormData, schemaUtils, schema, errorSchema } = this.state; + const isRootPath = !path || path.length === 0 || (path.length === 1 && path[0] === idPrefix); let retrievedSchema = this.state.retrievedSchema; + let formData = isRootPath ? newValue : _cloneDeep(oldFormData); if (isObject(formData) || Array.isArray(formData)) { + if (!isRootPath) { + // If the newValue is not on the root path, then set it into the form data + _set(formData, path, newValue); + } const newState = this.getStateFromProps(this.props, formData); formData = newState.formData; retrievedSchema = newState.retrievedSchema; @@ -738,6 +786,13 @@ export default class Form< }; } + // First update the value in the newErrorSchema in a copy of the old error schema if it was specified and the path + // is not the root + if (newErrorSchema && !isRootPath) { + const errorSchemaCopy = _cloneDeep(errorSchema); + _set(errorSchemaCopy, path, newErrorSchema); + newErrorSchema = errorSchemaCopy; + } if (mustValidate) { const schemaValidation = this.validate(newFormData, schema, schemaUtils, retrievedSchema); let errors = schemaValidation.errors; @@ -762,6 +817,7 @@ export default class Form< schemaValidationErrorSchema, }; } else if (!noValidate && newErrorSchema) { + // Merging 'newErrorSchema' into 'errorSchema' to display the custom raised errors. const errorSchema = extraErrors ? (mergeObjects(newErrorSchema, extraErrors, 'preventDuplicates') as ErrorSchema) : newErrorSchema; @@ -771,8 +827,15 @@ export default class Form< errors: toErrorList(errorSchema), }; } - this.setState(state as FormState, () => onChange && onChange({ ...this.state, ...state }, id)); - }; + this.setState(state as FormState, () => { + if (onChange) { + onChange({ ...this.state, ...state }, id); + } + // Now remove the change we just completed and call this again + this.pendingChanges.shift(); + this.processPendingChange(); + }); + } /** * If the retrievedSchema has changed the new retrievedSchema is returned. @@ -1029,7 +1092,7 @@ export default class Form< const { children, id, - idPrefix, + idPrefix = '', idSeparator, className = '', tagName, @@ -1082,7 +1145,7 @@ export default class Form< > {showErrorList === 'top' && this.renderErrors(registry)} <_SchemaField - name='' + name={idPrefix} schema={schema} uiSchema={uiSchema} errorSchema={errorSchema} diff --git a/packages/core/src/components/fields/ArrayField.tsx b/packages/core/src/components/fields/ArrayField.tsx index 0b6d87aac5..0427888eda 100644 --- a/packages/core/src/components/fields/ArrayField.tsx +++ b/packages/core/src/components/fields/ArrayField.tsx @@ -229,7 +229,8 @@ class ArrayField onChange(keyedToPlainFormData(newKeyedFormData), newErrorSchema as ErrorSchema), + // add click will pass the empty `path` array to the onChange which adds the appropriate path + () => onChange(keyedToPlainFormData(newKeyedFormData), [], newErrorSchema as ErrorSchema), ); } @@ -298,7 +299,8 @@ class ArrayField onChange(keyedToPlainFormData(newKeyedFormData), newErrorSchema as ErrorSchema), + // Copy index will pass the empty `path` array to the onChange which adds the appropriate path + () => onChange(keyedToPlainFormData(newKeyedFormData), [], newErrorSchema as ErrorSchema), ); }; }; @@ -335,7 +337,8 @@ class ArrayField onChange(keyedToPlainFormData(newKeyedFormData), newErrorSchema as ErrorSchema), + // drop index will pass the empty `path` array to the onChange which adds the appropriate path + () => onChange(keyedToPlainFormData(newKeyedFormData), [], newErrorSchema as ErrorSchema), ); }; }; @@ -385,7 +388,8 @@ class ArrayField onChange(keyedToPlainFormData(newKeyedFormData), newErrorSchema as ErrorSchema), + // reorder click will pass the empty `path` array to the onChange which adds the appropriate path + () => onChange(keyedToPlainFormData(newKeyedFormData), [], newErrorSchema as ErrorSchema), ); }; }; @@ -396,22 +400,17 @@ class ArrayField { - return (value: any, newErrorSchema?: ErrorSchema, id?: string) => { - const { formData, onChange, errorSchema } = this.props; - const arrayData = Array.isArray(formData) ? formData : []; - const newFormData = arrayData.map((item: T, i: number) => { + return (value: any, path?: (number | string)[], newErrorSchema?: ErrorSchema, id?: string) => { + const { onChange } = this.props; + // Copy the current path and insert in the index into the first location + const changePath = Array.isArray(path) ? path.slice() : []; + changePath.unshift(index); + onChange( // We need to treat undefined items as nulls to have validation. // See https://github.com/tdegrunt/jsonschema/issues/206 - const jsonValue = typeof value === 'undefined' ? null : value; - return index === i ? jsonValue : item; - }); - onChange( - newFormData, - errorSchema && - errorSchema && { - ...errorSchema, - [index]: newErrorSchema, - }, + value === undefined ? null : value, + changePath, + newErrorSchema as ErrorSchema, id, ); }; @@ -419,8 +418,9 @@ class ArrayField { - const { onChange, idSchema } = this.props; - onChange(value, undefined, idSchema && idSchema.$id); + const { name, onChange, idSchema } = this.props; + // select change will pass the `path` array with the name + onChange(value, [name], undefined, idSchema && idSchema.$id); }; /** Helper method to compute item UI schema for both normal and fixed arrays diff --git a/packages/core/src/components/fields/BooleanField.tsx b/packages/core/src/components/fields/BooleanField.tsx index 4ff48ce058..91de24d798 100644 --- a/packages/core/src/components/fields/BooleanField.tsx +++ b/packages/core/src/components/fields/BooleanField.tsx @@ -1,3 +1,4 @@ +import { useCallback } from 'react'; import { getWidget, getUiOptions, @@ -5,6 +6,7 @@ import { FieldProps, FormContextType, EnumOptionsType, + ErrorSchema, RJSFSchema, StrictRJSFSchema, TranslatableString, @@ -86,6 +88,13 @@ function BooleanField({ enum: enums } as S, uiSchema); } } + const onWidgetChange = useCallback( + (value: T | undefined, errorSchema?: ErrorSchema, id?: string) => { + // Boolean field change passes an empty path array to the parent field which adds the appropriate path + return onChange(value, [], errorSchema, id); + }, + [onChange], + ); return ( { - return (value: unknown, errSchema?: ErrorSchema, id?: string) => { - const { onChange, errorSchema, formData } = this.props; - const newFormData = cloneDeep(formData || ({} as T)); + return (value: T | undefined, path?: (number | string)[], errSchema?: ErrorSchema, id?: string) => { + const { onChange, errorSchema } = this.props; let newErrorSchema = errorSchema; if (errSchema && errorSchema) { newErrorSchema = cloneDeep(errorSchema); set(newErrorSchema, dottedPath, errSchema); } - set(newFormData as object, dottedPath, value); - onChange(newFormData, newErrorSchema, id); + onChange(value, dottedPath.split('.'), newErrorSchema, id); }; }; diff --git a/packages/core/src/components/fields/LayoutMultiSchemaField.tsx b/packages/core/src/components/fields/LayoutMultiSchemaField.tsx index 54d86c4dd0..3b79de38be 100644 --- a/packages/core/src/components/fields/LayoutMultiSchemaField.tsx +++ b/packages/core/src/components/fields/LayoutMultiSchemaField.tsx @@ -171,7 +171,8 @@ export default function LayoutMultiSchemaField< if (newFormData) { set(newFormData, selectorField, opt); } - onChange(newFormData, undefined, id); + // Pass the component name in the path + onChange(newFormData, [name], undefined, id); }; // filtering the options based on the type of widget because `selectField` does not recognize the `convertOther` prop diff --git a/packages/core/src/components/fields/MultiSchemaField.tsx b/packages/core/src/components/fields/MultiSchemaField.tsx index 4ebdf255e9..819fe65d1c 100644 --- a/packages/core/src/components/fields/MultiSchemaField.tsx +++ b/packages/core/src/components/fields/MultiSchemaField.tsx @@ -131,7 +131,8 @@ class AnyOfField { - onChange(newFormData, undefined, this.getFieldId()); + // Changing the option will pass an empty path array to the parent field which will add the appropriate path + onChange(newFormData, [], undefined, this.getFieldId()); }); }; diff --git a/packages/core/src/components/fields/NullField.tsx b/packages/core/src/components/fields/NullField.tsx index a682b8a24d..3cb335fcc4 100644 --- a/packages/core/src/components/fields/NullField.tsx +++ b/packages/core/src/components/fields/NullField.tsx @@ -9,12 +9,12 @@ import { FieldProps, FormContextType, RJSFSchema, StrictRJSFSchema } from '@rjsf function NullField( props: FieldProps, ) { - const { formData, onChange } = props; + const { name, formData, onChange } = props; useEffect(() => { if (formData === undefined) { - onChange(null as unknown as T); + onChange(null as unknown as T, [name]); } - }, [formData, onChange]); + }, [name, formData, onChange]); return null; } diff --git a/packages/core/src/components/fields/NumberField.tsx b/packages/core/src/components/fields/NumberField.tsx index 4de29bd9bc..d3483731bb 100644 --- a/packages/core/src/components/fields/NumberField.tsx +++ b/packages/core/src/components/fields/NumberField.tsx @@ -44,7 +44,7 @@ function NumberField['value'], errorSchema?: ErrorSchema, id?: string) => { + (value: FieldProps['value'], path?: (number | string)[], errorSchema?: ErrorSchema, id?: string) => { // Cache the original value in component state setLastValue(value); @@ -62,7 +62,7 @@ function NumberField { - return (value: T | undefined, newErrorSchema?: ErrorSchema, id?: string) => { - const { formData, onChange, errorSchema } = this.props; + return (value: T | undefined, path?: (number | string)[], newErrorSchema?: ErrorSchema, id?: string) => { + const { onChange } = this.props; if (value === undefined && addedByAdditionalProperties) { // Don't set value = undefined for fields added by // additionalProperties. Doing so removes them from the @@ -78,16 +78,10 @@ class ObjectField { return (event: DragEvent) => { event.preventDefault(); - const { onChange, formData } = this.props; + const { onChange, formData, name } = this.props; const copiedFormData = { ...formData } as T; unset(copiedFormData, key); - onChange(copiedFormData); + // drop property will pass the name in `path` array + onChange(copiedFormData, [name]); }; }; @@ -133,11 +128,11 @@ class ObjectField { - return (value: any, newErrorSchema: ErrorSchema) => { + return (value: any) => { if (oldValue === value) { return; } - const { formData, onChange, errorSchema } = this.props; + const { formData, onChange } = this.props; value = this.getAvailableKey(value, formData); const newFormData: GenericObjectType = { @@ -152,14 +147,8 @@ class ObjectField, id?: string) => { + (formData: T | undefined, path?: (number | string)[], newErrorSchema?: ErrorSchema, id?: string) => { const theId = id || fieldId; - return onChange(formData, newErrorSchema, theId); + return onChange(formData, path, newErrorSchema, theId); }, [fieldId, onChange], ); diff --git a/packages/core/src/components/fields/StringField.tsx b/packages/core/src/components/fields/StringField.tsx index 26d6bb2096..b6a069533b 100644 --- a/packages/core/src/components/fields/StringField.tsx +++ b/packages/core/src/components/fields/StringField.tsx @@ -1,3 +1,4 @@ +import { useCallback } from 'react'; import { getWidget, getUiOptions, @@ -7,6 +8,7 @@ import { FormContextType, RJSFSchema, StrictRJSFSchema, + ErrorSchema, } from '@rjsf/utils'; /** The `StringField` component is used to render a schema field that represents a string type @@ -44,6 +46,13 @@ function StringField(schema, widget, widgets); + const onWidgetChange = useCallback( + (value: T | undefined, errorSchema?: ErrorSchema, id?: string) => { + // String field change passes an empty path array to the parent field which adds the appropriate path + return onChange(value, [], errorSchema, id); + }, + [onChange], + ); return ( ) { const { translateString } = registry; - const [lastValue, setLastValue] = useState(value); - const [state, setState] = useReducer( - (state: DateObject, action: Partial) => { - return { ...state, ...action }; - }, - parseDateString(value, time), - ); + const [state, setState] = useState(parseDateString(value, time)); useEffect(() => { - const stateValue = toDateString(state, time); - if (readyForChange(state) && stateValue !== value) { - // The user changed the date to a new valid data via the comboboxes, so call onChange - onChange(stateValue); - } else if (lastValue !== value) { - // We got a new value in the props - setLastValue(value); - setState(parseDateString(value, time)); - } - }, [time, value, onChange, state, lastValue]); + setState(parseDateString(value, time)); + }, [time, value]); + + const handleChange = useCallback( + (property: keyof DateObject, value: string) => { + const nextState = { + ...state, + [property]: typeof value === 'undefined' ? -1 : value, + }; - const handleChange = useCallback((property: keyof DateObject, value: string) => { - setState({ [property]: value }); - }, []); + if (readyForChange(nextState)) { + onChange(toDateString(nextState, time)); + } else { + setState(nextState); + } + }, + [state, onChange, time], + ); const handleSetNow = useCallback( (event: MouseEvent) => { @@ -117,7 +115,7 @@ function AltDateWidget) => onBlur(id, enumOptionsValueForIndex(target && target.value, enumOptions, emptyValue)), - [onBlur, id], + [onBlur, id, enumOptions, emptyValue], ); const handleFocus = useCallback( ({ target }: FocusEvent) => onFocus(id, enumOptionsValueForIndex(target && target.value, enumOptions, emptyValue)), - [onFocus, id], + [onFocus, id, enumOptions, emptyValue], ); return (
diff --git a/packages/core/test/ArrayField.test.jsx b/packages/core/test/ArrayField.test.jsx index db67dea862..016ea26588 100644 --- a/packages/core/test/ArrayField.test.jsx +++ b/packages/core/test/ArrayField.test.jsx @@ -164,21 +164,14 @@ const ArrayFieldTestItemTemplate = (props) => { }; const ArrayFieldTest = (props) => { - const onChangeTest = (newFormData, errorSchema, id) => { - if (Array.isArray(newFormData) && newFormData.length === 1) { - const itemValue = newFormData[0]?.text; - if (itemValue !== 'Appie') { - const raiseError = { - ...errorSchema, - 0: { - text: { - __errors: ['Value must be "Appie"'], - }, - }, - }; - props.onChange(newFormData, raiseError, id); - } + const onChangeTest = (newFormData, path, errorSchema, id) => { + let newErrorSchema = errorSchema; + if (newFormData !== 'Appie') { + newErrorSchema = { + __errors: ['Value must be "Appie"'], + }; } + props.onChange(newFormData, path, newErrorSchema, id); }; return ; }; diff --git a/packages/core/test/Form.test.jsx b/packages/core/test/Form.test.jsx index 2c047642b0..f28488b5d7 100644 --- a/packages/core/test/Form.test.jsx +++ b/packages/core/test/Form.test.jsx @@ -1,11 +1,11 @@ -import * as React from 'react'; +import { Component, createRef, useEffect } from 'react'; import { expect } from 'chai'; import sinon from 'sinon'; -import { createRef } from 'react'; -import { fireEvent, act, render } from '@testing-library/react'; +import { fireEvent, act, render, waitFor } from '@testing-library/react'; import { Simulate } from 'react-dom/test-utils'; import { findDOMNode } from 'react-dom'; import { Portal } from 'react-portal'; +import { getTemplate, getUiOptions } from '@rjsf/utils'; import validator, { customizeValidator } from '@rjsf/validator-ajv8'; import Form from '../src'; @@ -32,26 +32,26 @@ describeRepeated('Form common', (createFormComponent) => { describe('Empty schema', () => { it('Should throw error when Form is missing validator', () => { - expect(() => createFormComponent({ ref: React.createRef(), schema: {}, validator: undefined })).to.Throw( + expect(() => createFormComponent({ ref: createRef(), schema: {}, validator: undefined })).to.Throw( Error, 'A validator is required for Form functionality to work', ); }); it('should render a form tag', () => { - const { node } = createFormComponent({ ref: React.createRef(), schema: {} }); + const { node } = createFormComponent({ ref: createRef(), schema: {} }); expect(node.tagName).eql('FORM'); }); it('should render a submit button', () => { - const { node } = createFormComponent({ ref: React.createRef(), schema: {} }); + const { node } = createFormComponent({ ref: createRef(), schema: {} }); expect(node.querySelectorAll('button[type=submit]')).to.have.length.of(1); }); it('should render children buttons', () => { - const props = { ref: React.createRef(), schema: {}, validator }; + const props = { ref: createRef(), schema: {}, validator }; const comp = (
@@ -66,7 +66,7 @@ describeRepeated('Form common', (createFormComponent) => { it("should render errors if schema isn't object", () => { const props = { - ref: React.createRef(), + ref: createRef(), validator, schema: { type: 'object', @@ -1126,7 +1126,7 @@ describeRepeated('Form common', (createFormComponent) => { const onSubmit = sandbox.spy(); const event = { type: 'submit' }; const { node } = createFormComponent({ - ref: React.createRef(), + ref: createRef(), schema, formData, onSubmit, @@ -1154,7 +1154,7 @@ describeRepeated('Form common', (createFormComponent) => { const onSubmit = sandbox.spy(); const onError = sandbox.spy(); const { node } = createFormComponent({ - ref: React.createRef(), + ref: createRef(), schema, formData, onSubmit, @@ -1188,7 +1188,7 @@ describeRepeated('Form common', (createFormComponent) => { }; const onChange = sandbox.spy(); const { node } = createFormComponent({ - ref: React.createRef(), + ref: createRef(), schema, uiSchema, formData, @@ -1225,7 +1225,7 @@ describeRepeated('Form common', (createFormComponent) => { const secondOnChange = sandbox.spy(); const { comp, onChange } = createFormComponent({ - ref: React.createRef(), + ref: createRef(), schema, formData: { foo: 'bar1' }, }); @@ -1261,6 +1261,73 @@ describeRepeated('Form common', (createFormComponent) => { sinon.assert.callCount(onChange, 1); sinon.assert.callCount(secondOnChange, 1); }); + it('should call change handler with proper data after two near simultaneous changes', async () => { + const schema = { + type: 'object', + properties: { + foo: { + type: 'string', + default: 'bar', + }, + baz: { + type: 'string', + default: 'blah', + }, + }, + }; + function FooWidget(props) { + const { value, id, onChange, uiSchema, registry } = props; + const uiOptions = getUiOptions(uiSchema); + const BaseInputTemplate = getTemplate('BaseInputTemplate', registry, uiOptions); + useEffect(() => { + if (value === 'bar') { + onChange('bar2', undefined, id); + } + }, [value, onChange, id]); + return ; + } + function BazWidget(props) { + const { value, id, onChange, uiSchema, registry } = props; + const uiOptions = getUiOptions(uiSchema); + const BaseInputTemplate = getTemplate('BaseInputTemplate', registry, uiOptions); + useEffect(() => { + if (value === 'blah') { + onChange('blah2', undefined, id); + } + }, [value, onChange, id]); + return ; + } + const uiSchema = { + foo: { + 'ui:widget': FooWidget, + }, + baz: { + 'ui:widget': BazWidget, + }, + }; + + let formData = {}; + const ids = []; + const onChange = (data, id) => { + const { formData: fd } = data; + formData = { ...formData, ...fd }; + ids.push(id); + }; + createFormComponent({ + schema, + formData, + onChange, + uiSchema, + }); + + await waitFor(() => { + expect(ids).to.have.length(3); + }); + + expect(formData).to.eql({ foo: 'bar2', baz: 'blah2' }); + // There will be 3 ids, undefined for the setting of the defaults and then the two updated components + expect(ids).to.eql([undefined, 'root_foo', 'root_baz']); + }); it('should modify an allOf field when the defaults are set', () => { const schema = { properties: { @@ -1878,7 +1945,7 @@ describeRepeated('Form common', (createFormComponent) => { beforeEach(() => { onChangeProp = sinon.spy(); formProps = { - ref: React.createRef(), + ref: createRef(), schema: { type: 'string', default: 'foobar', @@ -1981,7 +2048,7 @@ describeRepeated('Form common', (createFormComponent) => { }); describe('when the onChange prop sets formData to a falsey value', () => { - class TestForm extends React.Component { + class TestForm extends Component { constructor() { super(); @@ -2026,7 +2093,7 @@ describeRepeated('Form common', (createFormComponent) => { describe('External formData updates', () => { describe('root level', () => { const formProps = { - ref: React.createRef(), + ref: createRef(), schema: { type: 'string' }, liveValidate: true, }; @@ -2074,7 +2141,7 @@ describeRepeated('Form common', (createFormComponent) => { describe('object level', () => { it('should call submit handler with new formData prop value', async () => { const formProps = { - ref: React.createRef(), + ref: createRef(), schema: { type: 'object', properties: { foo: { type: 'string' } } }, }; const { comp, onSubmit, node } = createFormComponent(formProps); @@ -2101,7 +2168,7 @@ describeRepeated('Form common', (createFormComponent) => { type: 'string', }, }; - const { comp, node, onSubmit } = createFormComponent({ ref: React.createRef(), schema }); + const { comp, node, onSubmit } = createFormComponent({ ref: createRef(), schema }); setProps(comp, { ref: comp.ref, @@ -2121,7 +2188,7 @@ describeRepeated('Form common', (createFormComponent) => { describe('Internal formData updates', () => { it('root', () => { const formProps = { - ref: React.createRef(), + ref: createRef(), schema: { type: 'string' }, liveValidate: true, }; @@ -2141,7 +2208,7 @@ describeRepeated('Form common', (createFormComponent) => { }); it('object', () => { const { node, onChange } = createFormComponent({ - ref: React.createRef(), + ref: createRef(), schema: { type: 'object', properties: { @@ -2171,7 +2238,7 @@ describeRepeated('Form common', (createFormComponent) => { type: 'string', }, }; - const { node, onChange } = createFormComponent({ ref: React.createRef(), schema }); + const { node, onChange } = createFormComponent({ ref: createRef(), schema }); fireEvent.click(node.querySelector('.rjsf-array-item-add button')); @@ -2196,7 +2263,7 @@ describeRepeated('Form common', (createFormComponent) => { }, }, }; - const { node, onChange } = createFormComponent({ ref: React.createRef(), schema }); + const { node, onChange } = createFormComponent({ ref: createRef(), schema }); fireEvent.click(node.querySelector('.rjsf-array-item-add button')); @@ -2246,7 +2313,7 @@ describeRepeated('Form common', (createFormComponent) => { }, }, }; - const { node, onChange } = createFormComponent({ ref: React.createRef(), schema }); + const { node, onChange } = createFormComponent({ ref: createRef(), schema }); const checkbox = node.querySelector('[type=checkbox]'); fireEvent.click(checkbox); @@ -2415,7 +2482,6 @@ describeRepeated('Form common', (createFormComponent) => { }); fireEvent.submit(node); - console.log('onSubmit.lastCall ', onSubmit.lastCall); sinon.assert.calledWithMatch(onSubmit.lastCall, { errorSchema: {}, }); @@ -3032,7 +3098,7 @@ describeRepeated('Form common', (createFormComponent) => { it('should only show errors for properties in selected branch', () => { const { node, onChange } = createFormComponent({ - ref: React.createRef(), + ref: createRef(), schema, liveValidate: true, formData: { branch: 2 }, @@ -3062,7 +3128,7 @@ describeRepeated('Form common', (createFormComponent) => { it('should not show any errors when branch is empty', () => { const { node, onChange } = createFormComponent({ - ref: React.createRef(), + ref: createRef(), schema, liveValidate: true, formData: { branch: 3 }, @@ -3174,7 +3240,7 @@ describeRepeated('Form common', (createFormComponent) => { it('should replace state when props remove formData keys', () => { const formData = { foo: 'foo', bar: 'bar' }; const { comp, node, onChange } = createFormComponent({ - ref: React.createRef(), + ref: createRef(), schema, formData, }); @@ -3206,7 +3272,7 @@ describeRepeated('Form common', (createFormComponent) => { it('should replace state when props change formData keys', () => { const formData = { foo: 'foo', bar: 'bar' }; const { comp, node, onChange } = createFormComponent({ - ref: React.createRef(), + ref: createRef(), schema, formData, }); @@ -3265,7 +3331,7 @@ describeRepeated('Form common', (createFormComponent) => { it('should not update idSchema for a falsey value', () => { const formData = { a: 'int' }; const { comp, node, onSubmit } = createFormComponent({ - ref: React.createRef(), + ref: createRef(), schema, formData, }); @@ -3309,7 +3375,7 @@ describeRepeated('Form common', (createFormComponent) => { a: 'int', }; const { comp, node, onSubmit } = createFormComponent({ - ref: React.createRef(), + ref: createRef(), schema, formData, }); @@ -3524,7 +3590,7 @@ describeRepeated('Form common', (createFormComponent) => { describe('Custom format updates', () => { it('Should update custom formats when customFormats is changed', () => { const formProps = { - ref: React.createRef(), + ref: createRef(), liveValidate: true, formData: { areaCode: '123455', @@ -3585,7 +3651,7 @@ describeRepeated('Form common', (createFormComponent) => { describe('Meta schema updates', () => { it('Should update allowed meta schemas when additionalMetaSchemas is changed', () => { const formProps = { - ref: React.createRef(), + ref: createRef(), schema: { $schema: 'http://json-schema.org/draft-06/schema#', type: 'string', @@ -3658,7 +3724,7 @@ describeRepeated('Form common', (createFormComponent) => { const outerOnSubmit = sandbox.spy(); let innerRef; - class ArrayTemplateWithForm extends React.Component { + class ArrayTemplateWithForm extends Component { constructor(props) { super(props); innerRef = createRef(); @@ -3799,7 +3865,7 @@ describe('Form omitExtraData and liveOmit', () => { const liveOmit = true; const { node, comp } = createFormComponent({ - ref: React.createRef(), + ref: createRef(), schema, formData, onChange, @@ -3833,7 +3899,7 @@ describe('Form omitExtraData and liveOmit', () => { const onChange = sandbox.spy(); const omitExtraData = true; const { node, comp } = createFormComponent({ - ref: React.createRef(), + ref: createRef(), schema, formData, onChange, @@ -3868,7 +3934,7 @@ describe('Form omitExtraData and liveOmit', () => { const onError = sandbox.spy(); const omitExtraData = true; const { comp, node } = createFormComponent({ - ref: React.createRef(), + ref: createRef(), schema, formData, onSubmit, @@ -3894,7 +3960,7 @@ describe('Form omitExtraData and liveOmit', () => { }, }; const formData = { foo: 'bar', baz: 'baz' }; - const formRef = React.createRef(); + const formRef = createRef(); const props = { ref: formRef, schema, @@ -3918,7 +3984,7 @@ describe('Form omitExtraData and liveOmit', () => { }, }; const formData = { foo: 'bar', baz: 'baz' }; - const formRef = React.createRef(); + const formRef = createRef(); const props = { ref: formRef, schema, @@ -3943,7 +4009,7 @@ describe('Form omitExtraData and liveOmit', () => { const formData = 'foo'; const onSubmit = sandbox.spy(); const { comp } = createFormComponent({ - ref: React.createRef(), + ref: createRef(), schema, formData, onSubmit, @@ -3963,7 +4029,7 @@ describe('Form omitExtraData and liveOmit', () => { const formData = []; const onSubmit = sandbox.spy(); const { comp } = createFormComponent({ - ref: React.createRef(), + ref: createRef(), schema, formData, onSubmit, @@ -3984,7 +4050,7 @@ describe('Form omitExtraData and liveOmit', () => { foo: 'bar', }; const onSubmit = sandbox.spy(); - const formRef = React.createRef(); + const formRef = createRef(); const { comp } = createFormComponent({ ref: formRef, schema, @@ -4025,7 +4091,7 @@ describe('Form omitExtraData and liveOmit', () => { }; const onSubmit = sandbox.spy(); const { comp } = createFormComponent({ - ref: React.createRef(), + ref: createRef(), schema, formData, onSubmit, @@ -4049,7 +4115,7 @@ describe('Form omitExtraData and liveOmit', () => { const onSubmit = sandbox.spy(); const { comp } = createFormComponent({ - ref: React.createRef(), + ref: createRef(), schema, formData, onSubmit, @@ -4084,7 +4150,7 @@ describe('Form omitExtraData and liveOmit', () => { const onSubmit = sandbox.spy(); const { comp } = createFormComponent({ - ref: React.createRef(), + ref: createRef(), schema, formData, onSubmit, @@ -4145,7 +4211,7 @@ describe('Form omitExtraData and liveOmit', () => { const onSubmit = sandbox.spy(); const { comp } = createFormComponent({ - ref: React.createRef(), + ref: createRef(), schema, formData, onSubmit, @@ -4191,7 +4257,7 @@ describe('Form omitExtraData and liveOmit', () => { const onSubmit = sandbox.spy(); const { comp } = createFormComponent({ - ref: React.createRef(), + ref: createRef(), schema, formData, onSubmit, @@ -4253,7 +4319,7 @@ describe('Form omitExtraData and liveOmit', () => { }; const formData = { foo: 'foo', baz: 'baz' }; const { node, onChange } = createFormComponent({ - ref: React.createRef(), + ref: createRef(), schema, formData, omitExtraData, @@ -4574,7 +4640,7 @@ describe('Form omitExtraData and liveOmit', () => { }, }; - const formRef = React.createRef(); + const formRef = createRef(); const props = { ref: formRef, schema, @@ -4613,7 +4679,7 @@ describe('Form omitExtraData and liveOmit', () => { }, }; - const formRef = React.createRef(); + const formRef = createRef(); const props = { ref: formRef, schema, @@ -4648,7 +4714,7 @@ describe('Form omitExtraData and liveOmit', () => { }; const { comp, node } = createFormComponent({ - ref: React.createRef(), + ref: createRef(), schema, }); @@ -4703,7 +4769,7 @@ describe('Form omitExtraData and liveOmit', () => { const onSubmit = sinon.spy(); - const formRef = React.createRef(); + const formRef = createRef(); const props = { ref: formRef, schema, @@ -4737,13 +4803,13 @@ describe('Form omitExtraData and liveOmit', () => { }; let changed = false; - class ArrayThatTriggersOnChangeRightAfterUpdated extends React.Component { + class ArrayThatTriggersOnChangeRightAfterUpdated extends Component { componentDidUpdate = () => { if (changed) { return; } changed = true; - this.props.onChange([...this.props.formData, 'test']); + this.props.onChange('test', [this.props.formData.length]); }; render() { const { ArrayField } = this.props.registry.fields; @@ -4761,7 +4827,7 @@ describe('Form omitExtraData and liveOmit', () => { validator, }; - class Container extends React.Component { + class Container extends Component { constructor(props) { super(props); this.state = {}; @@ -4792,7 +4858,7 @@ describe('Form omitExtraData and liveOmit', () => { title: 'Test form', type: 'string', }; - const formRef = React.createRef(); + const formRef = createRef(); const props = { ref: formRef, schema, @@ -4812,7 +4878,7 @@ describe('Form omitExtraData and liveOmit', () => { title: 'Test form', type: 'number', }; - const formRef = React.createRef(); + const formRef = createRef(); const props = { ref: formRef, schema, @@ -4841,7 +4907,7 @@ describe('Form omitExtraData and liveOmit', () => { type: 'string', default: 'Some-Value', }; - const formRef = React.createRef(); + const formRef = createRef(); const props = { ref: formRef, schema: schemaWithDefault, @@ -4864,7 +4930,7 @@ describe('Form omitExtraData and liveOmit', () => { it('Reset button test with complex schema', () => { const schema = widgetsSchema; - const formRef = React.createRef(); + const formRef = createRef(); const props = { ref: formRef, schema, @@ -4904,7 +4970,7 @@ describe('Form omitExtraData and liveOmit', () => { }, }; const formData = { foo: 'bar', baz: 'baz' }; - const formRef = React.createRef(); + const formRef = createRef(); const props = { ref: formRef, schema, @@ -4930,7 +4996,7 @@ describe('Form omitExtraData and liveOmit', () => { }, }; const formData = { foo: 'bar', baz: 'baz' }; - const formRef = React.createRef(); + const formRef = createRef(); const props = { ref: formRef, schema, diff --git a/packages/core/test/LayoutGridField.test.tsx b/packages/core/test/LayoutGridField.test.tsx index 377cd6476e..98167b4d0b 100644 --- a/packages/core/test/LayoutGridField.test.tsx +++ b/packages/core/test/LayoutGridField.test.tsx @@ -587,12 +587,12 @@ function TestRenderer({ 'data-testid': testId, ...props }: Readonly) // Render a div with the props stringified in a span, also render an input to test the onXXXX callbacks function FakeSchemaField({ 'data-testid': testId, ...props }: Readonly) { - const { idSchema, formData, onChange, onBlur, onFocus, uiSchema } = props; + const { idSchema, formData, onChange, onBlur, onFocus, uiSchema, name } = props; const { [ID_KEY]: id } = idSchema; // Special test case that will pass an error schema into on change to allow coverage const error = has(uiSchema, UI_GLOBAL_OPTIONS_KEY) ? EXTRA_ERROR : undefined; const onTextChange = ({ target: { value: val } }: ChangeEvent) => { - onChange(val, error, id); + onChange(val, [name], error, id); }; const onTextBlur = ({ target: { value: val } }: FocusEvent) => onBlur(id, val); const onTextFocus = ({ target: { value: val } }: FocusEvent) => onFocus(id, val); @@ -1374,7 +1374,7 @@ describe('LayoutGridField', () => { expect(props.onFocus).toHaveBeenCalledWith(fieldId, ''); // Type to trigger the onChange await userEvent.type(input, 'foo'); - expect(props.onChange).toHaveBeenCalledWith({ [fieldName]: 'foo' }, props.errorSchema, fieldId); + expect(props.onChange).toHaveBeenCalledWith('foo', [fieldName], props.errorSchema, fieldId); // Tab out of the input field to cause the blur await userEvent.tab(); expect(props.onBlur).toHaveBeenCalledWith(fieldId, 'foo'); @@ -1532,7 +1532,7 @@ describe('LayoutGridField', () => { expect(input).toHaveValue(props.formData[fieldName]); await userEvent.type(input, '!'); const expectedErrors = new ErrorSchemaBuilder().addErrors(ERRORS, fieldName).ErrorSchema; - expect(props.onChange).toHaveBeenCalledWith({ [fieldName]: 'foo!' }, expectedErrors, fieldId); + expect(props.onChange).toHaveBeenCalledWith('foo!', [fieldName], expectedErrors, fieldId); }); test('renderCondition, condition fails, field and null value, NONE operator, no data', () => { const gridProps = { operator: Operators.NONE, field: 'simpleString', value: null }; diff --git a/packages/core/test/LayoutMultiSchemaField.test.tsx b/packages/core/test/LayoutMultiSchemaField.test.tsx index 6c73171109..89111e214c 100644 --- a/packages/core/test/LayoutMultiSchemaField.test.tsx +++ b/packages/core/test/LayoutMultiSchemaField.test.tsx @@ -293,7 +293,7 @@ describe('LayoutMultiSchemaField', () => { await user.click(input); // OnChange was called with the correct event - expect(props.onChange).toHaveBeenCalledWith({ [selectorField]: '2' }, undefined, DEFAULT_ID); + expect(props.onChange).toHaveBeenCalledWith({ [selectorField]: '2' }, [''], undefined, DEFAULT_ID); // Rerender to simulate the onChange updating the value const newFormData = { [selectorField]: SIMPLE_ONEOF_OPTIONS[1].value }; @@ -372,6 +372,7 @@ describe('LayoutMultiSchemaField', () => { ...props.registry.schemaUtils.getDefaultFormState(retrievedOptions[0], sanitizedFormData), [selectorField]: 'first_option', }, + [''], undefined, DEFAULT_ID, ); @@ -432,7 +433,7 @@ describe('LayoutMultiSchemaField', () => { await user.selectOptions(button, ''); // OnChange was called with the correct event - expect(props.onChange).toHaveBeenCalledWith(undefined, undefined, DEFAULT_ID); + expect(props.onChange).toHaveBeenCalledWith(undefined, [''], undefined, DEFAULT_ID); }); test('no options for radio widget, ui:hideError true, props.hideError false, no errors to hide', () => { const props = getProps({ options: [], uiSchema: { 'ui:hideError': true }, hideError: false }); diff --git a/packages/core/test/ObjectField.test.jsx b/packages/core/test/ObjectField.test.jsx index 596d83025a..b9fd152f92 100644 --- a/packages/core/test/ObjectField.test.jsx +++ b/packages/core/test/ObjectField.test.jsx @@ -10,17 +10,15 @@ import { TextWidgetTest } from './StringField.test'; import { createFormComponent, createSandbox, submitForm } from './test_utils'; const ObjectFieldTest = (props) => { - const onChangeTest = (newFormData, errorSchema, id) => { - const propertyValue = newFormData?.foo; - if (propertyValue !== 'test') { - const raiseError = { + const onChangeTest = (newFormData, path, errorSchema, id) => { + let newErrorSchema = errorSchema; + if (newFormData !== 'test') { + newErrorSchema = { ...errorSchema, - foo: { - __errors: ['Value must be "test"'], - }, + __errors: ['Value must be "test"'], }; - props.onChange(newFormData, raiseError, id); } + props.onChange(newFormData, path, newErrorSchema, id); }; return ; }; diff --git a/packages/core/test/StringField.test.jsx b/packages/core/test/StringField.test.jsx index 1d5cacc1e7..39f30632cd 100644 --- a/packages/core/test/StringField.test.jsx +++ b/packages/core/test/StringField.test.jsx @@ -10,16 +10,15 @@ import TextWidget from '../src/components/widgets/TextWidget'; import { createFormComponent, createSandbox, getSelectedOptionValue, submitForm } from './test_utils'; const StringFieldTest = (props) => { - const onChangeTest = (newFormData, errorSchema, id) => { + const onChangeTest = (newFormData, path, errorSchema, id) => { const value = newFormData; let raiseError = errorSchema; if (value !== 'test') { raiseError = { - ...raiseError, __errors: ['Value must be "test"'], }; } - props.onChange(newFormData, raiseError, id); + props.onChange(newFormData, path, raiseError, id); }; return ; }; @@ -30,7 +29,6 @@ export const TextWidgetTest = (props) => { let raiseError = errorSchema; if (value !== 'test') { raiseError = { - ...raiseError, __errors: ['Value must be "test"'], }; } diff --git a/packages/daisyui/src/widgets/DateTimeWidget/DateTimeWidget.tsx b/packages/daisyui/src/widgets/DateTimeWidget/DateTimeWidget.tsx index e33075862f..05e914ed2b 100644 --- a/packages/daisyui/src/widgets/DateTimeWidget/DateTimeWidget.tsx +++ b/packages/daisyui/src/widgets/DateTimeWidget/DateTimeWidget.tsx @@ -277,7 +277,7 @@ export default function DateTimeWidget< // Need to use native DOM events since we're attaching to document document.addEventListener('keydown', handleEscape as (e: KeyboardEvent) => void); return () => document.removeEventListener('keydown', handleEscape as (e: KeyboardEvent) => void); - }, [id, isOpen, onBlur, value]); + }, [id, isOpen, setIsOpen, onBlur, value]); // Add the handleDoneClick callback near the top of the component, with the other event handlers /** Handle clicking the "Done" button @@ -289,7 +289,7 @@ export default function DateTimeWidget< onBlur(id, value); } inputRef.current?.focus(); - }, [localDate, onChange, onBlur, id, value]); + }, [localDate, onChange, onBlur, id, value, setIsOpen]); return (
diff --git a/packages/daisyui/src/widgets/DateWidget/DateWidget.tsx b/packages/daisyui/src/widgets/DateWidget/DateWidget.tsx index f2c9dfb2e7..dfe0f62abb 100644 --- a/packages/daisyui/src/widgets/DateWidget/DateWidget.tsx +++ b/packages/daisyui/src/widgets/DateWidget/DateWidget.tsx @@ -320,7 +320,7 @@ export default function DateWidget document.removeEventListener('keydown', handleEscape); - }, [id, isOpen, onBlur, value]); + }, [id, isOpen, setIsOpen, onBlur, value]); // Add the handleDoneClick callback near the top of the component, with the other event handlers /** Handle clicking the "Done" button @@ -332,7 +332,7 @@ export default function DateWidget diff --git a/packages/docs/docs/advanced-customization/custom-widgets-fields.md b/packages/docs/docs/advanced-customization/custom-widgets-fields.md index f323d6c9a0..d085395157 100644 --- a/packages/docs/docs/advanced-customization/custom-widgets-fields.md +++ b/packages/docs/docs/advanced-customization/custom-widgets-fields.md @@ -131,7 +131,7 @@ const CustomTextWidget = function (props: WidgetProps) { __errors: ['Value must be "test"'], }; } - props.onChange(value, raiseError, id); + props.onChange(value, [], raiseError, id); }; return ; @@ -402,7 +402,7 @@ A field component will always be passed the following props: - `idPrefix`: To avoid collisions with existing ids in the DOM, it is possible to change the prefix used for ids; Default is `root` - `idSeparator`: To avoid using a path separator that is present in field names, it is possible to change the separator used for ids (Default is `_`) - `rawErrors`: `An array of strings listing all generated error messages from encountered errors for this field -- `onChange`: The field change event handler; called with the updated form data and an optional `ErrorSchema` +- `onChange`: The field change event handler; called with the updated field value, the optional change path for the value (defaults to an empty array), an optional ErrorSchema and the optional id of the field being changed - `onBlur`: The input blur event handler; call it with the field id and value; - `onFocus`: The input focus event handler; call it with the field id and value; @@ -535,13 +535,13 @@ const { function MyObjectField(props: FieldProps) { const { onChange } = props; const onChangeHandler = useCallback( - (newFormData: T | undefined, es?: ErrorSchema, id?: string) => { + (newFormData: T | undefined, path: (number | string)[], es?: ErrorSchema, id?: string) => { let data = newFormData; let error = es; if (checkBadData(newFormData)) { // Format the `error` and fix the `data` here } - onChange(data, error, id); + onChange(data, path, error, id); }, [onChange], ); diff --git a/packages/docs/docs/migration-guides/v6.x upgrade guide.md b/packages/docs/docs/migration-guides/v6.x upgrade guide.md index a2b4592193..2491a396a6 100644 --- a/packages/docs/docs/migration-guides/v6.x upgrade guide.md +++ b/packages/docs/docs/migration-guides/v6.x upgrade guide.md @@ -67,6 +67,180 @@ React 18 is officially supported on all the themes. React 19 support is expected before the end of beta (although several developers have already upgraded with no problems). +### Fields BREAKING CHANGES + +### FieldProps.onChange + +The `onChange` handling for fields has been changed to fix a serious bug related to nearly simultaneous updates losing data. +Previously in 5.x, data change handling worked by passing a complete `newFormData` object up to the `Form` from the underlying `Field`s. +In 6.x, data change handling now works by passing just the changed `newValue` for a `Field` and the `path` array of the `Field` within the `formData`, with the `Form` itself being responsible for injecting the changed data into the `formData`. + +As a result, the `FieldProps` interface was updated with the following breaking change so that custom `Field` authors are forced to respond to this update: + +```typescript +// Version 5's `onChange` handler: + +/** The field change event handler; called with the updated form data and an optional `ErrorSchema` */ +onChange: (newFormData: T | undefined, es?: ErrorSchema, id?: string) => any; + +// Version 6's `onChange` handler: +/** The field change event handler; called with the updated field value, the optional change path for the value + * (defaults to an empty array), an optional ErrorSchema and the optional id of the field being changed + */ +onChange: (newValue: T | undefined, path?: (number | string)[], es?: ErrorSchema, id?: string) => void; +``` + +In order to support letting the `Form` know what the path of the change was, a new `path: (number | string)[]` parameter was injected into the handler before the `es?: ErrorSchema` parameter. +If you have written a custom `Field` that implements merging the new value into the `newFormData`, now you just need to pass that value and provide an empty `path` array to the `onChange` function. + +Here is an example of a custom `Field` that was updated due to this change: + +```tsx +import { FieldProps } from '@rjsf/utils'; +import { getDefaultRegistry } from '@rjsf/core'; + +const { ArrayField } = getDefaultRegistry().fields; + +// Version 5-based custom field +function CustomField(props: FieldProps) { + const { + idSchema: { $id }, + formData, + onChange, + } = props; + const changeHandlerFactory = (fieldName: string) => (event: any) => { + onChange({ ...formData, [fieldName]: event.target.value }); + }; + return ( + <> +

Location

+
+ + +
+
+ + +
+
+ + +
+ + ); +} + +// Version 6-based custom field +function CustomField(props: FieldProps) { + const { + idSchema: { $id }, + formData, + onChange, + } = props; + const changeHandlerFactory = (fieldName: string) => (event: any) => { + onChange(event.target.value, [fieldName]); + }; + return ( + <> +

Location

+
+ + +
+
+ + +
+
+ + +
+ + ); +} +``` + +The same change also applies to the `ErrorSchema` object being passed to the `Form`. +Therefore, if your custom `Field` also updated the `ErrorSchema` to add a new error, now you just need to pass that error as well. + +Here is an example of a custom `Field` that was updated due to this change: + +```tsx +import { FieldProps } from '@rjsf/utils'; +import { getDefaultRegistry } from '@rjsf/core'; + +const { StringField } = getDefaultRegistry().fields; + +// Version 5-based custom field +function StringFieldError(props: FieldProps) { + const onChange = (newFormData: any | undefined, es?: ErrorSchema, id?: string) => { + let raiseError = es; + if (newFormData === 'test') { + raiseError = { + ...es, + __errors: ['Value cannot be "test"'], + }; + } + props.onChange(newFormData, raiseError, id); + }; + return ; +} + +// Version 6-based custom field +function StringFieldError(props: FieldProps) { + const onChange = (newValue: any | undefined, path?: (number | string)[], es?: ErrorSchema, id?: string) => { + let raiseError = es; + if (newValue === 'test') { + raiseError = { + __errors: ['Value cannot be "test"'], + }; + } + props.onChange(newValue, path, raiseError, id); + }; + return ; +} +``` + ### Templates BREAKING CHANGES #### ArrayFieldTemplateItemType diff --git a/packages/mantine/src/widgets/DateTime/AltDateWidget.tsx b/packages/mantine/src/widgets/DateTime/AltDateWidget.tsx index 0c2e75e43c..e13e1e5b14 100644 --- a/packages/mantine/src/widgets/DateTime/AltDateWidget.tsx +++ b/packages/mantine/src/widgets/DateTime/AltDateWidget.tsx @@ -44,26 +44,27 @@ export default function AltDateWidget< } = props; const { translateString } = registry; - - const [lastValue, setLastValue] = useState(value); const [state, setState] = useState(parseDateString(value, showTime)); useEffect(() => { - const stateValue = toDateString(state, showTime); - if (lastValue !== value) { - // We got a new value in the props - setLastValue(value); - setState(parseDateString(value, showTime)); - } else if (readyForChange(state) && stateValue !== value) { - // Selected date is ready to be submitted - onChange(stateValue); - setLastValue(stateValue); - } - }, [showTime, value, onChange, state, lastValue]); + setState(parseDateString(value, showTime)); + }, [showTime, value]); - const handleChange = useCallback((property: keyof DateObject, nextValue: any) => { - setState((prev) => ({ ...prev, [property]: typeof nextValue === 'undefined' ? -1 : nextValue })); - }, []); + const handleChange = useCallback( + (property: keyof DateObject, nextValue: any) => { + const nextState = { + ...state, + [property]: typeof nextValue === 'undefined' ? -1 : nextValue, + }; + + if (readyForChange(nextState)) { + onChange(toDateString(nextState, showTime)); + } else { + setState(nextState); + } + }, + [state, onChange, showTime], + ); const handleSetNow = useCallback(() => { if (!disabled && !readonly) { diff --git a/packages/playground/src/components/Header.tsx b/packages/playground/src/components/Header.tsx index e6ab277cd1..6599a2f1d2 100644 --- a/packages/playground/src/components/Header.tsx +++ b/packages/playground/src/components/Header.tsx @@ -331,7 +331,7 @@ export default function Header({ setShareURL(null); console.error(error); } - }, [formData, liveSettings, schema, theme, uiSchema, validator, setShareURL]); + }, [formData, liveSettings, schema, theme, uiSchema, validator, setShareURL, sampleName]); return (
diff --git a/packages/playground/src/components/Playground.tsx b/packages/playground/src/components/Playground.tsx index d258a42996..47913cf2b4 100644 --- a/packages/playground/src/components/Playground.tsx +++ b/packages/playground/src/components/Playground.tsx @@ -22,7 +22,7 @@ export interface PlaygroundProps { export default function Playground({ themes, validators }: PlaygroundProps) { const [loaded, setLoaded] = useState(false); const [schema, setSchema] = useState(samples.Simple.schema as RJSFSchema); - const [uiSchema, setUiSchema] = useState(samples.Simple.uiSchema!); + const [uiSchema, setUiSchema] = useState(samples.Simple.uiSchema as UiSchema); // Store the generator inside of an object, otherwise react treats it as an initializer function const [uiSchemaGenerator, setUiSchemaGenerator] = useState<{ generator: UiSchemaForTheme } | undefined>(undefined); const [formData, setFormData] = useState(samples.Simple.formData); @@ -60,7 +60,7 @@ export default function Playground({ themes, validators }: PlaygroundProps) { setUiSchema(uiSchemaGenerator.generator(theme)); } }, - [uiSchemaGenerator, setTheme, setSubtheme, setFormComponent, setStylesheet], + [uiSchemaGenerator, setTheme, setFormComponent, setStylesheet], ); const load = useCallback( @@ -123,7 +123,7 @@ export default function Playground({ themes, validators }: PlaygroundProps) { (sampleName: string) => { load({ ...samples[sampleName], sampleName, liveSettings, theme }); }, - [load, liveSettings, theme, samples], + [load, liveSettings, theme], ); useEffect(() => { diff --git a/packages/playground/src/samples/customArray.tsx b/packages/playground/src/samples/customArray.tsx index ca4297c630..dd4f4de0d8 100644 --- a/packages/playground/src/samples/customArray.tsx +++ b/packages/playground/src/samples/customArray.tsx @@ -9,13 +9,13 @@ function ArrayFieldTemplate(props: ArrayFieldTemplateProps) { items.map((element) => (
{element.children}
- {element.hasMoveDown && ( - + {element.buttonsProps.hasMoveDown && ( + )} - {element.hasMoveUp && ( - + {element.buttonsProps.hasMoveUp && ( + )} - +
))} diff --git a/packages/playground/src/samples/customFieldAnyOf.tsx b/packages/playground/src/samples/customFieldAnyOf.tsx index 42f4e07a3c..9304821bce 100644 --- a/packages/playground/src/samples/customFieldAnyOf.tsx +++ b/packages/playground/src/samples/customFieldAnyOf.tsx @@ -1,15 +1,45 @@ +import { FieldProps, FieldTemplateProps, ID_KEY, IdSchema, RJSFSchema, getTemplate } from '@rjsf/utils'; +import noop from 'lodash/noop'; + import { Sample } from './Sample'; -import { FieldProps } from '@rjsf/utils'; function UiField(props: FieldProps) { - const { - idSchema: { $id }, - formData, + const { idSchema, formData, onChange, registry, schema, uiSchema, ...otherProps } = props; + const { fields, schemaUtils } = registry; + const changeHandlerFactory = (fieldName: string) => (value: any) => { + onChange(value, [fieldName]); + }; + + const { StringField, NumberField } = fields; + const FieldTemplate = getTemplate('FieldTemplate', registry); + const schema1 = (schema.anyOf?.[0] || {}) as RJSFSchema; + const schema2 = (schema.anyOf?.[1] || {}) as RJSFSchema; + const cityLabel = 'City'; + const latLabel = 'Latitude'; + const lonLabel = 'Longitude'; + const cityKey = 'city'; + const latKey = 'lat'; + const lonKey = 'lon'; + const citySchema = schemaUtils.findFieldInSchema(schema1, cityKey, {} as RJSFSchema); + const latSchema = schemaUtils.findFieldInSchema(schema2, latKey, {} as RJSFSchema); + const lonSchema = schemaUtils.findFieldInSchema(schema2, lonKey, {} as RJSFSchema); + const cityIdSchema: IdSchema = { [ID_KEY]: cityKey }; + const latIdSchema: IdSchema = { [ID_KEY]: latKey }; + const lonIdSchema: IdSchema = { [ID_KEY]: lonKey }; + + const fieldTemplateProps: Omit = { + registry, + schema, + uiSchema, + formContext: props.formContext, + displayLabel: true, + disabled: false, + readonly: false, onChange, - } = props; - const changeHandlerFactory = (fieldName: string) => (event: any) => { - onChange(formData ? { ...formData, [fieldName]: event.target.value } : { [fieldName]: event.target.value }); + onKeyChange: () => noop, + onDropPropertyClick: () => noop, }; + return ( <>

Location

@@ -22,20 +52,18 @@ function UiField(props: FieldProps) { margin: '1rem', }} > -
- - + -
+
-
- - + -
-
- - + + -
+
@@ -105,6 +133,7 @@ const customFieldAnyOf: Sample = { }, uiSchema: { 'ui:field': UiField, + 'ui:fieldReplacesAnyOrOneOf': true, }, formData: {}, }; diff --git a/packages/utils/src/types.ts b/packages/utils/src/types.ts index 81bbef25be..3f5806b35b 100644 --- a/packages/utils/src/types.ts +++ b/packages/utils/src/types.ts @@ -440,8 +440,10 @@ export interface FieldProps; - /** The field change event handler; called with the updated form data and an optional `ErrorSchema` */ - onChange: (newFormData: T | undefined, es?: ErrorSchema, id?: string) => any; + /** The field change event handler; called with the updated field value, the optional change path for the value + * (defaults to an empty array), an optional ErrorSchema and the optional id of the field being changed + */ + onChange: (newValue: T | undefined, path?: (number | string)[], es?: ErrorSchema, id?: string) => void; /** The input blur event handler; call it with the field id and value */ onBlur: (id: string, value: any) => void; /** The input focus event handler; call it with the field id and value */ @@ -799,7 +801,7 @@ export interface MultiSchemaFieldTemplateProps< export interface WidgetProps extends GenericObjectType, RJSFBaseProps, - Pick, Exclude, 'onBlur' | 'onFocus'>> { + Pick, Exclude, 'onBlur' | 'onFocus' | 'onChange'>> { /** The generated id for this widget, used to provide unique `name`s and `id`s for the HTML field elements rendered by * widgets */