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

Merged
merged 20 commits into from
Aug 3, 2024
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ 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
heath-freenome marked this conversation as resolved.
Show resolved Hide resolved
# 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) => {
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