diff --git a/CHANGELOG.md b/CHANGELOG.md index 979b70d83c..48a4174725 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,14 @@ should change the heading of the (upcoming) version to include a major version b - Updated `ArrayField` and `ObjectField` to check whether it `shouldRenderOptionalData()` and if true, calls `ObjectDataControlsField` and passes the result to its associated render template as `optionalDataControl` - Updated `ArrayFieldTemplate`, `ObjectFieldTemplate`, `TitleField` to add support for the new `optionalDataControl` feature - Added the new `OptionalDataControlTemplate` to the theme, adding it to the `templates` list +- Updated `Form` as follows to fix [#4796](https://github.com/rjsf-team/react-jsonschema-form/issues/4796) + - Refactored the `liveValidate()` and `mergeErrors()` functions out of `getStateFromProp()` and `processPendingChange()` + - Added new, optional `customErrors?: ErrorSchemaBuilder` to the `FormState`, updating the `IChangeEvent` interface to remove all of the private variables + - Reworked the `newErrorSchema` handling in `processPendingChange()` to simplify the handling since `newErrorSchema` is now path-specific, adding `newErrorSchema` to `customErrors` when they don't match an existing validator-based validation + - This rework resulted in any custom errors passed from custom widgets/fields will now be remembered during the validation stage + - Removed the now unused `getPreviousCustomValidateErrors()` and `filterErrorsBasedOnSchema()` methods +- Updated `LayoutGridField` to simplify `onFieldChange()` to just return the given `errorSchema` now that it is path-specific, fixing [#4796](https://github.com/rjsf-team/react-jsonschema-form/issues/4796) +- Updated `NullField` to pass `fieldPathId.path` for the `onChange()` instead of `[name]` ## @rjsf/daisyui @@ -99,16 +107,18 @@ should change the heading of the (upcoming) version to include a major version b - Updated `getDefaultFormState` to fix an issue where optional array props had their default set to an empty array when they shouldn't be - Updated the `TranslatableString` enum to add three new strings in support of the new feature: `OptionalObjectAdd`, `OptionalObjectRemove` and `OptionalObjectEmptyMsg` - Added four new utility functions: `isFormDataAvailable()`, `isRootSchema()`, `optionalControlsId()`, and `shouldRenderOptionalField()` +- Updated `validationDataMerge()` to add an additional, optional parameter `preventDuplicates = false`, that causes the `mergeObjects()` call to receive `preventDuplicates` instead of `true` ## Dev / docs / playground - Updated docs for `getDefaultFormState` to reflect addition of the `initialDefaultsGenerated` prop -- Updated `utility-function.me` docs to add documentation for the new functions +- Updated `utility-function.me` docs to add documentation for the new functions and to update the `validationDataMerge()` function's new parameter - Also updated docs for `retrieveSchema` and `SchemaUtilsType` for the new prop - Updated `uiSchema.md` to add documentation for the new `enableOptionalDataFieldForType` prop -- Updated the `v6x upgrade guide.md` to document the new feature and utility functions and changes to `retrieveSchema` - Updated the playground to add a new `Optional Data Controls` example - Updated the snapshot and jest tests for `Form` to test the new `Optional Data Controls` feature +- Updated `custom-widgets-fields.md` to change the documentation around passing errors via `onChange()` to reflect the new reality +- Updated the `v6x upgrade guide.md` to document the new feature, utility functions and changes to existing method parameters # 6.0.0-beta-20 diff --git a/packages/core/src/components/Form.tsx b/packages/core/src/components/Form.tsx index 037493f951..5242934eaa 100644 --- a/packages/core/src/components/Form.tsx +++ b/packages/core/src/components/Form.tsx @@ -3,8 +3,8 @@ import { createSchemaUtils, CustomValidator, deepEquals, - ERRORS_KEY, ErrorSchema, + ErrorSchemaBuilder, ErrorTransformer, FieldPathId, FieldPathList, @@ -38,17 +38,14 @@ import { ValidatorType, Experimental_DefaultFormStateBehavior, Experimental_CustomMergeAllOf, - createErrorHandler, - unwrapErrorHandler, DEFAULT_ID_SEPARATOR, DEFAULT_ID_PREFIX, GlobalFormOptions, + ERRORS_KEY, } 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'; @@ -259,13 +256,15 @@ export interface FormState; + // Private /** The current list of errors for the form directly from schema validation, does NOT include `extraErrors` */ schemaValidationErrors: RJSFValidationError[]; /** The current errors, in `ErrorSchema` format, for the form directly from schema validation, does NOT include * `extraErrors` */ schemaValidationErrorSchema: ErrorSchema; - // Private + /** A container used to handle custom errors provided via `onChange` */ + customErrors?: ErrorSchemaBuilder; /** @description result of schemaUtils.retrieveSchema(schema, formData). This a memoized value to avoid re calculate at internal functions (getStateFromProps, onChange) */ retrievedSchema: S; /** Flag indicating whether the initial form defaults have been generated */ @@ -276,7 +275,14 @@ export interface FormState - extends Omit, 'schemaValidationErrors' | 'schemaValidationErrorSchema'> { + extends Omit< + FormState, + | 'schemaValidationErrors' + | 'schemaValidationErrorSchema' + | 'retrievedSchema' + | 'customErrors' + | 'initialDefaultsGenerated' + > { /** The status of the form when submitted */ status?: 'submitted'; } @@ -499,21 +505,22 @@ export default class Form< let schemaValidationErrorSchema: ErrorSchema = state.schemaValidationErrorSchema; // If we are skipping live validate, it means that the state has already been updated with live validation errors if (mustValidate && !skipLiveValidate) { - const schemaValidation = this.validate(formData, rootSchema, schemaUtils, _retrievedSchema); - errors = schemaValidation.errors; - // If retrievedSchema is undefined which means the schema or formData has changed, we do not merge state. - // Else in the case where it hasn't changed, we merge 'state.errorSchema' with 'schemaValidation.errorSchema.' This done to display the raised field error. - if (retrievedSchema === undefined) { - errorSchema = schemaValidation.errorSchema; - } else { - errorSchema = mergeObjects( - this.state?.errorSchema, - schemaValidation.errorSchema, - 'preventDuplicates', - ) as ErrorSchema; - } - schemaValidationErrors = errors; - schemaValidationErrorSchema = errorSchema; + const liveValidation = this.liveValidate( + rootSchema, + schemaUtils, + state.errorSchema, + formData, + undefined, + state.customErrors, + retrievedSchema, + // If retrievedSchema is undefined which means the schema or formData has changed, we do not merge state. + // Else in the case where it hasn't changed, + retrievedSchema !== undefined, + ); + errors = liveValidation.errors; + errorSchema = liveValidation.errorSchema; + schemaValidationErrors = liveValidation.schemaValidationErrors; + schemaValidationErrorSchema = liveValidation.schemaValidationErrorSchema; } else { const currentErrors = getCurrentErrors(); errors = currentErrors.errors; @@ -533,13 +540,11 @@ export default class Form< 'preventDuplicates', ) as ErrorSchema; } + const mergedErrors = this.mergeErrors({ errorSchema, errors }, props.extraErrors, state.customErrors); + errors = mergedErrors.errors; + errorSchema = mergedErrors.errorSchema; } - if (props.extraErrors) { - const merged = validationDataMerge({ errorSchema, errors }, props.extraErrors); - errorSchema = merged.errorSchema; - errors = merged.errors; - } const fieldPathId = toFieldPathId('', this.getGlobalFormOptions(this.props)); const nextState: FormState = { schemaUtils, @@ -568,20 +573,6 @@ export default class Form< const { experimental_componentUpdateStrategy = 'customDeep' } = this.props; return shouldRender(this, nextProps, nextState, experimental_componentUpdateStrategy); } - /** Gets the previously raised customValidate errors. - * - * @returns the previous customValidate errors - */ - private getPreviousCustomValidateErrors(): ErrorSchema { - const { customValidate, uiSchema } = this.props; - const prevFormData = this.state.formData as T; - let customValidateErrors = {}; - if (typeof customValidate === 'function') { - const errorHandler = customValidate(prevFormData, createErrorHandler(prevFormData), uiSchema); - customValidateErrors = unwrapErrorHandler(errorHandler); - } - return customValidateErrors; - } /** Validates the `formData` against the `schema` using the `altSchemaUtils` (if provided otherwise it uses the * `schemaUtils` in the state), returning the results. @@ -625,6 +616,75 @@ export default class Form< return null; } + /** Merges any `extraErrors` or `customErrors` into the given `schemaValidation` object, returning the result + * + * @param schemaValidation - The `ValidationData` object into which additional errors are merged + * @param [extraErrors] - The extra errors from the props + * @param [customErrors] - The customErrors from custom components + * @return - The `extraErrors` and `customErrors` merged into the `schemaValidation` + * @private + */ + private mergeErrors( + schemaValidation: ValidationData, + extraErrors?: FormProps['extraErrors'], + customErrors?: ErrorSchemaBuilder, + ): ValidationData { + let errorSchema: ErrorSchema = schemaValidation.errorSchema; + let errors: RJSFValidationError[] = schemaValidation.errors; + if (extraErrors) { + const merged = validationDataMerge(schemaValidation, extraErrors); + errorSchema = merged.errorSchema; + errors = merged.errors; + } + if (customErrors) { + const merged = validationDataMerge(schemaValidation, customErrors.ErrorSchema, true); + errorSchema = merged.errorSchema; + errors = merged.errors; + } + return { errors, errorSchema }; + } + + /** Performs live validation and then updates and returns the errors and error schemas by potentially merging in + * `extraErrors` and `customErrors`. + * + * @param rootSchema - The `rootSchema` from the state + * @param schemaUtils - The `SchemaUtilsType` from the state + * @param originalErrorSchema - The original `ErrorSchema` from the state + * @param [formData] - The new form data to validate + * @param [extraErrors] - The extra errors from the props + * @param [customErrors] - The customErrors from custom components + * @param [retrievedSchema] - An expanded schema, if not provided, it will be retrieved from the `schema` and `formData` + * @param [mergeIntoOriginalErrorSchema=false] - Optional flag indicating whether we merge into original schema + * @returns - An object containing `errorSchema`, `errors`, `schemaValidationErrors` and `schemaValidationErrorSchema` + * @private + */ + private liveValidate( + rootSchema: S, + schemaUtils: SchemaUtilsType, + originalErrorSchema: ErrorSchema, + formData?: T, + extraErrors?: FormProps['extraErrors'], + customErrors?: ErrorSchemaBuilder, + retrievedSchema?: S, + mergeIntoOriginalErrorSchema = false, + ) { + const schemaValidation = this.validate(formData, rootSchema, schemaUtils, retrievedSchema); + const errors = schemaValidation.errors; + let errorSchema = schemaValidation.errorSchema; + // We merge 'originalErrorSchema' with 'schemaValidation.errorSchema.'; This done to display the raised field error. + if (mergeIntoOriginalErrorSchema) { + errorSchema = mergeObjects( + originalErrorSchema, + schemaValidation.errorSchema, + 'preventDuplicates', + ) as ErrorSchema; + } + const schemaValidationErrors = errors; + const schemaValidationErrorSchema = errorSchema; + const mergedErrors = this.mergeErrors({ errorSchema, errors }, extraErrors, customErrors); + return { ...mergedErrors, schemaValidationErrors, schemaValidationErrorSchema }; + } + /** Returns the `formData` with only the elements specified in the `fields` list * * @param formData - The data for the `Form` @@ -698,63 +758,6 @@ export default class Form< return this.getUsedFormData(formData, fieldNames); }; - /** 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; - const pathSchema = schemaUtils.toPathSchema(_retrievedSchema, '', formData); - const fieldNames = this.getFieldNames(pathSchema, formData); - const filteredErrors: ErrorSchema = _pick(schemaErrors, fieldNames as unknown as string[]); - // If the root schema is of a primitive type, do not filter out the __errors - if (resolvedSchema?.type !== 'object' && resolvedSchema?.type !== 'array') { - filteredErrors[ERRORS_KEY] = schemaErrors[ERRORS_KEY]; - } - - const prevCustomValidateErrors = this.getPreviousCustomValidateErrors(); - // Filtering out the previous raised customValidate errors so that they are cleared when no longer valid. - const filterPreviousCustomErrors = (errors: string[] = [], prevCustomErrors: string[]) => { - if (errors.length === 0) { - return errors; - } - - return errors.filter((error) => { - return !prevCustomErrors.includes(error); - }); - }; - - // Removing undefined, null and empty errors. - const filterNilOrEmptyErrors = (errors: any, previousCustomValidateErrors: any = {}): ErrorSchema => { - _forEach(errors, (errorAtKey: ErrorSchema['__errors'] | undefined, errorKey: keyof typeof errors) => { - const prevCustomValidateErrorAtKey: ErrorSchema | undefined = previousCustomValidateErrors[errorKey]; - if (_isNil(errorAtKey) || (Array.isArray(errorAtKey) && errorAtKey.length === 0)) { - delete errors[errorKey]; - } else if ( - isObject(errorAtKey) && - isObject(prevCustomValidateErrorAtKey) && - Array.isArray(prevCustomValidateErrorAtKey?.[ERRORS_KEY]) - ) { - // if previous customValidate error is an object and has __errors array, filter out the errors previous customValidate errors. - errors[errorKey] = { - [ERRORS_KEY]: filterPreviousCustomErrors( - errorAtKey[ERRORS_KEY], - prevCustomValidateErrorAtKey?.[ERRORS_KEY], - ), - }; - } else if (typeof errorAtKey === 'object' && !Array.isArray(errorAtKey[ERRORS_KEY])) { - filterNilOrEmptyErrors(errorAtKey, previousCustomValidateErrors[errorKey]); - } - }); - return errors; - }; - return filterNilOrEmptyErrors(filteredErrors, prevCustomValidateErrors); - } - /** Pushes the given change information into the `pendingChanges` array and then calls `processPendingChanges()` if * the array only contains a single pending change. * @@ -784,9 +787,10 @@ export default class Form< return; } const { newValue, path, id } = this.pendingChanges[0]; - let { newErrorSchema } = this.pendingChanges[0]; + const { newErrorSchema } = this.pendingChanges[0]; const { extraErrors, omitExtraData, liveOmit, noValidate, liveValidate, onChange } = this.props; - const { formData: oldFormData, schemaUtils, schema, errorSchema, fieldPathId } = this.state; + const { formData: oldFormData, schemaUtils, schema, fieldPathId, schemaValidationErrorSchema, errors } = this.state; + let { customErrors, errorSchema: originalErrorSchema } = this.state; const rootPathId = fieldPathId.path[0] || ''; const isRootPath = !path || path.length === 0 || (path.length === 1 && path[0] === rootPathId); @@ -814,46 +818,51 @@ 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 (newErrorSchema) { + // First check to see if there is an existing validation error on this path... + // @ts-expect-error TS2590, because getting from the error schema is confusing TS + const oldValidationError = !isRootPath ? _get(schemaValidationErrorSchema, path) : schemaValidationErrorSchema; + // If there is an old validation error for this path, assume we are updating it directly + if (!_isEmpty(oldValidationError)) { + // Update the originalErrorSchema "in place" or replace it if it is the root + if (!isRootPath) { + _set(originalErrorSchema, path, newErrorSchema); + } else { + originalErrorSchema = newErrorSchema; + } + } else { + if (!customErrors) { + customErrors = new ErrorSchemaBuilder(); + } + if (isRootPath) { + customErrors.setErrors(_get(newErrorSchema, ERRORS_KEY, '')); + } else { + _set(customErrors.ErrorSchema, path, newErrorSchema); + } + } + } else if (customErrors && _get(customErrors.ErrorSchema, [...path, ERRORS_KEY])) { + // If we have custom errors and the path has an error, then we need to clear it + customErrors.clearErrors(path); } // If there are pending changes in the queue, skip live validation since it will happen with the last change if (mustValidate && this.pendingChanges.length === 1) { - const schemaValidation = this.validate(newFormData, schema, schemaUtils, retrievedSchema); - let errors = schemaValidation.errors; - let errorSchema = schemaValidation.errorSchema; - const schemaValidationErrors = errors; - const schemaValidationErrorSchema = errorSchema; - if (extraErrors) { - const merged = validationDataMerge(schemaValidation, extraErrors); - errorSchema = merged.errorSchema; - errors = merged.errors; - } - // Merging 'newErrorSchema' into 'errorSchema' to display the custom raised errors. - if (newErrorSchema) { - const filteredErrors = this.filterErrorsBasedOnSchema(newErrorSchema, retrievedSchema, newFormData); - errorSchema = mergeObjects(errorSchema, filteredErrors, 'preventDuplicates') as ErrorSchema; - } - state = { - formData: newFormData, - errors, - errorSchema, - schemaValidationErrors, - schemaValidationErrorSchema, - }; + const liveValidation = this.liveValidate( + schema, + schemaUtils, + originalErrorSchema, + newFormData, + extraErrors, + customErrors, + retrievedSchema, + ); + state = { formData: newFormData, ...liveValidation, customErrors }; } else if (!noValidate && newErrorSchema) { // Merging 'newErrorSchema' into 'errorSchema' to display the custom raised errors. - const errorSchema = extraErrors - ? (mergeObjects(newErrorSchema, extraErrors, 'preventDuplicates') as ErrorSchema) - : newErrorSchema; + const mergedErrors = this.mergeErrors({ errorSchema: originalErrorSchema, errors }, extraErrors, customErrors); state = { formData: newFormData, - errorSchema: errorSchema, - errors: toErrorList(errorSchema), + ...mergedErrors, + customErrors, }; } this.setState(state as FormState, () => { @@ -897,6 +906,7 @@ export default class Form< schemaValidationErrors: [] as unknown, schemaValidationErrorSchema: {}, initialDefaultsGenerated: false, + customErrors: undefined, } as FormState; this.setState(state, () => onChange && onChange({ ...this.state, ...state })); diff --git a/packages/core/src/components/fields/LayoutGridField.tsx b/packages/core/src/components/fields/LayoutGridField.tsx index 244460e61f..86c815f54b 100644 --- a/packages/core/src/components/fields/LayoutGridField.tsx +++ b/packages/core/src/components/fields/LayoutGridField.tsx @@ -26,7 +26,6 @@ import { UiSchema, ITEMS_KEY, } from '@rjsf/utils'; -import cloneDeep from 'lodash/cloneDeep'; import each from 'lodash/each'; import flatten from 'lodash/flatten'; import get from 'lodash/get'; @@ -705,13 +704,8 @@ export default class LayoutGridField< */ onFieldChange = (dottedPath: string) => { return (value: T | undefined, path: FieldPathList, errSchema?: ErrorSchema, id?: string) => { - const { onChange, errorSchema } = this.props; - let newErrorSchema = errorSchema; - if (errSchema && errorSchema) { - newErrorSchema = cloneDeep(errorSchema); - set(newErrorSchema, dottedPath, errSchema); - } - onChange(value, path, newErrorSchema, id); + const { onChange } = this.props; + onChange(value, path, errSchema, id); }; }; diff --git a/packages/core/src/components/fields/NullField.tsx b/packages/core/src/components/fields/NullField.tsx index 3cb335fcc4..055791e5e5 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 { name, formData, onChange } = props; + const { formData, onChange, fieldPathId } = props; useEffect(() => { if (formData === undefined) { - onChange(null as unknown as T, [name]); + onChange(null as unknown as T, fieldPathId.path); } - }, [name, formData, onChange]); + }, [fieldPathId, formData, onChange]); return null; } diff --git a/packages/core/test/ArrayField.test.jsx b/packages/core/test/ArrayField.test.jsx index ee3f178d9c..0acdf04f9e 100644 --- a/packages/core/test/ArrayField.test.jsx +++ b/packages/core/test/ArrayField.test.jsx @@ -3337,6 +3337,38 @@ describe('ArrayField', () => { expect(errorMessages).to.have.length(0); }); + it('should clear an error if value is entered correctly', () => { + const { node } = createFormComponent({ + schema, + formData: [ + { + text: 'y', + }, + ], + templates, + fields: { + ArrayField: ArrayFieldTest, + }, + }); + + const inputs = node.querySelectorAll('.rjsf-field-string input[type=text]'); + act(() => { + fireEvent.change(inputs[0], { target: { value: 'test' } }); + }); + + let errorMessages = node.querySelectorAll('#root_0_text__error'); + expect(errorMessages).to.have.length(1); + const errorMessageContent = node.querySelector('#root_0_text__error .text-danger').textContent; + expect(errorMessageContent).to.contain('Value must be "Appie"'); + + act(() => { + fireEvent.change(inputs[0], { target: { value: 'Appie' } }); + }); + + errorMessages = node.querySelectorAll('#root_0_text__error'); + expect(errorMessages).to.have.length(0); + }); + it('raise an error and check if the error is displayed using custom text widget', () => { const { node } = createFormComponent({ schema, @@ -3384,6 +3416,38 @@ describe('ArrayField', () => { const errorMessages = node.querySelectorAll('#root_0_text__error'); expect(errorMessages).to.have.length(0); }); + + it('should clear an error if value is entered correctly using custom text widget', () => { + const { node } = createFormComponent({ + schema, + formData: [ + { + text: 'y', + }, + ], + templates, + widgets: { + TextWidget: TextWidgetTest, + }, + }); + + const inputs = node.querySelectorAll('.rjsf-field-string input[type=text]'); + act(() => { + fireEvent.change(inputs[0], { target: { value: 'hello' } }); + }); + + let errorMessages = node.querySelectorAll('#root_0_text__error'); + expect(errorMessages).to.have.length(1); + const errorMessageContent = node.querySelector('#root_0_text__error .text-danger').textContent; + expect(errorMessageContent).to.contain('Value must be "test"'); + + act(() => { + fireEvent.change(inputs[0], { target: { value: 'test' } }); + }); + + errorMessages = node.querySelectorAll('#root_0_text__error'); + expect(errorMessages).to.have.length(0); + }); }); describe('Dynamic uiSchema.items function', () => { diff --git a/packages/core/test/LayoutGridField.test.tsx b/packages/core/test/LayoutGridField.test.tsx index c73ec4b33a..e99bcf522e 100644 --- a/packages/core/test/LayoutGridField.test.tsx +++ b/packages/core/test/LayoutGridField.test.tsx @@ -1448,7 +1448,7 @@ describe('LayoutGridField', () => { expect(props.onFocus).toHaveBeenCalledWith(fieldId, ''); // Type to trigger the onChange await userEvent.type(input, 'foo'); - expect(props.onChange).toHaveBeenCalledWith('foo', fieldPathId.path, props.errorSchema, fieldId); + expect(props.onChange).toHaveBeenCalledWith('foo', fieldPathId.path, undefined, fieldId); // Tab out of the input field to cause the blur await userEvent.tab(); expect(props.onBlur).toHaveBeenCalledWith(fieldId, 'foo'); @@ -1474,7 +1474,7 @@ describe('LayoutGridField', () => { expect(props.onFocus).toHaveBeenCalledWith(fieldId, ''); // Type to trigger the onChange await userEvent.type(input, 'foo'); - expect(props.onChange).toHaveBeenCalledWith('foo', fieldPathId.path, props.errorSchema, fieldId); + expect(props.onChange).toHaveBeenCalledWith('foo', fieldPathId.path, undefined, fieldId); // Tab out of the input field to cause the blur await userEvent.tab(); expect(props.onBlur).toHaveBeenCalledWith(fieldId, 'foo'); @@ -1500,7 +1500,7 @@ describe('LayoutGridField', () => { expect(props.onFocus).toHaveBeenCalledWith(fieldId, ''); // Type to trigger the onChange await userEvent.type(input, 'foo'); - expect(props.onChange).toHaveBeenCalledWith('foo', fieldPathId.path, props.errorSchema, fieldId); + expect(props.onChange).toHaveBeenCalledWith('foo', fieldPathId.path, undefined, fieldId); // Tab out of the input field to cause the blur await userEvent.tab(); expect(props.onBlur).toHaveBeenCalledWith(fieldId, 'foo'); @@ -1654,8 +1654,7 @@ describe('LayoutGridField', () => { const input = within(fields[0]).getByRole('textbox'); expect(input).toHaveValue(props.formData[fieldName]); await userEvent.type(input, '!'); - const expectedErrors = new ErrorSchemaBuilder().addErrors(ERRORS, fieldName).ErrorSchema; - expect(props.onChange).toHaveBeenCalledWith('foo!', fieldPathId.path, expectedErrors, fieldId); + expect(props.onChange).toHaveBeenCalledWith('foo!', fieldPathId.path, EXTRA_ERROR, 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/ObjectField.test.jsx b/packages/core/test/ObjectField.test.jsx index 17e20c8e24..5c73b2ac0a 100644 --- a/packages/core/test/ObjectField.test.jsx +++ b/packages/core/test/ObjectField.test.jsx @@ -376,6 +376,32 @@ describe('ObjectField', () => { expect(errorMessages).to.have.length(0); }); + it('should clear an error if value is entered correctly', () => { + const { node } = createFormComponent({ + schema, + fields: { + ObjectField: ObjectFieldTest, + }, + }); + + const inputs = node.querySelectorAll('.rjsf-field-string input[type=text]'); + act(() => { + fireEvent.change(inputs[0], { target: { value: 'hello' } }); + }); + + let errorMessages = node.querySelectorAll('#root_foo__error'); + expect(errorMessages).to.have.length(1); + const errorMessageContent = node.querySelector('#root_foo__error .text-danger').textContent; + expect(errorMessageContent).to.contain('Value must be "test"'); + + act(() => { + fireEvent.change(inputs[0], { target: { value: 'test' } }); + }); + + errorMessages = node.querySelectorAll('#root_foo__error'); + expect(errorMessages).to.have.length(0); + }); + it('raise an error and check if the error is displayed using custom text widget', () => { const { node } = createFormComponent({ schema, @@ -411,6 +437,31 @@ describe('ObjectField', () => { const errorMessages = node.querySelectorAll('#root_foo__error'); expect(errorMessages).to.have.length(0); }); + + it('should clear an error if value is entered correctly using custom text widget', () => { + const { node } = createFormComponent({ + schema, + widgets: { + TextWidget: TextWidgetTest, + }, + }); + + const inputs = node.querySelectorAll('.rjsf-field-string input[type=text]'); + act(() => { + fireEvent.change(inputs[0], { target: { value: 'hello' } }); + }); + + let errorMessages = node.querySelectorAll('#root_foo__error'); + expect(errorMessages).to.have.length(1); + const errorMessageContent = node.querySelector('#root_foo__error .text-danger').textContent; + expect(errorMessageContent).to.contain('Value must be "test"'); + act(() => { + fireEvent.change(inputs[0], { target: { value: 'test' } }); + }); + + errorMessages = node.querySelectorAll('#root_foo__error'); + expect(errorMessages).to.have.length(0); + }); }); describe('fields ordering', () => { diff --git a/packages/core/test/StringField.test.jsx b/packages/core/test/StringField.test.jsx index 39f30632cd..43c0f6b7cb 100644 --- a/packages/core/test/StringField.test.jsx +++ b/packages/core/test/StringField.test.jsx @@ -351,6 +351,32 @@ describe('StringField', () => { expect(errorMessages).to.have.length(0); }); + it('should clear an error if value is entered correctly', () => { + const { node } = createFormComponent({ + schema: { type: 'string' }, + fields: { + StringField: StringFieldTest, + }, + }); + + const inputs = node.querySelectorAll('.rjsf-field-string input[type=text]'); + act(() => { + fireEvent.change(inputs[0], { target: { value: 'hello' } }); + }); + + let errorMessages = node.querySelectorAll('#root__error'); + expect(errorMessages).to.have.length(1); + const errorMessageContent = node.querySelector('#root__error .text-danger').textContent; + expect(errorMessageContent).to.contain('Value must be "test"'); + + act(() => { + fireEvent.change(inputs[0], { target: { value: 'test' } }); + }); + + errorMessages = node.querySelectorAll('#root__error'); + expect(errorMessages).to.have.length(0); + }); + it('raise an error and check if the error is displayed using custom text widget', () => { const { node } = createFormComponent({ schema: { type: 'string' }, diff --git a/packages/docs/docs/advanced-customization/custom-widgets-fields.md b/packages/docs/docs/advanced-customization/custom-widgets-fields.md index e8925f253e..c1f29f0a93 100644 --- a/packages/docs/docs/advanced-customization/custom-widgets-fields.md +++ b/packages/docs/docs/advanced-customization/custom-widgets-fields.md @@ -100,12 +100,11 @@ The default widgets you can override are: ## Raising errors from within a custom widget or field You can raise custom 'live validation' errors by overriding the `onChange` method to provide feedback while users are actively changing the form data. -Note that these errors are temporary and are not recognized during the form validation process. +If you do set errors this way, you must also clear them this way by passing `undefined` to the `onChange()` for the `errorSchema` parameter. :::warning -This method of raising errors _only_ runs during `onChange`, i.e. when the user is changing data. This will not catch errors `onSubmit`, i.e when submitting the form. -If you wish to add generic validation logic for your component, you should use the [`customValidate` Form prop](../api-reference/form-props.md#customvalidate). +While these errors are retained during validation, it is still preferred for you to use the [`customValidate` Form prop](../api-reference/form-props.md#customvalidate) mechanism instead. ::: diff --git a/packages/docs/docs/api-reference/utility-functions.md b/packages/docs/docs/api-reference/utility-functions.md index 3ea6a67b68..60775fa7fe 100644 --- a/packages/docs/docs/api-reference/utility-functions.md +++ b/packages/docs/docs/api-reference/utility-functions.md @@ -1055,6 +1055,7 @@ If no `additionalErrorSchema` is passed, then `validationData` is returned. - validationData: ValidationData<T> - The current `ValidationData` into which to merge the additional errors - [additionalErrorSchema]: ErrorSchema<T> | undefined - The optional additional set of errors in an `ErrorSchema` +- [preventDuplicates=false]: boolean - Optional flag, if true, will call `mergeObjects()` with `preventDuplicates` #### Returns 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 294fe11966..4dee5775cc 100644 --- a/packages/docs/docs/migration-guides/v6.x upgrade guide.md +++ b/packages/docs/docs/migration-guides/v6.x upgrade guide.md @@ -309,6 +309,7 @@ function CustomField(props: FieldProps) { 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. +Finally, the errors are preserved across validations, so if you want to clear an error you passed via `onChange`, you will have to pass `undefined` Here is an example of a custom `Field` that was updated due to this change: @@ -743,6 +744,7 @@ Three new validator-based utility functions are available in `@rjsf/utils`: - `getDefaultFormState()`: Added an optional `initialDefaultsGenerated` boolean flag that indicates whether or not initial defaults have been generated - `retrieveSchema()`: Added an optional `resolveAnyOfOrOneOfRefs` boolean flag that causes the internal `resolveAllSchemas()` to resolve `$ref`s inside of the options of `anyOf`/`oneOf` schemas - This new optional flag was added to the `SchemaUtilsType` interface's version of `retrieveSchema()` as well. +- `validationDataMerge()`: Added optional `preventDuplicates` boolean flag that causes the `mergeObjects()` call to receive `preventDuplicates` instead of `true` ### Optional Data Controls diff --git a/packages/utils/src/ErrorSchemaBuilder.ts b/packages/utils/src/ErrorSchemaBuilder.ts index a235bf215e..c72c48e1ef 100644 --- a/packages/utils/src/ErrorSchemaBuilder.ts +++ b/packages/utils/src/ErrorSchemaBuilder.ts @@ -3,13 +3,13 @@ import get from 'lodash/get'; import set from 'lodash/set'; import setWith from 'lodash/setWith'; -import { ErrorSchema } from './types'; +import { ErrorSchema, FieldPathList } from './types'; import { ERRORS_KEY } from './constants'; /** Represents the type of the path which can be a string of dotted path values or a list of string or numbers where * numbers represent array indexes/ */ -export type PathType = string | (string | number)[]; +export type PathType = string | FieldPathList; /** The `ErrorSchemaBuilder` is used to build an `ErrorSchema` since the definition of the `ErrorSchema` type is * designed for reading information rather than writing it. Use this class to add, replace or clear errors in an error diff --git a/packages/utils/src/validationDataMerge.ts b/packages/utils/src/validationDataMerge.ts index 145191fe36..0a080093c7 100644 --- a/packages/utils/src/validationDataMerge.ts +++ b/packages/utils/src/validationDataMerge.ts @@ -11,11 +11,13 @@ import { ErrorSchema, ValidationData } from './types'; * * @param validationData - The current `ValidationData` into which to merge the additional errors * @param [additionalErrorSchema] - The optional additional set of errors in an `ErrorSchema` + * @param [preventDuplicates=false] - Optional flag, if true, will call `mergeObjects()` with `preventDuplicates` * @returns - The `validationData` with the additional errors from `additionalErrorSchema` merged into it, if provided. */ export default function validationDataMerge( validationData: ValidationData, additionalErrorSchema?: ErrorSchema, + preventDuplicates = false, ): ValidationData { if (!additionalErrorSchema) { return validationData; @@ -24,7 +26,11 @@ export default function validationDataMerge( let errors = toErrorList(additionalErrorSchema); let errorSchema = additionalErrorSchema; if (!isEmpty(oldErrorSchema)) { - errorSchema = mergeObjects(oldErrorSchema, additionalErrorSchema, true) as ErrorSchema; + errorSchema = mergeObjects( + oldErrorSchema, + additionalErrorSchema, + preventDuplicates ? 'preventDuplicates' : true, + ) as ErrorSchema; errors = [...oldErrors].concat(errors); } return { errorSchema, errors }; diff --git a/packages/utils/test/validationDataMerge.test.ts b/packages/utils/test/validationDataMerge.test.ts index 5432f8d408..25252164db 100644 --- a/packages/utils/test/validationDataMerge.test.ts +++ b/packages/utils/test/validationDataMerge.test.ts @@ -37,4 +37,19 @@ describe('validationDataMerge()', () => { }; expect(validationDataMerge(validationData, errorSchema)).toEqual(expected); }); + it('Returns merged data when additionalErrorSchema is passed, prevent duplicates', () => { + const oldError = 'ajv error'; + const validationData: ValidationData = { + errorSchema: { [ERRORS_KEY]: [oldError] } as ErrorSchema, + errors: [{ stack: oldError, name: 'foo', schemaPath: '.foo' }], + }; + const errors = ['custom errors']; + const customErrors = [{ property: '.', message: errors[0], stack: `. ${errors[0]}` }]; + const errorSchema: ErrorSchema = { [ERRORS_KEY]: errors } as ErrorSchema; + const expected = { + errorSchema: { [ERRORS_KEY]: [oldError, ...errors] }, + errors: [...validationData.errors, ...customErrors], + }; + expect(validationDataMerge(validationData, errorSchema, true)).toEqual(expected); + }); });