Skip to content

Commit

Permalink
fix(form): fix resetting dependent fields during schema changes in lists
Browse files Browse the repository at this point in the history
  • Loading branch information
sampsonbryce authored and jgroth committed Oct 2, 2020
1 parent 7c2bcef commit 13457d4
Show file tree
Hide file tree
Showing 7 changed files with 366 additions and 249 deletions.
94 changes: 94 additions & 0 deletions src/components/form/fields/array-field.ts
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);
}
}
187 changes: 187 additions & 0 deletions src/components/form/fields/field-helpers.spec.ts
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',
});
});
});
62 changes: 62 additions & 0 deletions src/components/form/fields/field-helpers.ts
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;
};
Loading

0 comments on commit 13457d4

Please sign in to comment.