Repository: DevExpress/devextreme-react
Pull request #361 (2019-06-21 12:30:58)

Fix setting widget options when modifying nested components (T742357)
Cherrypick of #358 


Patch:
diff --git a/example/app.tsx b/example/app.tsx
index 86f828e..7a04b6b 100644
--- a/example/app.tsx
+++ b/example/app.tsx
@@ -22,6 +22,7 @@ import ToolbarExample from "./toolbar-example";
 import ValidationExample from "./validation-example";
 
 import { Button, NumberBox } from "../src";
+import BoxExample from "./box-example";
 
 ReactDOM.render(
   <div>
@@ -73,6 +74,9 @@ ReactDOM.render(
     <Example title="SelectBox example">
       <SelectBoxExample />
     </Example>
+
+    <BoxExample />
+
   </div>,
   document.getElementById("app")
 );
diff --git a/example/box-example.tsx b/example/box-example.tsx
new file mode 100644
index 0000000..393c855
--- /dev/null
+++ b/example/box-example.tsx
@@ -0,0 +1,71 @@
+import * as React from "react";
+
+import Box, { Item } from "../src/box";
+import Button from "../src/button";
+import Example from "./example-block";
+
+const initialState = {
+  items: [
+    {
+      id: 1,
+      name: "Item #1"
+    },
+    {
+      id: 2,
+      name: "Item #2"
+    }
+  ]
+};
+
+class App extends React.Component<any, typeof initialState> {
+
+  constructor(props: any) {
+    super(props);
+
+    this.state = { ...initialState };
+  }
+
+  public render() {
+    return (
+      <Example title="Box example">
+        <Button
+          text="Add"
+          onClick={this.add}
+        />
+        <Button
+          text="Remove"
+          onClick={this.remove}
+        />
+        <Box direction={"row"} width={"100%"} height={75}>
+          {this.renderItems()}
+        </Box>
+      </Example>
+    );
+  }
+
+  private renderItems = () => {
+    return this.state.items.map((item) => (
+      <Item key={item.id} ratio={1}>
+        {item.name}
+      </Item>
+    ));
+  }
+
+  private add = () => {
+    const items = [...this.state.items];
+    const id = items.length ? items[items.length - 1].id + 1 : 1;
+    items.push({
+      id,
+      name: `Item #${id}`
+    });
+
+    this.setState({ items });
+  }
+
+  private remove = () => {
+    const items = this.state.items.slice(0, this.state.items.length - 1);
+    this.setState({ items });
+  }
+}
+
+export default App;
diff --git a/src/core/component-base.ts b/src/core/component-base.ts
index 16a1b9e..33b85f8 100644
--- a/src/core/component-base.ts
+++ b/src/core/component-base.ts
@@ -63,11 +63,8 @@ abstract class ComponentBase<P extends IHtmlOptions> extends React.PureComponent
     this._updateCssClasses(prevProps, this.props);
 
     const preparedProps = this._prepareProps(this.props);
-    const options: Record<string, any> = {
-      ...preparedProps.options,
-      ...this._templatesManager.options
-    };
-    this._optionsManager.processChangedValues(options, prevProps);
+
+    this._optionsManager.updateOptions(preparedProps.options, prevProps);
   }
 
   public componentWillUnmount() {
@@ -172,7 +169,7 @@ abstract class ComponentBase<P extends IHtmlOptions> extends React.PureComponent
   private _prepareProps(rawProps: Record<string, any>): IWidgetConfig {
     const separatedProps = separateProps(rawProps, this._defaults, this._templateProps);
 
-    this._templatesManager.add({
+    const templatesOptions = this._templatesManager.add({
       useChildren: () => false,
       ownerName: "",
       templateProps: this._templateProps,
@@ -181,7 +178,10 @@ abstract class ComponentBase<P extends IHtmlOptions> extends React.PureComponent
     });
 
     return {
-        options: separatedProps.options,
+        options: {
+          ...separatedProps.options,
+          ...templatesOptions
+        },
         defaults: separatedProps.defaults
     };
   }
diff --git a/src/core/options-manager.ts b/src/core/options-manager.ts
index 2cad25a..3ceb159 100644
--- a/src/core/options-manager.ts
+++ b/src/core/options-manager.ts
@@ -55,10 +55,11 @@ class OptionsManager {
         this._templatesManager = templateHost;
 
         this._setOption = this._setOption.bind(this);
+        this._syncOptions = this._syncOptions.bind(this);
         this._registerNestedOption = this._registerNestedOption.bind(this);
         this.registerNestedOption = this.registerNestedOption.bind(this);
         this.handleOptionChange = this.handleOptionChange.bind(this);
-        this.processChangedValues = this.processChangedValues.bind(this);
+        this.updateOptions = this.updateOptions.bind(this);
     }
 
     public resetNestedElements() {
@@ -132,37 +133,24 @@ class OptionsManager {
         this._setGuard(optionName, optionValue);
     }
 
-    public processChangedValues(newProps: Record<string, any>, prevProps: Record<string, any>): void {
-        this._updatingProps = false;
-
-        for (const optionName of Object.keys(newProps)) {
-            if (newProps[optionName] === prevProps[optionName]) {
-                continue;
-            }
-
-            if (this._guards[optionName]) {
-                window.clearTimeout(this._guards[optionName]);
-                delete this._guards[optionName];
-            }
-
-            if (!this._updatingProps) {
-                this._instance.beginUpdate();
-                this._updatingProps = true;
-            }
-            this._setOption(optionName, newProps[optionName]);
-        }
-
-        if (this._updatingProps) {
-            this._updatingProps = false;
-            this._instance.endUpdate();
-        }
+    public updateOptions(newProps: Record<string, any>, prevProps: Record<string, any>): void {
+        const nestedOptions: Record<string, any> = {};
 
         Object.keys(this._dirtyOptions).forEach((optionName) => {
             const optionDescr = this._dirtyOptions[optionName];
-            const optionValue = this._getNestedOptionObj(optionDescr, false);
-            this._setOption(optionName, optionValue);
+            const optionValue = this._getNestedOptionObj(optionDescr, true);
+
+            nestedOptions[optionName] = optionValue;
         });
 
+        const newOptions: Record<string, any> = {
+            ...this._templatesManager.options,
+            ...nestedOptions,
+            ...newProps
+        };
+
+        this._syncOptions(newOptions, prevProps);
+
         this._dirtyOptions = {};
     }
 
@@ -184,6 +172,35 @@ class OptionsManager {
         }
     }
 
+    private _syncOptions(
+        newOptions: Record<string, any>,
+        prevOptions: Record<string, any>
+    ): void {
+        this._updatingProps = false;
+
+        for (const optionName of Object.keys(newOptions)) {
+            if (newOptions[optionName] === prevOptions[optionName]) {
+                continue;
+            }
+
+            if (this._guards[optionName]) {
+                window.clearTimeout(this._guards[optionName]);
+                delete this._guards[optionName];
+            }
+
+            if (!this._updatingProps) {
+                this._instance.beginUpdate();
+                this._updatingProps = true;
+            }
+            this._setOption(optionName, newOptions[optionName]);
+        }
+
+        if (this._updatingProps) {
+            this._updatingProps = false;
+            this._instance.endUpdate();
+        }
+    }
+
     private _setOption(name: string, value: any): void {
         let actualValue = value;
         if (isEventHanlder(name, value)) {
@@ -238,8 +255,9 @@ class OptionsManager {
             const hasChildrenForTemplate =
                 ReactChildren.count(e.element.props.children) > nestedObjectsCount;
 
+            let templatesOptions = {};
             if (templateRegistrationRequired) {
-                this._templatesManager.add({
+                templatesOptions = this._templatesManager.add({
                     useChildren: (optionName) => {
                         return optionName === "template" && hasChildrenForTemplate;
                     },
@@ -249,7 +267,14 @@ class OptionsManager {
                         configComponent,
                         configComponent.isCollectionItem ? index : undefined,
                     ),
-                    propsGetter: (prop) => configComponent.elementEntries[index].element.props[prop]
+                    propsGetter: (prop) => {
+                        const nestedElement = configComponent.elementEntries[index];
+                        if (!nestedElement) {
+                            return;
+                        }
+
+                        return nestedElement.element.props[prop];
+                    }
                 });
             }
 
@@ -257,7 +282,8 @@ class OptionsManager {
                 ...e.predefinedProps,
                 ...props.defaults,
                 ...props.options,
-                ...nestedObjects
+                ...nestedObjects,
+                ...templatesOptions
             };
         });
 
@@ -323,7 +349,7 @@ class OptionsManager {
                     nestedOptionClass.type.TemplateProps
                 ).options;
 
-                this.processChangedValues(
+                this._syncOptions(
                     addPrefixToKeys(newOptions, optionItemName + "."),
                     addPrefixToKeys(prevProps, optionItemName + ".")
                 );
diff --git a/src/core/templates-manager.ts b/src/core/templates-manager.ts
index e568ce0..09df8ef 100644
--- a/src/core/templates-manager.ts
+++ b/src/core/templates-manager.ts
@@ -23,7 +23,6 @@ const contentCreators = {
 class TemplatesManager {
     private _templatesStore: TemplatesStore;
     private _templates: Record<string, any> = {};
-    private _stubs: Record<string, any> = {};
     private _nestedTemplateProps: Record<string, {
         render: any;
         component: any;
@@ -34,9 +33,9 @@ class TemplatesManager {
         this._templatesStore = templatesStore;
     }
 
-    public add(meta: IIntegrationDescr) {
+    public add(meta: IIntegrationDescr): Record<string, any> {
         const templates: Record<string, IDxTemplate> = {};
-        const stubs: Record<string, any> = {};
+        const templatesOptions: Record<string, any> = {};
 
         const props = meta.props;
         const templateProps = meta.templateProps || [];
@@ -67,7 +66,7 @@ class TemplatesManager {
             contentCreator = contentCreator.bind(this, propName, meta.propsGetter);
 
             const name = ownerName ? `${ownerName}.${tmpl.tmplOption}` : tmpl.tmplOption;
-            stubs[name] = name;
+            templatesOptions[tmpl.tmplOption] = name;
             templates[name] = createDxTemplate(
                 contentCreator,
                 this._templatesStore,
@@ -80,10 +79,7 @@ class TemplatesManager {
             ...templates
         };
 
-        this._stubs = {
-            ...this._stubs,
-            ...stubs
-        };
+        return templatesOptions;
     }
 
     public addNested(props: ITemplateProps): void {
@@ -109,8 +105,7 @@ class TemplatesManager {
         return {
             integrationOptions: {
                 templates: this._templates
-            },
-            ...this._stubs
+            }
         };
     }
 }


Test patch:
diff --git a/src/core/__tests__/template.test.tsx b/src/core/__tests__/template.test.tsx
index 6d7d833..f4380ee 100644
--- a/src/core/__tests__/template.test.tsx
+++ b/src/core/__tests__/template.test.tsx
@@ -1,3 +1,4 @@
+/* tslint:disable:no-string-literal */
 import * as events from "devextreme/events";
 
 import ConfigurationComponent from "../../core/nested-option";
@@ -413,7 +414,7 @@ describe("component/render in nested options", () => {
         );
 
         const options = WidgetClass.mock.calls[0][1];
-        expect(options["option.item"]).toBe("option.item");
+        expect(options["option"]["item"]).toBe("option.item");
 
         const integrationOptions = options.integrationOptions;
 
@@ -457,8 +458,8 @@ describe("component/render in nested options", () => {
 
         const options = WidgetClass.mock.calls[0][1];
 
-        expect(options["option.item"]).toBe("option.item");
-        expect(options["collection[0].template"]).toBe("collection[0].template");
+        expect(options["option"]["item"]).toBe("option.item");
+        expect(options["collection"][0]["template"]).toBe("collection[0].template");
 
         const integrationOptions = options.integrationOptions;
 
@@ -486,10 +487,10 @@ describe("component/render in nested options", () => {
 
         const options = WidgetClass.mock.calls[0][1];
 
-        expect(options["collection[0].template"]).toBe("collection[0].template");
-        expect(options["collection[1].template"]).toBe("collection[1].template");
-        expect(options["collection[2].option.item"]).toBe("collection[2].option.item");
-        expect(options["option.collection[0].template"]).toBe("option.collection[0].template");
+        expect(options["collection"][0]["template"]).toBe("collection[0].template");
+        expect(options["collection"][1]["template"]).toBe("collection[1].template");
+        expect(options["collection"][2]["option"]["item"]).toBe("collection[2].option.item");
+        expect(options["option"]["collection"][0]["template"]).toBe("option.collection[0].template");
 
         const integrationOptions = options.integrationOptions;
 
@@ -543,15 +544,15 @@ describe("component/render in nested options", () => {
 
         const options = WidgetClass.mock.calls[0][1];
 
-        expect(options["collection[0].template"]).toBe("collection[0].template");
-        expect(options["collection[1].template"]).toBe("collection[1].template");
-        expect(options["collection[2].template"]).toBe("collection[2].template");
-        expect(options["collection[3].template"]).toBe("collection[3].template");
-        expect(options["collection[4].template"]).toBe(undefined);
-        expect(options["collection[5].template"]).toBe(undefined);
-        expect(options["collection[6].template"]).toBe(undefined);
-        expect(options["option.item"]).toBe(undefined);
-        expect(options["option.template"]).toBe(undefined);
+        expect(options["collection"][0]["template"]).toBe("collection[0].template");
+        expect(options["collection"][1]["template"]).toBe("collection[1].template");
+        expect(options["collection"][2]["template"]).toBe("collection[2].template");
+        expect(options["collection"][3]["template"]).toBe("collection[3].template");
+        expect(options["collection"][4]["template"]).toBe(undefined);
+        expect(options["collection"][5]["template"]).toBe(undefined);
+        expect(options["collection"][6]["template"]).toBe(undefined);
+        expect(options["option"]["item"]).toBe(undefined);
+        expect(options["option"]["template"]).toBe(undefined);
 
         const integrationOptions = options.integrationOptions;
 
@@ -606,4 +607,82 @@ describe("component/render in nested options", () => {
         component.update();
         expect(component.find(".template").html()).toBe('<div class="template">Second Template</div>');
     });
+
+    it("adds nested components dynamically", () => {
+        const renderItem = () => <div>Template</div>;
+        const items = [{id: 1, render: renderItem}];
+
+        const TestContainer = (props: any) => (
+            <TestComponent>
+                {props.items.map((item) => <CollectionNestedComponent key={item.id} render={item.render} />)}
+            </TestComponent>
+        );
+
+        const component = mount(<TestContainer items={items} />);
+        component.setProps({
+            items: [
+                ...items,
+                {id: 2, render: renderItem}
+            ]
+        });
+
+        const updatedOptions = Widget.option.mock.calls;
+
+        expect(updatedOptions[0][0]).toBe("integrationOptions");
+        expect(Object.keys(updatedOptions[0][1].templates)).toEqual([
+            "collection[0].template",
+            "collection[1].template"
+        ]);
+
+        expect(updatedOptions[1][0]).toBe("collection");
+        expect(updatedOptions[1][1].length).toBe(2);
+        expect(updatedOptions[1][1][0].template).toBe("collection[0].template");
+        expect(updatedOptions[1][1][1].template).toBe("collection[1].template");
+    });
+
+    it("removes nested components dynamically", () => {
+        const renderItem = () => <div>Template</div>;
+        const items = [{id: 1, render: renderItem}, {id: 2, render: renderItem}];
+
+        const TestContainer = (props: any) => (
+            <TestComponent>
+                {props.items.map((item) => <CollectionNestedComponent key={item.id} render={item.render} />)}
+            </TestComponent>
+        );
+
+        const component = mount(<TestContainer items={items} />);
+        component.setProps({
+            items: items.slice(0, 1)
+        });
+
+        const updatedOptions = Widget.option.mock.calls;
+
+        expect(updatedOptions[1][0]).toBe("collection");
+        expect(updatedOptions[1][1].length).toBe(1);
+        expect(updatedOptions[1][1][0].template).toBe("collection[0].template");
+    });
+
+    xit("removes deleted tempalates from integrationOptions", () => {
+        const ItemTemplate = () => <div>Template</div>;
+        const items = [{id: 1, render: ItemTemplate}, {id: 2, render: ItemTemplate}];
+
+        const TestContainer = (props: any) => (
+            <TestComponent>
+                {props.items.map((item) => <CollectionNestedComponent key={item.id} render={item.render} />)}
+            </TestComponent>
+        );
+
+        const component = mount(<TestContainer items={items} />);
+        component.setProps({
+            items: items.slice(0, 1)
+        });
+
+        const updatedOptions = Widget.option.mock.calls;
+
+        expect(updatedOptions[0][0]).toBe("integrationOptions");
+        expect(Object.keys(updatedOptions[0][1].templates).length).toBe(1);
+        expect(Object.keys(updatedOptions[0][1].templates)[0]).toBe(
+            "collection[0].template"
+        );
+    });
 });