Repository: DevExpress/devextreme-vue (ts)

Issue:
Add .sync support for nested components
<!--
  Note: if the issue is a general Vue question, please check Vue Docs first: https://vuejs.org/v2/guide/
  If your question is about  DevExtreme widgets' APIs, use the DevExpress Support Center: https://www.devexpress.com/Support/Center
-->

### Issue Type
<!-- E.g. 'question', 'bug report', 'feature request' -->
feature request
### Description
<!-- Specify what exactly the issue is related to (e.g. a component name) -->
It would be great to support `.sync` syntax in nested components. 
Use case:
```html
<dx-data-grid>
  <dx-column dataField="staticColumn1"/>
  <dx-column dataField="orderedColumn" :sortOrder.sync="sortOrter" />
  <dx-column dataField="staticColumn2"/>
</dx-data-grid>
```


Pull request:
Add sync support for nested components
Resolve #128 

Patch:
diff --git a/example/components/grid-example.vue b/example/components/grid-example.vue
index 7c83fb1..8deb34b 100644
--- a/example/components/grid-example.vue
+++ b/example/components/grid-example.vue
@@ -1,20 +1,22 @@
 <template>
     <example-block title="dxDataGrid">
       <div class="flex-container">
+        <span class="label">Date Filter:</span>
+        <dx-date-box 
+          v-model="dateFilter"
+        />
+        <span class="label">Alternate Row Colors:</span>
         <dx-check-box 
-          v-model="alternateRowColors" 
-          text="Alternate Row Colors"
+          v-model="alternateRowColors"
         />
+        <span class="label">Filter Row Visible:</span>
         <dx-check-box 
-          v-model="filterRowVisible" 
-          text="Filter Row Visible"
+          v-model="filterRowVisible"
         />
-      </div>
-      <div>
-        Country Sort Order:&nbsp;
+        <span class="label">Country Sort Order:</span>
         <dx-switch
           :value.sync="countrySortOrderVal"
-          offText="desc"
+          offText="des"
           onText="asc"
         />
       </div>
@@ -46,7 +48,7 @@
         />
         <dx-column
           dataField="country"
-          :sortOrder="countrySortOrder"
+          :sortOrder.sync="countrySortOrder"
         />
         <dx-column
           dataField="region"
@@ -56,7 +58,7 @@
           dataField="date"
           dataType="date"
           selectedFilterOperation=">="
-          filterValue="2013/04/01"
+          :filterValue.sync="dateFilter"
           :width="150"
         />
         <dx-column
@@ -79,7 +81,7 @@
 import Vue from "vue";
 
 import ExampleBlock from "./example-block";
