diff --git a/src/components/form/fields/array-field.ts b/src/components/form/fields/array-field.ts new file mode 100644 index 0000000000..cd8029001f --- /dev/null +++ b/src/components/form/fields/array-field.ts @@ -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 { + 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); + } +} diff --git a/src/components/form/fields/field-helpers.spec.ts b/src/components/form/fields/field-helpers.spec.ts new file mode 100644 index 0000000000..dc811eb198 --- /dev/null +++ b/src/components/form/fields/field-helpers.spec.ts @@ -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', + }); + }); +}); diff --git a/src/components/form/fields/field-helpers.ts b/src/components/form/fields/field-helpers.ts new file mode 100644 index 0000000000..9847cec587 --- /dev/null +++ b/src/components/form/fields/field-helpers.ts @@ -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; +}; diff --git a/src/components/form/fields/schema-field.spec.ts b/src/components/form/fields/schema-field.spec.ts index 619320f301..bac2d0741e 100644 --- a/src/components/form/fields/schema-field.spec.ts +++ b/src/components/form/fields/schema-field.spec.ts @@ -1,190 +1,4 @@ -import { resetDependentFields, getFactoryProps } from './schema-field'; - -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', - }); - }); -}); +import { getFactoryProps } from './schema-field'; describe('getFactoryProps', () => { describe('when no factory is given', () => { diff --git a/src/components/form/fields/schema-field.ts b/src/components/form/fields/schema-field.ts index 76112934ab..b57bf5aae5 100644 --- a/src/components/form/fields/schema-field.ts +++ b/src/components/form/fields/schema-field.ts @@ -2,68 +2,8 @@ import { LimeElementsAdapter } from '../adapters'; import JSONSchemaField from 'react-jsonschema-form/lib/components/fields/SchemaField'; import React from 'react'; import { FieldProps } from './types'; -import { isEmpty, capitalize, 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; -}; +import { isEmpty, capitalize } from 'lodash-es'; +import { resetDependentFields } from './field-helpers'; /** * If given a value and schema, check if the value should be translated diff --git a/src/components/form/fields/types.ts b/src/components/form/fields/types.ts index 9e8f38d7fb..5dc52e3f60 100644 --- a/src/components/form/fields/types.ts +++ b/src/components/form/fields/types.ts @@ -14,6 +14,24 @@ export interface FieldProps { onChange: (formData: any) => void; } +export interface ArrayProps { + disabled: boolean; + errorSchema: any; + formContext: any; + formData: any; + idPrefix: any; + idSchema: any; + name: string; + rawErrors: any; + readonly: boolean; + registry: any; + required: boolean; + schema: any; + uiSchema: any; + wasPropertyKeyModified: boolean; + onChange: (formData: any, errorSchema: any) => void; +} + export interface Registry { fields: any; widgets: any; diff --git a/src/components/form/form.tsx b/src/components/form/form.tsx index 9b78f5f2f7..a9f19cee27 100644 --- a/src/components/form/form.tsx +++ b/src/components/form/form.tsx @@ -18,6 +18,7 @@ import { ObjectFieldTemplate, } from './templates'; import { SchemaField as CustomSchemaField } from './fields/schema-field'; +import { ArrayField as CustomArrayField } from './fields/array-field'; import { widgets } from './widgets'; import { createRandomString } from '../../util/random-string'; import Ajv from 'ajv'; @@ -131,6 +132,7 @@ export class Form { }, fields: { SchemaField: CustomSchemaField, + ArrayField: CustomArrayField, }, }, []