Skip to content

Commit

Permalink
fix: errorSchema is not correctly updated when inserting or copying a…
Browse files Browse the repository at this point in the history
…rray items (#3874)

* fix: errorSchema is not correctly updated when inserting array items

* update changelog

---------

Co-authored-by: Heath C <[email protected]>
  • Loading branch information
jordyjordy and heath-freenome authored Oct 6, 2023
1 parent 4330787 commit 7dd90eb
Show file tree
Hide file tree
Showing 3 changed files with 338 additions and 5 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ should change the heading of the (upcoming) version to include a major version b
-->
# 5.13.1

## @rjsf/core

- Updated `ArrayField` to move errors in the errorSchema when the position of array items changes for the insert and copy cases.

## @rjsf/mui

- Removed an unnecessary `Grid` container component in the `ArrayFieldTemplate` component that wrapped the `ArrayFieldItemTemplate`, fixing [#3863](https://github.com/rjsf-team/react-jsonschema-form/issues/3863)
Expand Down
36 changes: 32 additions & 4 deletions packages/core/src/components/fields/ArrayField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,8 +198,22 @@ class ArrayField<T = any, S extends StrictRJSFSchema = RJSFSchema, F extends For
event.preventDefault();
}

const { onChange } = this.props;
const { onChange, errorSchema } = this.props;
const { keyedFormData } = this.state;
// refs #195: revalidate to ensure properly reindexing errors
let newErrorSchema: ErrorSchema<T>;
if (errorSchema) {
newErrorSchema = {};
for (const idx in errorSchema) {
const i = parseInt(idx);
if (index === undefined || i < index) {
set(newErrorSchema, [i], errorSchema[idx]);
} else if (i >= index) {
set(newErrorSchema, [i + 1], errorSchema[idx]);
}
}
}

const newKeyedFormDataRow: KeyedFormDataType<T> = {
key: generateRowId(),
item: this._getNewFormDataRow(),
Expand All @@ -215,7 +229,7 @@ class ArrayField<T = any, S extends StrictRJSFSchema = RJSFSchema, F extends For
keyedFormData: newKeyedFormData,
updatedKeyedFormData: true,
},
() => onChange(keyedToPlainFormData(newKeyedFormData))
() => onChange(keyedToPlainFormData(newKeyedFormData), newErrorSchema as ErrorSchema<T[]>)
);
}

Expand Down Expand Up @@ -253,8 +267,22 @@ class ArrayField<T = any, S extends StrictRJSFSchema = RJSFSchema, F extends For
event.preventDefault();
}

const { onChange } = this.props;
const { onChange, errorSchema } = this.props;
const { keyedFormData } = this.state;
// refs #195: revalidate to ensure properly reindexing errors
let newErrorSchema: ErrorSchema<T>;
if (errorSchema) {
newErrorSchema = {};
for (const idx in errorSchema) {
const i = parseInt(idx);
if (i <= index) {
set(newErrorSchema, [i], errorSchema[idx]);
} else if (i > index) {
set(newErrorSchema, [i + 1], errorSchema[idx]);
}
}
}

const newKeyedFormDataRow: KeyedFormDataType<T> = {
key: generateRowId(),
item: cloneDeep(keyedFormData[index].item),
Expand All @@ -270,7 +298,7 @@ class ArrayField<T = any, S extends StrictRJSFSchema = RJSFSchema, F extends For
keyedFormData: newKeyedFormData,
updatedKeyedFormData: true,
},
() => onChange(keyedToPlainFormData(newKeyedFormData))
() => onChange(keyedToPlainFormData(newKeyedFormData), newErrorSchema as ErrorSchema<T[]>)
);
};
};
Expand Down
303 changes: 302 additions & 1 deletion packages/core/test/ArrayField.test.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect } from 'chai';
import { Simulate } from 'react-dom/test-utils';
import { Simulate, act } from 'react-dom/test-utils';
import sinon from 'sinon';

import { createFormComponent, createSandbox, submitForm } from './test_utils';
Expand Down Expand Up @@ -73,6 +73,89 @@ const CustomOnAddClickTemplate = function (props) {
);
};

const ArrayFieldTestItemTemplate = (props) => {
const {
children,
className,
disabled,
hasToolbar,
hasMoveDown,
hasMoveUp,
hasRemove,
hasCopy,
canAdd,
index,
onAddIndexClick,
onCopyIndexClick,
onDropIndexClick,
onReorderClick,
readonly,
} = props;
const btnStyle = {
flex: 1,
paddingLeft: 6,
paddingRight: 6,
fontWeight: 'bold',
};
return (
<div className={className}>
<div className={hasToolbar ? 'col-xs-9' : 'col-xs-12'}>{children}</div>
{hasToolbar && (
<div className='col-xs-3 array-item-toolbox'>
<div
className='btn-group'
style={{
display: 'flex',
justifyContent: 'space-around',
}}
>
{hasMoveDown && (
<button
title='move-down'
style={btnStyle}
disabled={disabled || readonly}
onClick={onReorderClick(index, index + 1)}
>
move down
</button>
)}
{hasMoveUp && (
<button
title='move-up'
style={btnStyle}
disabled={disabled || readonly}
onClick={onReorderClick(index, index - 1)}
>
move up
</button>
)}
{hasCopy && (
<button title='copy' style={btnStyle} disabled={disabled || readonly} onClick={onCopyIndexClick(index)}>
copy
</button>
)}
{hasRemove && (
<button title='remove' style={btnStyle} disabled={disabled || readonly} onClick={onDropIndexClick(index)}>
remove
</button>
)}
{hasMoveDown && canAdd && (
<button
title='insert'
style={btnStyle}
disabled={disabled || readonly}
onClick={onAddIndexClick(index + 1)}
>
insert
</button>
)}
</div>
</div>
)}
</div>
);
};