-import { DxButton, DxCheckBox, DxDataGrid, DxSwitch } from "../../src";
+import { DxButton, DxCheckBox, DxDataGrid, DxDateBox, DxSwitch } from "../../src";
 import {
   DxColumn,
   DxFilterRow,
@@ -102,6 +104,7 @@ export default {
     DxButton,
     DxCheckBox,
     DxDataGrid,
+    DxDateBox,
     DxSwitch,
     DxColumn,
     DxFilterRow,
@@ -112,12 +115,18 @@ export default {
     DxSelection
   },
   computed: {
-    countrySortOrder() {
-      return this.countrySortOrderVal ? "asc" : "desc";
+    countrySortOrder: {
+      get() {
+        return this.countrySortOrderVal ? "asc" : "desc";
+      },
+      set(newValue) {
+        this.countrySortOrderVal = newValue === "asc";
+      }
     }
   },
   data: function() {
     return {
+      dateFilter: "2013/04/01",
       sales: sales,
       selectedRowKeys: selectedKeys,
       alternateRowColors: true,
@@ -133,4 +142,14 @@ export default {
     display: flex;
     flex-direction: row;
   }
+  .label {
+    color: black;
+    padding-top: 0.6em;
+  }
+  .dx-checkbox {
+    padding-top: .4em;
+  }
+  .dx-switch {
+    padding-top: .2em;
+  }
 </style>
diff --git a/src/core/component.ts b/src/core/component.ts
index d49860b..f0fc578 100644
--- a/src/core/component.ts
+++ b/src/core/component.ts
@@ -3,7 +3,7 @@ import { VNode, VueConstructor } from "vue";
 
 import * as events from "devextreme/events";
 
-import Configuration, { bindOptionWatchers } from "./configuration";
+import Configuration, { bindOptionWatchers, subscribeOnUpdates } from "./configuration";
 import { IConfigurable, IConfigurationComponent } from "./configuration-component";
 import { camelize } from "./helpers";
 
@@ -46,19 +46,22 @@ const BaseComponent: VueConstructor = Vue.extend({
 
     methods: {
         $_createWidget(element: any): void {
+            const config = (this as any as IWidgetComponent).$_config;
             const options: object = {
                 ...this.$_getIntegrationOptions(),
                 ...this.$options.propsData,
-                ...(this as any as IWidgetComponent).$_config.getInitialValues()
+                ...config.getInitialValues()
             };
 
             const instance = new (this as any).$_WidgetClass(element, options);
             (this as any as IWidgetComponent).$_instance = instance;
 
-            instance.on("optionChanged", this.$_handleOptionChanged.bind(this));
-            bindOptionWatchers((this as any as IWidgetComponent).$_config, this);
+            instance.on("optionChanged", (args) => config.onOptionChanged(args));
+            subscribeOnUpdates(config, this);
+            bindOptionWatchers(config, this);
             this.$_createEmitters(instance);
         },
+
         $_getIntegrationOptions(): object {
             if (!this.$scopedSlots || !Object.keys(this.$scopedSlots).length) {
                 return {};
@@ -99,10 +102,6 @@ const BaseComponent: VueConstructor = Vue.extend({
             };
         },
 
-        $_handleOptionChanged(args: any): void {
-            this.$emit("update:" + args.name, args.value);
-        },
-
         $_createEmitters(instance: any): void {
             Object.keys(this.$listeners).forEach((listenerName: string) => {
                 const eventName = camelize(listenerName);
diff --git a/src/core/configuration-component.ts b/src/core/configuration-component.ts
index f5355b7..00f7c2e 100644
--- a/src/core/configuration-component.ts
+++ b/src/core/configuration-component.ts
@@ -3,7 +3,7 @@ import { VNode, VueConstructor } from "vue";
 
 const Vue = VueType.default || VueType;
 
-import Configuration, { bindOptionWatchers } from "./configuration";
+import Configuration, { bindOptionWatchers, subscribeOnUpdates } from "./configuration";
 
 interface IConfigurationComponent {
     $_optionName: string;
@@ -17,8 +17,11 @@ interface IConfigurable {
 const DxConfiguration: VueConstructor = Vue.extend({
 
     beforeMount() {
-        (this.$vnode.componentOptions as any as IConfigurable).$_config.init(Object.keys(this.$props));
-        bindOptionWatchers((this.$vnode.componentOptions as any as IConfigurable).$_config, this);
+        const config = (this.$vnode.componentOptions as any as IConfigurable).$_config;
+
+        config.init(Object.keys(this.$props));
+        bindOptionWatchers(config, this);
+        subscribeOnUpdates(config, this);
     },
 
     render(createElement: (...args) => VNode): VNode {
diff --git a/src/core/configuration.ts b/src/core/configuration.ts
index d5d02c1..d7c4f31 100644
--- a/src/core/configuration.ts
+++ b/src/core/configuration.ts
@@ -10,6 +10,7 @@ class Configuration {
     private readonly _collectionItemIndex: number | undefined;
     private readonly _initialValues: Record<string, any>;
     private readonly _updateFunc: UpdateFunc;
+    private _optionChangedFunc: any;
 
     private _options: string[];
 
@@ -34,6 +35,10 @@ class Configuration {
         return this._name;
     }
 
+    public get fullPath(): string | null {
+        return this._isCollectionItem ? `${this._name}[${this._collectionItemIndex}]` : this._name;
+    }
+
     public get options(): string[] {
         return this._options;
     }
@@ -62,6 +67,19 @@ class Configuration {
         this._options = options ? options : [];
     }
 
+    public set optionChangedFunc(handler: any) {
+        this._optionChangedFunc = handler;
+    }
+
+    public onOptionChanged(args: {name: string, fullName: string, value: any}): void {
+        if (this._optionChangedFunc) {
+            this._optionChangedFunc(args);
+        }
+        this._nestedConfigurations.forEach((nestedConfig) => {
+            nestedConfig.onOptionChanged(args);
+        });
+    }
+
     public createNested(
         name: string,
         initialValues: Record<string, any>,
@@ -86,8 +104,7 @@ class Configuration {
     }
 
     public updateValue(nestedName: string, value: any): void {
-        const name = this._isCollectionItem ? `${this._name}[${this._collectionItemIndex}]` : this._name;
-        const fullName = [name, nestedName].filter((n) => n).join(".");
+        const fullName = [this.fullPath, nestedName].filter((n) => n).join(".");
         this._updateFunc(fullName, value);
     }
 
@@ -135,5 +152,16 @@ function bindOptionWatchers(config: Configuration, vueInstance: Pick<Vue, "$watc
     }
 }
 
+function subscribeOnUpdates(config: Configuration, vueInstance: Pick<Vue, "$emit">): void {
+    config.optionChangedFunc = (args: any) => {
+        let optionName = args.name;
+        const fullOptionPath = config.fullPath + ".";
+        if (config.name && config.name === args.name && args.fullName.indexOf(fullOptionPath) === 0) {
+            optionName = args.fullName.slice(fullOptionPath.length);
+        }
+        vueInstance.$emit("update:" + optionName, args.value);
+    };
+}
+
 export default Configuration;
-export { bindOptionWatchers, UpdateFunc };
+export { bindOptionWatchers, subscribeOnUpdates, UpdateFunc };


Test patch:
diff --git a/src/core/configuration.test.ts b/src/core/configuration.test.ts
index 76d2778..c16cdde 100644
--- a/src/core/configuration.test.ts
+++ b/src/core/configuration.test.ts
@@ -1,4 +1,4 @@
-import Configuration, { bindOptionWatchers, UpdateFunc } from "./configuration";
+import Configuration, { bindOptionWatchers, subscribeOnUpdates, UpdateFunc } from "./configuration";
 
 function createRootConfig(updateFunc: UpdateFunc): Configuration {
     return new Configuration(updateFunc, null, {});
@@ -101,6 +101,47 @@ it("binds option watchers", () => {
     expect(updateValueFunc.mock.calls[0][1]).toBe(value);
 });
 
+it("subscribes on updates", () => {
+    const $emitFunc = jest.fn();
+
+    const config: any = {
+        name: null
+    };
+
+    subscribeOnUpdates(
+        config,
+        {
+            $emit: $emitFunc
+        }
+    );
+    config.optionChangedFunc({name: "option1", fullName: "option1", value: "value"});
+    expect($emitFunc.mock.calls[0][0]).toBe("update:option1");
+    expect($emitFunc.mock.calls[0][1]).toBe("value");
+
+    expect($emitFunc).toHaveBeenCalledTimes(1);
+});
+
+it("subscribes on updates of nested options", () => {
+    const $emitFunc = jest.fn();
+
+    const config: any = {
+        name: "widgetOption",
+        fullPath: "widgetOption[1]"
+    };
+
+    subscribeOnUpdates(
+        config,
+        {
+            $emit: $emitFunc
+        }
+    );
+    config.optionChangedFunc({name: "widgetOption", fullName: "widgetOption[1].option1", value: "value"});
+    expect($emitFunc.mock.calls[0][0]).toBe("update:option1");
+    expect($emitFunc.mock.calls[0][1]).toBe("value");
+
+    expect($emitFunc).toHaveBeenCalledTimes(1);
+});
+
 describe("initial configuration", () => {
 
     it("pulls value from nested", () => {