From 4d3094c54cb70ff96a5d6a8b49114d7a9814d972 Mon Sep 17 00:00:00 2001 From: Appie Date: Sun, 4 Aug 2024 00:27:23 +0100 Subject: [PATCH] Feat: Allow raising errors from within a custom whatever(#2718) (#4188) * #2718 feature - raise errors from within fields * fixed failing tests * Fixed failing build * Removing raiseError message and errorSchema is updated now using the onChange. * reverting tests * Filtering errors based on your retrieved schema to only show errors for properties in the selected branch. * fixed issue with typing causing build failures. * Improvement based on feedback * improvement based on feedback and written test for custom widget * documenting the feature * docs improvement base on feedback * removed empty line * fixed lodash import * Update packages/core/src/components/Form.tsx Ordered lodash import * Update packages/core/src/components/Form.tsx * Update CHANGELOG.md Added missing packages * Update CHANGELOG.md Added missing space --------- Co-authored-by: Abdallah Al-Soqatri Co-authored-by: Abdallah Al-Soqatri Co-authored-by: Heath C <51679588+heath-freenome@users.noreply.github.com> --- CHANGELOG.md | 14 +++ packages/core/src/components/Form.tsx | 43 ++++++- packages/core/test/ArrayField.test.jsx | 118 ++++++++++++++++++ packages/core/test/ObjectField.test.jsx | 90 +++++++++++++ packages/core/test/StringField.test.jsx | 104 +++++++++++++++ .../custom-widgets-fields.md | 44 +++++++ packages/utils/src/types.ts | 2 +- 7 files changed, 413 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99e84c21d4..ad4c1c3f6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,20 @@ should change the heading of the (upcoming) version to include a major version b --> +# 5.20.0 + +## @rjsf/core + +- Support allowing raising errors from within a custom Widget [#2718](https://github.com/rjsf-team/react-jsonschema-form/issues/2718) + +## @rjsf/utils + +- Updated the `WidgetProps` type to add `es?: ErrorSchema, id?: string` to the params of the `onChange` handler function + +## Dev / docs / playground + +- Update the `custom-widget-fields.md` to add documentation for how to raise errors from a custom widget or field + # 5.19.4 ## @rjsf/core diff --git a/packages/core/src/components/Form.tsx b/packages/core/src/components/Form.tsx index 951887cf28..48851cfcf3 100644 --- a/packages/core/src/components/Form.tsx +++ b/packages/core/src/components/Form.tsx @@ -34,6 +34,7 @@ import { ValidatorType, Experimental_DefaultFormStateBehavior, } from '@rjsf/utils'; +import _forEach from 'lodash/forEach'; import _get from 'lodash/get'; import _isEmpty from 'lodash/isEmpty'; import _pick from 'lodash/pick'; @@ -421,7 +422,17 @@ export default class Form< if (mustValidate) { const schemaValidation = this.validate(formData, schema, schemaUtils, _retrievedSchema); errors = schemaValidation.errors; - errorSchema = schemaValidation.errorSchema; + // If the schema has changed, we do not merge state.errorSchema. + // 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 (isSchemaChanged) { + errorSchema = schemaValidation.errorSchema; + } else { + errorSchema = mergeObjects( + this.state?.errorSchema, + schemaValidation.errorSchema, + 'preventDuplicates' + ) as ErrorSchema; + } schemaValidationErrors = errors; schemaValidationErrorSchema = errorSchema; } else { @@ -581,6 +592,31 @@ export default class Form< return newFormData; }; + // Filtering errors based on your retrieved schema to only show errors for properties in the selected branch. + 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 = schemaErrors.__errors; + } + // Removing undefined and empty errors. + const filterUndefinedErrors = (errors: any): ErrorSchema => { + _forEach(errors, (errorAtKey, errorKey: keyof typeof errors) => { + if (errorAtKey === undefined) { + delete errors[errorKey]; + } else if (typeof errorAtKey === 'object' && !Array.isArray(errorAtKey.__errors)) { + filterUndefinedErrors(errorAtKey); + } + }); + return errors; + }; + return filterUndefinedErrors(filteredErrors); + } + /** 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 @@ -624,6 +660,11 @@ export default class Form< 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, diff --git a/packages/core/test/ArrayField.test.jsx b/packages/core/test/ArrayField.test.jsx index 5e36a22ed3..c80a65ee32 100644 --- a/packages/core/test/ArrayField.test.jsx +++ b/packages/core/test/ArrayField.test.jsx @@ -5,6 +5,8 @@ import sinon from 'sinon'; import { createFormComponent, createSandbox, submitForm } from './test_utils'; import SchemaField from '../src/components/fields/SchemaField'; +import ArrayField from '../src/components/fields/ArrayField'; +import { TextWidgetTest } from './StringField.test'; const ArrayKeyDataAttr = 'data-rjsf-itemkey'; const ExposedArrayKeyTemplate = function (props) { @@ -157,6 +159,26 @@ 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); + } + } + }; + return ; +}; + describe('ArrayField', () => { let sandbox; const CustomComponent = (props) => { @@ -3196,5 +3218,101 @@ describe('ArrayField', () => { }, }); }); + + it('raise an error and check if the error is displayed', () => { + const { node } = createFormComponent({ + schema, + formData: [ + { + text: 'y', + }, + ], + templates, + fields: { + ArrayField: ArrayFieldTest, + }, + }); + + const inputs = node.querySelectorAll('.field-string input[type=text]'); + act(() => { + fireEvent.change(inputs[0], { target: { value: 'test' } }); + }); + + const 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"'); + }); + + it('should not raise an error if value is correct', () => { + const { node } = createFormComponent({ + schema, + formData: [ + { + text: 'y', + }, + ], + templates, + fields: { + ArrayField: ArrayFieldTest, + }, + }); + + const inputs = node.querySelectorAll('.field-string input[type=text]'); + act(() => { + fireEvent.change(inputs[0], { target: { value: 'Appie' } }); + }); + + const 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, + formData: [ + { + text: 'y', + }, + ], + templates, + widgets: { + TextWidget: TextWidgetTest, + }, + }); + + const inputs = node.querySelectorAll('.field-string input[type=text]'); + act(() => { + fireEvent.change(inputs[0], { target: { value: 'hello' } }); + }); + + const 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"'); + }); + + it('should not raise an error if value is correct using custom text widget', () => { + const { node } = createFormComponent({ + schema, + formData: [ + { + text: 'y', + }, + ], + templates, + widgets: { + TextWidget: TextWidgetTest, + }, + }); + + const inputs = node.querySelectorAll('.field-string input[type=text]'); + act(() => { + fireEvent.change(inputs[0], { target: { value: 'test' } }); + }); + + const errorMessages = node.querySelectorAll('#root_0_text__error'); + expect(errorMessages).to.have.length(0); + }); }); }); diff --git a/packages/core/test/ObjectField.test.jsx b/packages/core/test/ObjectField.test.jsx index 1f0c2d2cf7..89a83d9b63 100644 --- a/packages/core/test/ObjectField.test.jsx +++ b/packages/core/test/ObjectField.test.jsx @@ -5,8 +5,26 @@ import sinon from 'sinon'; import { UI_GLOBAL_OPTIONS_KEY } from '@rjsf/utils'; import SchemaField from '../src/components/fields/SchemaField'; +import ObjectField from '../src/components/fields/ObjectField'; +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 = { + ...errorSchema, + foo: { + __errors: ['Value must be "test"'], + }, + }; + props.onChange(newFormData, raiseError, id); + } + }; + return ; +}; + describe('ObjectField', () => { let sandbox; @@ -208,6 +226,78 @@ describe('ObjectField', () => { expect(node.querySelector(`code#${formContext[key]}`)).to.exist; }); }); + + it('raise an error and check if the error is displayed', () => { + const { node } = createFormComponent({ + schema, + fields: { + ObjectField: ObjectFieldTest, + }, + }); + + const inputs = node.querySelectorAll('.field-string input[type=text]'); + act(() => { + fireEvent.change(inputs[0], { target: { value: 'hello' } }); + }); + + const 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"'); + }); + + it('should not raise an error if value is correct', () => { + const { node } = createFormComponent({ + schema, + fields: { + ObjectField: ObjectFieldTest, + }, + }); + + const inputs = node.querySelectorAll('.field-string input[type=text]'); + act(() => { + fireEvent.change(inputs[0], { target: { value: 'test' } }); + }); + + const 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, + widgets: { + TextWidget: TextWidgetTest, + }, + }); + + const inputs = node.querySelectorAll('.field-string input[type=text]'); + act(() => { + fireEvent.change(inputs[0], { target: { value: 'hello' } }); + }); + + const 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"'); + }); + + it('should not raise an error if value is correct using custom text widget', () => { + const { node } = createFormComponent({ + schema, + widgets: { + TextWidget: TextWidgetTest, + }, + }); + + const inputs = node.querySelectorAll('.field-string input[type=text]'); + act(() => { + fireEvent.change(inputs[0], { target: { value: 'test' } }); + }); + + const 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 1bc7d82b3d..f14d195d67 100644 --- a/packages/core/test/StringField.test.jsx +++ b/packages/core/test/StringField.test.jsx @@ -3,9 +3,41 @@ import { Simulate } from 'react-dom/test-utils'; import { fireEvent, act } from '@testing-library/react'; import sinon from 'sinon'; import { parseDateString, toDateString, TranslatableString, utcToLocal } from '@rjsf/utils'; +import StringField from '../src/components/fields/StringField'; +import TextWidget from '../src/components/widgets/TextWidget'; import { createFormComponent, createSandbox, getSelectedOptionValue, submitForm } from './test_utils'; +const StringFieldTest = (props) => { + const onChangeTest = (newFormData, errorSchema, id) => { + const value = newFormData; + let raiseError = errorSchema; + if (value !== 'test') { + raiseError = { + ...raiseError, + __errors: ['Value must be "test"'], + }; + } + props.onChange(newFormData, raiseError, id); + }; + return ; +}; + +export const TextWidgetTest = (props) => { + const onChangeTest = (newFormData, errorSchema, id) => { + const value = newFormData; + let raiseError = errorSchema; + if (value !== 'test') { + raiseError = { + ...raiseError, + __errors: ['Value must be "test"'], + }; + } + props.onChange(newFormData, raiseError, id); + }; + return ; +}; + describe('StringField', () => { let sandbox; @@ -266,6 +298,78 @@ describe('StringField', () => { expect(node.querySelector('input').getAttribute('autocomplete')).eql('family-name'); }); + + it('raise an error and check if the error is displayed', () => { + const { node } = createFormComponent({ + schema: { type: 'string' }, + fields: { + StringField: StringFieldTest, + }, + }); + + const inputs = node.querySelectorAll('.field-string input[type=text]'); + act(() => { + fireEvent.change(inputs[0], { target: { value: 'hello' } }); + }); + + const 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"'); + }); + + it('should not raise an error if value is correct', () => { + const { node } = createFormComponent({ + schema: { type: 'string' }, + fields: { + StringField: StringFieldTest, + }, + }); + + const inputs = node.querySelectorAll('.field-string input[type=text]'); + act(() => { + fireEvent.change(inputs[0], { target: { value: 'test' } }); + }); + + const 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' }, + widgets: { + TextWidget: TextWidgetTest, + }, + }); + + const inputs = node.querySelectorAll('.field-string input[type=text]'); + act(() => { + fireEvent.change(inputs[0], { target: { value: 'hello' } }); + }); + + const 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"'); + }); + + it('should not raise an error if value is correct using custom text widget', () => { + const { node } = createFormComponent({ + schema: { type: 'string' }, + widgets: { + TextWidget: TextWidgetTest, + }, + }); + + const inputs = node.querySelectorAll('.field-string input[type=text]'); + act(() => { + fireEvent.change(inputs[0], { target: { value: 'test' } }); + }); + + const errorMessages = node.querySelectorAll('#root__error'); + expect(errorMessages).to.have.length(0); + }); }); describe('SelectWidget', () => { diff --git a/packages/docs/docs/advanced-customization/custom-widgets-fields.md b/packages/docs/docs/advanced-customization/custom-widgets-fields.md index 5792887e0c..b5bd57948d 100644 --- a/packages/docs/docs/advanced-customization/custom-widgets-fields.md +++ b/packages/docs/docs/advanced-customization/custom-widgets-fields.md @@ -92,6 +92,50 @@ The default widgets you can override are: - `UpDownWidget` - `URLWidget` +## Raising errors from within a custom widget or field + +You can raise a custom error by overriding the `onChange` method to raise field/widget errors: + +```tsx +import { ErrorSchema, RJSFSchema, UiSchema, WidgetProps, RegistryWidgetsType } from '@rjsf/utils'; +import validator from '@rjsf/validator-ajv8'; + +const schema: RJSFSchema = { + type: 'text', + default: 'hello', +}; + +const uiSchema: UiSchema = { + 'ui:widget': 'text', +}; + +const CustomTextWidget = function (props: WidgetProps) { + const { id, value } = props; + const raiseErrorOnChange = ({ target: { value } }: ChangeEvent) => { + let raiseError: ErrorSchema | undefined; + if (value !== 'test') { + raiseError = { + __errors: ['Value must be "test"'], + }; + } + props.onChange(value, raiseError, id); + }; + + return ; +}; + +const widgets: RegistryWidgetsType = { + TextWidget: CustomTextWidget, +}; + +render( +
, + document.getElementById('app') +); +``` + +This creates a custom text widget that raises an error if the input value does not match 'test'. + ## Adding your own custom widgets You can provide your own custom widgets to a uiSchema for the following json data types: diff --git a/packages/utils/src/types.ts b/packages/utils/src/types.ts index f79c081deb..5e4b848808 100644 --- a/packages/utils/src/types.ts +++ b/packages/utils/src/types.ts @@ -750,7 +750,7 @@ export interface WidgetProps void; /** The value change event handler; call it with the new value every time it changes */ - onChange: (value: any) => void; + onChange: (value: any, es?: ErrorSchema, id?: string) => void; /** The input focus event handler; call it with the widget id and value */ onFocus: (id: string, value: any) => void; /** The computed label for this widget, as a string */