-
Notifications
You must be signed in to change notification settings - Fork 16
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix(form): fix resetting dependent fields during schema changes in lists
- Loading branch information
1 parent
7c2bcef
commit 13457d4
Showing
7 changed files
with
366 additions
and
249 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,94 @@ | ||
import JSONArrayField from 'react-jsonschema-form/lib/components/fields/ArrayField'; | ||
import React from 'react'; | ||
import { ArrayProps } from './types'; | ||
import { resetDependentFields } from './field-helpers'; | ||
|
||
/** | ||
* This override field exists to supplement the need to reset dependent fields | ||
* in SchemaField. The `resetDependentFields` function requires that the schema that | ||
* is passed to it be a raw schema with dependencies and all. However, when rendering list items | ||
* react jsonschema form calls `retrieveSchema` on the raw schema resulting in a schema for the current | ||
* data. This schema from `retrieveSchema` cannot be used to detect changes in a schema due to data changes | ||
* as the schema will not change when passed again into `retrieveSchema` with old and then new data. | ||
* So since the SchemaFields directly rendered by a list have a modified schema, they cannot properly reset | ||
* dependent fields by calling `resetDependentFields` due to the above limitations. To fix this, we do the | ||
* same resetting of dependent fields here in this component by detecting changed list items and calling | ||
* `resetDependentFields` on the changed list items. This is possible because the ArrayField is also given | ||
* the raw schema so it can properly call `resetDependentFields` | ||
* | ||
* This form has to handle several events that can affect the list: | ||
* | ||
* 1. An on change where no list item changes | ||
* Solution: Ignore all changes and just pass the event through | ||
* | ||
* 2. An on change where one list item is changed | ||
* Solution: Call `resetDependentFields` with the old list item and the new list item as old and new data | ||
* | ||
* 3. An on change event where the size of the list of items has changed due to adding or removing a list item | ||
* Solution: Ignore all changes and just pass the event through | ||
* | ||
* 4. An on change event where list items are swapped or scrambled due to list items being swapped or sorted | ||
* Solution: Ignore all changes and just pass the event through | ||
*/ | ||
export class ArrayField extends React.Component<ArrayProps> { | ||
constructor(props) { | ||
super(props); | ||
this.handleChange = this.handleChange.bind(this); | ||
} | ||
|
||
private handleChange(newData, errorSchema) { | ||
const { formData: oldData, schema } = this.props; | ||
const { definitions } = this.props.registry; | ||
|
||
// This case handles when the first list item is added. When there are no | ||
// items we get undefined instead of [] | ||
if (!oldData) { | ||
this.props.onChange(newData, errorSchema); | ||
|
||
return; | ||
} | ||
|
||
// This case happens when we add or remove an item from the list | ||
// No need to check for resetting fields unless we change data | ||
if (oldData.length !== newData.length) { | ||
this.props.onChange(newData, errorSchema); | ||
|
||
return; | ||
} | ||
|
||
const nonMatchingIndices = []; | ||
|
||
// Find all items in the new data list that do not match | ||
// the old data | ||
for (let i = 0; i < oldData.length; i++) { | ||
if (newData[i] === oldData[i]) { | ||
continue; | ||
} | ||
|
||
nonMatchingIndices.push(i); | ||
} | ||
|
||
// If we have one non matching index, then one item has been changed indicating that its data | ||
// has been changed so we check if it needs its dependent fields reset | ||
if (nonMatchingIndices.length === 1) { | ||
const i = nonMatchingIndices[0]; | ||
newData[i] = resetDependentFields( | ||
oldData[i], | ||
newData[i], | ||
schema.items, | ||
definitions | ||
); | ||
} | ||
|
||
this.props.onChange(newData, errorSchema); | ||
} | ||
|
||
render() { | ||
const arrayProps = { | ||
...this.props, | ||
onChange: this.handleChange, | ||
}; | ||
|
||
return React.createElement(JSONArrayField, arrayProps); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,187 @@ | ||
import { resetDependentFields } from './field-helpers'; | ||
|
||
describe('resetDependentFields()', () => { | ||
const schema = { | ||
type: 'object', | ||
properties: { | ||
type: { | ||
title: 'Type', | ||
type: 'string', | ||
enum: ['String', 'Object'], | ||
}, | ||
}, | ||
required: ['type'], | ||
dependencies: { | ||
type: { | ||
oneOf: [ | ||
{ | ||
properties: { | ||
type: { | ||
const: 'String', | ||
}, | ||
value: { | ||
type: 'string', | ||
title: 'String Type', | ||
}, | ||
}, | ||
}, | ||
{ | ||
properties: { | ||
type: { | ||
const: 'Object', | ||
}, | ||
value: { | ||
type: 'object', | ||
title: 'Object Type', | ||
properties: { | ||
name: { | ||
type: 'string', | ||
}, | ||
other: { | ||
type: 'string', | ||
}, | ||
}, | ||
}, | ||
}, | ||
}, | ||
], | ||
}, | ||
}, | ||
}; | ||
|
||
const schemaWithReferences = { | ||
definitions: { | ||
ObjectSchema: { | ||
title: 'Object Type', | ||
properties: { | ||
name: { | ||
type: 'string', | ||
}, | ||
other: { | ||
type: 'string', | ||
}, | ||
}, | ||
}, | ||
MainSchema: { | ||
type: 'object', | ||
properties: { | ||
type: { | ||
title: 'Type', | ||
type: 'string', | ||
enum: ['String', 'Object'], | ||
}, | ||
}, | ||
required: ['type'], | ||
dependencies: { | ||
type: { | ||
oneOf: [ | ||
{ | ||
properties: { | ||
type: { | ||
const: 'String', | ||
}, | ||
value: { | ||
type: 'string', | ||
title: 'String Type', | ||
}, | ||
}, | ||
}, | ||
{ | ||
properties: { | ||
type: { | ||
const: 'Object', | ||
}, | ||
value: { | ||
type: 'object', | ||
$ref: '#definitions/ObjectSchema', | ||
}, | ||
}, | ||
}, | ||
], | ||
}, | ||
}, | ||
}, | ||
}, | ||
$ref: '#/definitions/MainSchema', | ||
}; | ||
|
||
it('should reset a populated value if the schema changes', () => { | ||
const oldData = { | ||
type: 'String', | ||
value: 'bacon', | ||
}; | ||
|
||
const newData = { | ||
type: 'Object', | ||
value: 'bacon', | ||
}; | ||
|
||
expect(resetDependentFields(oldData, newData, schema, {})).toEqual({ | ||
type: 'Object', | ||
}); | ||
}); | ||
|
||
it('should not reset a populated value if the schema does not change', () => { | ||
const oldData = { | ||
type: 'String', | ||
value: 'bacon', | ||
}; | ||
|
||
const newData = { | ||
type: 'String', | ||
value: 'cheese', | ||
}; | ||
|
||
expect(resetDependentFields(oldData, newData, schema, {})).toEqual({ | ||
type: 'String', | ||
value: 'cheese', | ||
}); | ||
}); | ||
|
||
it('should reset a populated value if the schema changes (with definitions)', () => { | ||
const oldData = { | ||
type: 'String', | ||
value: 'bacon', | ||
}; | ||
|
||
const newData = { | ||
type: 'Object', | ||
value: 'bacon', | ||
}; | ||
|
||
expect( | ||
resetDependentFields( | ||
oldData, | ||
newData, | ||
schemaWithReferences, | ||
schemaWithReferences.definitions | ||
) | ||
).toEqual({ | ||
type: 'Object', | ||
}); | ||
}); | ||
|
||
it('should not reset a populated value if the schema does not change (with definitions)', () => { | ||
const oldData = { | ||
type: 'String', | ||
value: 'bacon', | ||
}; | ||
|
||
const newData = { | ||
type: 'String', | ||
value: 'cheese', | ||
}; | ||
|
||
expect( | ||
resetDependentFields( | ||
oldData, | ||
newData, | ||
schemaWithReferences, | ||
schemaWithReferences.definitions | ||
) | ||
).toEqual({ | ||
type: 'String', | ||
value: 'cheese', | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
import { union, isEqual, isPlainObject } from 'lodash-es'; | ||
import { retrieveSchema } from 'react-jsonschema-form/lib/utils'; | ||
|
||
/** | ||
* Given two objects, get a list of keys for each value that is different between | ||
* the two objects. Compares using deep comparison | ||
* | ||
* @param {object} a first object | ||
* @param {object} b second object | ||
* | ||
* @returns {any[]} the array of keys | ||
*/ | ||
const getDifferentKeys = (a: object = {}, b: object = {}): any[] => { | ||
const keys = union(Object.keys(a), Object.keys(b)); | ||
|
||
return keys.filter((key) => { | ||
return !isEqual(b[key], a[key]); | ||
}); | ||
}; | ||
|
||
/** | ||
* Given the data for the current SchemaField, detect if the changed data | ||
* has any other fields that are dependent on it, and if so reset those dependent fields | ||
* (by deleting them from the data so that their defaults are populated on the next rerender). | ||
* Call onChange with the updated data | ||
* | ||
* @param {any} oldData The previous data before a data change event | ||
* @param {any} newData The form data from a change event | ||
* @param {object} schema The schema associated with the data | ||
* @param {object} definitions The root schema definitions | ||
* | ||
* @returns {void} | ||
*/ | ||
export const resetDependentFields = (oldData, newData, schema, definitions) => { | ||
// Dependencies only exist on object types | ||
if (!isPlainObject(newData)) { | ||
return newData; | ||
} | ||
|
||
// Get the schema generated by the current data | ||
const currentSchema = retrieveSchema(schema, definitions, oldData); | ||
|
||
// Get the new schema that is calculated for the new data | ||
const newSchema = retrieveSchema(schema, definitions, newData); | ||
|
||
// Get property keys whose schema changed due to the new data. | ||
// These properties that have changed are the properties that are dependent on | ||
// data that has changed in the current onChange event | ||
const dependentPropertyKeys = getDifferentKeys( | ||
newSchema.properties, | ||
currentSchema.properties | ||
); | ||
|
||
// Reset keys that are dependent on the changed value. | ||
// The values for these dependent fields will be repopulated | ||
// with defaults during the next render | ||
for (const dependentPropertyKey of dependentPropertyKeys) { | ||
delete newData[dependentPropertyKey]; | ||
} | ||
|
||
return newData; | ||
}; |
Oops, something went wrong.