describe('ArrayField', () => {
let sandbox;
const CustomComponent = (props) => {
Expand Down Expand Up @@ -2330,4 +2413,222 @@ describe('ArrayField', () => {
});
});
});

describe('ErrorSchema gets updated', () => {
const templates = { ArrayFieldItemTemplate: ArrayFieldTestItemTemplate };
const schema = {
type: 'array',
items: {
type: 'object',
properties: {
text: {
type: 'string',
},
},
required: ['text'],
},
};
const uiSchema = {
'ui:copyable': true,
};

const formData = [
{},
{
text: 'y',
},
];

it('swaps errors when swapping elements', () => {
const { node, onChange } = createFormComponent({
schema,
formData,
templates,
});

act(() => {
submitForm(node);
});

const button = node.querySelector('[title="move-up"]');

act(() => {
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});

expect(onChange.callCount).to.equal(1);
expect(onChange.firstCall.firstArg.formData).to.eql([{ text: 'y' }, {}]);
expect(onChange.firstCall.firstArg.errorSchema).to.eql({
1: {
text: {
__errors: ["must have required property 'text'"],
},
},
});
});

it('leaves errors when removing higher elements', () => {
const { node, onChange } = createFormComponent({
schema,
formData,
templates,
});

act(() => {
submitForm(node);
});

const button = node.querySelectorAll('[title="remove"]')[1];

act(() => {
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});

expect(onChange.callCount).to.equal(1);
expect(onChange.firstCall.firstArg.formData).to.eql([{}]);
expect(onChange.firstCall.firstArg.errorSchema).to.eql({
0: {
text: {
__errors: ["must have required property 'text'"],
},
},
});
});

it('removes errors when removing elements', () => {
const { node, onChange } = createFormComponent({
schema,
formData,
templates,
});

act(() => {
submitForm(node);
});

const button = node.querySelector('[title="remove"]');

act(() => {
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});

expect(onChange.callCount).to.equal(1);
expect(onChange.firstCall.firstArg.formData).to.eql([{ text: 'y' }]);
expect(onChange.firstCall.firstArg.errorSchema).to.eql({});
});

it('leaves errors in place when inserting elements', () => {
const { node, onChange } = createFormComponent({
schema,
formData,
templates,
});

act(() => {
submitForm(node);
});

const button = node.querySelector('[title="insert"]');

act(() => {
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});

expect(onChange.callCount).to.equal(1);
expect(onChange.firstCall.firstArg.formData).to.eql([{}, {}, { text: 'y' }]);
expect(onChange.firstCall.firstArg.errorSchema).to.eql({
0: {
text: {
__errors: ["must have required property 'text'"],
},
},
});
});

it('moves errors when inserting elements', () => {
const { node, onChange } = createFormComponent({
schema,
formData: [{ text: 'y' }, {}],
templates,
});

act(() => {
submitForm(node);
});

const button = node.querySelector('[title="insert"]');

act(() => {
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});

expect(onChange.callCount).to.equal(1);
expect(onChange.firstCall.firstArg.formData).to.eql([{ text: 'y' }, {}, {}]);
expect(onChange.firstCall.firstArg.errorSchema).to.eql({
2: {
text: {
__errors: ["must have required property 'text'"],
},
},
});
});

it('leaves errors in place when copying elements', () => {
const { node, onChange } = createFormComponent({
schema,
uiSchema,
formData,
templates,
});

act(() => {
submitForm(node);
});

const button = node.querySelector('[title="copy"]');

act(() => {
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});

expect(onChange.callCount).to.equal(1);
expect(onChange.firstCall.firstArg.formData).to.eql([{}, {}, { text: 'y' }]);
expect(onChange.firstCall.firstArg.errorSchema).to.eql({
0: {
text: {
__errors: ["must have required property 'text'"],
},
},
});
});

it('moves errors when copying elements', () => {
const { node, onChange } = createFormComponent({
schema,
uiSchema,
formData: [{ text: 'y' }, {}],
templates,
});

act(() => {
submitForm(node);
});

const button = node.querySelector('[title="copy"]');

act(() => {
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});

expect(onChange.callCount).to.equal(1);
expect(onChange.firstCall.firstArg.formData).to.eql([{ text: 'y' }, { text: 'y' }, {}]);
expect(onChange.firstCall.firstArg.errorSchema).to.eql({
2: {
text: {
__errors: ["must have required property 'text'"],
},
},
});
});
});
});

0 comments on commit 7dd90eb

Please sign in to comment.