Repository: DevExpress/devextreme-vue
Pull request #364 (2020-04-14 18:48:07)

Fix dynamic collection components
Fix T870189

IlyaKhD: ![](https://github.trello.services/images/mini-trello-icon.png) [T870189 Form - The entire form is updated on an attempt to update an item inside the group](https://trello.com/c/AK1nBeAc/2207-t870189-form-the-entire-form-is-updated-on-an-attempt-to-update-an-item-inside-the-group)

Patch:
diff --git a/src/core/component.ts b/src/core/component.ts
index 60bba7f..481f2d2 100644
--- a/src/core/component.ts
+++ b/src/core/component.ts
@@ -8,7 +8,7 @@ import Configuration, { bindOptionWatchers, setEmitOptionChangedFunc } from "./c
 import { getConfig, getInnerChanges, IConfigurable, initOptionChangedFunc } from "./configuration-component";
 import { DX_REMOVE_EVENT } from "./constants";
 import { IExtension, IExtensionComponentNode } from "./extension-component";
-import { camelize, forEachChildNode, toComparable } from "./helpers";
+import { camelize, forEachChildNode, getOptionValue, toComparable } from "./helpers";
 import {
     IEventBusHolder
 } from "./templates-discovering";
@@ -23,6 +23,7 @@ interface IWidgetComponent extends IConfigurable {
 
 interface IBaseComponent extends IVue, IWidgetComponent, IEventBusHolder {
     $_isExtension: boolean;
+    $_applyConfigurationChanges: () => void;
     $_createWidget: (element: any) => void;
     $_getIntegrationOptions: () => void;
     $_getExtraIntegrationOptions: () => void;
@@ -95,31 +96,8 @@ const BaseComponent: VueConstructor<IBaseComponent> = Vue.extend({
         }
         (this as IBaseComponent).$_pendingOptions = {};
 
-        if (this.$_config.componentsCountChanged) {
-            const options = this.$_config.getNestedOptionValues();
-            const prevOptions = this.$_config.prevNestedOptions;
-            const optionsList = Object.keys(options);
-            const prevOptionsList = Object.keys(prevOptions);
-            if (optionsList.length < prevOptionsList.length) {
-                prevOptionsList.forEach((prevName) => {
-                    const hasOption = optionsList.some((name) => {
-                        return prevName === name;
-                    });
-
-                    if (!hasOption) {
-                        this.$_instance.resetOption(prevName);
-                    }
-                });
-            }
-
-            for (const name in options) {
-                if (options.hasOwnProperty(name)) {
-                    this.$_instance.option(name, options[name]);
-                }
-            }
+        this.$_applyConfigurationChanges();
 
-            this.$_config.componentsCountChanged = false;
-        }
         this.$_instance.endUpdate();
         this.eventBus.$emit("updated");
     },
@@ -145,6 +123,19 @@ const BaseComponent: VueConstructor<IBaseComponent> = Vue.extend({
     },
 
     methods: {
+        $_applyConfigurationChanges(): void {
+            this.$_config.componentsCountChanged.forEach(({ optionPath, isCollection, removed }) => {
+                const options = this.$_config.getNestedOptionValues();
+
+                if (!isCollection && removed) {
+                    this.$_instance.resetOption(optionPath);
+                } else {
+                    this.$_instance.option(optionPath, getOptionValue(options, optionPath));
+                }
+            });
+
+            this.$_config.cleanComponentsCountChanged();
+        },
         $_createWidget(element: any): void {
             const thisComponent = this as IBaseComponent;
 
diff --git a/src/core/configuration-component.ts b/src/core/configuration-component.ts
index 4cec07c..35b8de4 100644
--- a/src/core/configuration-component.ts
+++ b/src/core/configuration-component.ts
@@ -20,6 +20,12 @@ interface IConfigurable extends IConfigurationOwner {
     $_innerChanges: any;
 }
 
+interface IComponentInfo {
+    optionPath: string;
+    isCollection: boolean;
+    removed?: boolean;
+}
+
 function getConfig(vueInstance: Pick<IVue, "$vnode">): Configuration | undefined {
     if (!vueInstance.$vnode) {
         return;
@@ -49,6 +55,17 @@ function initOptionChangedFunc(config, vueInstance: Pick<IVue, "$vnode" | "$prop
     setEmitOptionChangedFunc(config, vueInstance, innerChanges);
 }
 
+function getComponentInfo({name, isCollectionItem, ownerConfig }: Configuration, removed?: boolean): IComponentInfo {
+    const parentPath =  ownerConfig && ownerConfig.fullPath;
+    const optionPath = name && parentPath ? `${parentPath}.${name}` : name || "";
+
+    return {
+        optionPath,
+        isCollection: isCollectionItem,
+        removed
+    };
+}
+
 const DxConfiguration: VueConstructor = Vue.extend({
     beforeMount() {
         const config = getConfig(this) as Configuration;
@@ -59,12 +76,14 @@ const DxConfiguration: VueConstructor = Vue.extend({
 
     mounted() {
         if ((this.$parent as any).$_instance) {
-            (this.$parent as any).$_config.componentsCountChanged = true;
+            (this.$parent as any).$_config.componentsCountChanged
+                .push(getComponentInfo(getConfig(this) as Configuration));
         }
     },
 
     beforeDestroy() {
-        (this.$parent as any).$_config.componentsCountChanged = true;
+        (this.$parent as any).$_config.componentsCountChanged
+            .push(getComponentInfo(getConfig(this) as Configuration, true));
     },
 
     render(createElement: (...args) => VNode): VNode {
@@ -72,4 +91,12 @@ const DxConfiguration: VueConstructor = Vue.extend({
     }
 });
 
-export { DxConfiguration, IConfigurable, IConfigurationComponent, initOptionChangedFunc, getConfig, getInnerChanges };
+export {
+    DxConfiguration,
+    IComponentInfo,
+    IConfigurable,
+    IConfigurationComponent,
+    initOptionChangedFunc,
+    getConfig,
+    getInnerChanges
+};
diff --git a/src/core/configuration.ts b/src/core/configuration.ts
index 726fabe..bb42a2b 100644
--- a/src/core/configuration.ts
+++ b/src/core/configuration.ts
@@ -1,4 +1,5 @@
 import { Vue } from "vue/types/vue";
+import { IComponentInfo } from "./configuration-component";
 import { getOptionInfo, isEqual } from "./helpers";
 
 type UpdateFunc = (name: string, value: any) => void;
@@ -28,7 +29,7 @@ class Configuration {
     private _nestedConfigurations: Configuration[];
     private _prevNestedConfigOptions: any;
     private _emitOptionChanged: EmitOptionChangedFunc;
-    private _componentsCountChanged: boolean;
+    private _componentChanges: IComponentInfo[];
 
     private _options: string[];
 
@@ -49,7 +50,7 @@ class Configuration {
         this._collectionItemIndex = collectionItemIndex;
         this._expectedChildren = expectedChildren || {};
         this._ownerConfig = ownerConfig;
-        this._componentsCountChanged = false;
+        this._componentChanges = [];
 
         this.updateValue = this.updateValue.bind(this);
     }
@@ -64,12 +65,12 @@ class Configuration {
             : this._name;
     }
 
-    public get hasOptionsToUpdate(): boolean {
-        return this._componentsCountChanged;
+    public get componentsCountChanged(): IComponentInfo[] {
+        return this._componentChanges;
     }
 
-    public set hasOptionsToUpdate(value: boolean) {
-        this._componentsCountChanged = value;
+    public cleanComponentsCountChanged() {
+        this._componentChanges = [];
     }
 
     public get fullPath(): string | null {
@@ -78,6 +79,10 @@ class Configuration {
             : this.fullName;
     }
 
+    public get ownerConfig(): Pick<Configuration, "fullPath"> | undefined {
+        return this._ownerConfig;
+    }
+
     public get options(): string[] {
         return this._options;
     }
diff --git a/src/core/helpers.ts b/src/core/helpers.ts
index 51b2edd..a3f1f61 100644
--- a/src/core/helpers.ts
+++ b/src/core/helpers.ts
@@ -49,6 +49,19 @@ export function allKeysAreEqual(obj1: object, obj2: object) {
     return true;
 }
 
+export function getOptionValue(options, optionPath) {
+    let value = options;
+
+    optionPath.split(".").forEach((p) => {
+        const optionInfo = getOptionInfo(p);
+        value = optionInfo.isCollection ?
+            value[optionInfo.name] && value[optionInfo.name][optionInfo.index] :
+            value[optionInfo.name];
+    });
+
+    return value;
+}
+
 export function getOptionInfo(name: string): IOptionInfo | ICollectionOptionInfo {
     const parts = name.split("[");
 


Test patch:
diff --git a/src/core/component.test.ts b/src/core/component.test.ts
index 2a96783..db3e106 100644
--- a/src/core/component.test.ts
+++ b/src/core/component.test.ts
@@ -841,15 +841,85 @@ describe("nested option", () => {
     });
 
     it("remove nested component by condition", (done) => {
+        const nestedCollectionItem = buildTestConfigCtor();
+        (nestedCollectionItem as any as IConfigurationComponent).$_optionName = "nestedOption";
+        (nestedCollectionItem as any as IConfigurationComponent).$_isCollectionItem = true;
         const vm = new Vue({
             template:
                 `<test-component>` +
-                `  <nested v-if="showNest" :prop1="123" />` +
-                `  <nested :prop1="321" />` +
+                `  <nested-collection-item v-if="showNest" :prop1="123" />` +
+                `  <nested-collection-item :prop1="321" />` +
                 `</test-component>`,
             components: {
                 TestComponent,
-                Nested
+                nestedCollectionItem
+            },
+            data: {
+                showNest: true
+            }
+        }).$mount();
+
+        vm.$data.showNest = false;
+
+        Vue.nextTick(() => {
+            expect(Widget.option).toHaveBeenCalledWith("nestedOption", [{ prop1: 321 }]);
+            done();
+        });
+    });
+
+    it("should update only part of collection components", (done) => {
+        const nestedCollectionItem = buildTestConfigCtor();
+        (nestedCollectionItem as any as IConfigurationComponent).$_optionName = "nestedOption";
+        (nestedCollectionItem as any as IConfigurationComponent).$_isCollectionItem = true;
+        const vm = new Vue({
+            template:
+                `<test-component>` +
+                `  <nested-collection-item>` +
+                `     <nested-collection-item>` +
+                `       <nested-collection-item v-if="showNest" :prop1="123">` +
+                `       </nested-collection-item>` +
+                `       <nested-collection-item :prop1="321">` +
+                `       </nested-collection-item>` +
+                `     </nested-collection-item>` +
+                `  </nested-collection-item>` +
+                `</test-component>`,
+            components: {
+                TestComponent,
+                nestedCollectionItem
+            },
+            data: {
+                showNest: true
+            }
+        }).$mount();
+
+        vm.$data.showNest = false;
+
+        Vue.nextTick(() => {
+            expect(Widget.option)
+                .toHaveBeenCalledWith("nestedOption[0].nestedOption[0].nestedOption", [{ prop1: 321 }]);
+            done();
+        });
+    });
+
+    it("should update only part of collection components (remove all subnested)", (done) => {
+        const nestedCollectionItem = buildTestConfigCtor();
+        (nestedCollectionItem as any as IConfigurationComponent).$_optionName = "nestedOption";
+        (nestedCollectionItem as any as IConfigurationComponent).$_isCollectionItem = true;
+        const vm = new Vue({
+            template:
+                `<test-component>` +
+                `  <nested-collection-item>` +
+                `     <nested-collection-item>` +
+                `       <nested-collection-item v-if="showNest" :prop1="123">` +
+                `       </nested-collection-item>` +
+                `       <nested-collection-item v-if="showNest" :prop1="321">` +
+                `       </nested-collection-item>` +
+                `     </nested-collection-item>` +
+                `  </nested-collection-item>` +
+                `</test-component>`,
+            components: {
+                TestComponent,
+                nestedCollectionItem
             },
             data: {
                 showNest: true
@@ -859,7 +929,7 @@ describe("nested option", () => {
         vm.$data.showNest = false;
 
         Vue.nextTick(() => {
-            expect(Widget.option).toHaveBeenCalledWith("nestedOption", { prop1: 321 });
+            expect(Widget.option).toHaveBeenCalledWith("nestedOption[0].nestedOption[0].nestedOption", undefined);
             done();
         });
     });
diff --git a/src/core/helpers.test.ts b/src/core/helpers.test.ts
index a988d25..e9e918d 100644
--- a/src/core/helpers.test.ts
+++ b/src/core/helpers.test.ts
@@ -1,4 +1,4 @@
-import { allKeysAreEqual, getOptionInfo, isEqual, toComparable } from "./helpers";
+import { allKeysAreEqual, getOptionInfo, getOptionValue, isEqual, toComparable } from "./helpers";
 
 describe("toComparable", () => {
 
@@ -70,6 +70,69 @@ describe("allKeysAreEqual", () => {
     });
 });
 
+describe("getOptionValue", () => {
+    it("returns for simple option", () => {
+        const optionValue = getOptionValue({ test: "text" }, "test");
+
+        expect(optionValue).toEqual("text");
+    });
+
+    it("returns for complex option", () => {
+        const optionValue = getOptionValue({ test: { value: "text" } }, "test");
+        const optionValue1 = getOptionValue({ test: { value: "text" } }, "test.value");
+
+        expect(optionValue).toEqual({ value: "text" });
+        expect(optionValue1).toEqual("text");
+    });
+
+    it("returns for complex option", () => {
+        const optionValue = getOptionValue({ test: { value: "text" } }, "test");
+
+        expect(optionValue).toEqual({ value: "text" });
+    });
+
+    it("returns for collection option", () => {
+        const value = [
+            { text: "value1"},
+            { text: "value2"},
+            { text: "value3",
+              test: [{
+               option: {
+                   text: "value1"
+               }
+            }, {
+               text: "value2"
+            }] }
+        ];
+
+        const optionValue1 = getOptionValue({ test: value }, "test[1]");
+        const optionValue2 = getOptionValue({ test: value }, "test");
+        const optionValue3 = getOptionValue({ test: value }, "test[2].test[1]");
+        const optionValue4 = getOptionValue({ test: value }, "test[2].test");
+        const optionValue5 = getOptionValue({ test: value }, "test[2].test[0].option");
+        const optionValue6 = getOptionValue({ test: value }, "test[2].test[0].option.text");
+
+        expect(optionValue1).toEqual({ text: "value2" });
+        expect(optionValue2).toEqual(value);
+        expect(optionValue3).toEqual({ text: "value2" });
+        expect(optionValue4).toEqual([{
+            option: { text: "value1" }
+         }, {
+            text: "value2"
+         }]);
+        expect(optionValue5).toEqual({ text: "value1" });
+        expect(optionValue6).toEqual("value1");
+    });
+
+    it("returns for empty", () => {
+        const optionValue = getOptionValue({}, "test");
+        const optionValue2 = getOptionValue({ test: [{ text: "value1" }] }, "test[1]");
+
+        expect(optionValue).toEqual(undefined);
+        expect(optionValue2).toEqual(undefined);
+    });
+});
+
 describe("getOptionInfo", () => {
     it("returns for simple option", () => {
         const optionInfo = getOptionInfo("test");