Skip to content

Commit

Permalink
Feat: Allow raising errors from within a custom whatever(#2718) (#4188)
Browse files Browse the repository at this point in the history
* #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 <abdallah.al-soqatri@aspentech.com>
Co-authored-by: Abdallah Al-Soqatri <abdallah.al-soqatri@inmation.com>
Co-authored-by: Heath C <51679588+heath-freenome@users.noreply.github.com>
  • Loading branch information
4 people committed Aug 3, 2024
1 parent 16b33c0 commit 4d3094c
Show file tree
Hide file tree
Showing 7 changed files with 413 additions and 2 deletions.
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>, 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
Expand Down
43 changes: 42 additions & 1 deletion packages/core/src/components/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<T>;
}
schemaValidationErrors = errors;
schemaValidationErrorSchema = errorSchema;
} else {
Expand Down Expand Up @@ -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<T>, resolvedSchema?: S, formData?: any): ErrorSchema<T> {
const { retrievedSchema, schemaUtils } = this.state;
const _retrievedSchema = resolvedSchema ?? retrievedSchema;
const pathSchema = schemaUtils.toPathSchema(_retrievedSchema, '', formData);
const fieldNames = this.getFieldNames(pathSchema, formData);
const filteredErrors: ErrorSchema<T> = _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<T> => {
_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
Expand Down Expand Up @@ -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<T>;
}
state = {
formData: newFormData,
errors,
Expand Down
118 changes: 118 additions & 0 deletions packages/core/test/ArrayField.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 <ArrayField {...props} onChange={onChangeTest} />;
};

describe('ArrayField', () => {
let sandbox;
const CustomComponent = (props) => {
Expand Down Expand Up @@ -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);
});
});
});
90 changes: 90 additions & 0 deletions packages/core/test/ObjectField.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ObjectField {...props} onChange={onChangeTest} />;
};

describe('ObjectField', () => {
let sandbox;

Expand Down Expand Up @@ -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', () => {
Expand Down
Loading

0 comments on commit 4d3094c

Please sign in to comment.