Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Allow raising errors from within a custom whatever(#2718) #4188

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ 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 whatever [#2718](https://github.com/rjsf-team/react-jsonschema-form/issues/2718)

# 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 @@ -40,6 +40,7 @@ import _pick from 'lodash/pick';
import _toPath from 'lodash/toPath';

import getDefaultRegistry from '../getDefaultRegistry';
import { forEach } from 'lodash';

/** The properties that are passed to the `Form` */
export interface FormProps<T = any, S extends StrictRJSFSchema = RJSFSchema, F extends FormContextType = any> {
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) => {
abdalla-rko marked this conversation as resolved.
Show resolved Hide resolved
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) => {
heath-freenome marked this conversation as resolved.
Show resolved Hide resolved
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