Repository: DevExpress/devextreme-vue (ts)

Issue:
dxValidator not work outside dx 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
-->

### Description
<!-- Specify what exactly the issue is related to (e.g. a component name) -->
For implement additional validation logic my custom editor, I place dx-validator inside DIV and set adapter.

#### Current Behavior
Validator not work. Property instace == undefined

#### Expected Behavior
Validate data 
#### Demo
<!--
  If you provide a minimal demo of the problem\proposal, it helps us to solve it much faster.
  Paste the link to your online code editor (like https://stackblitz.com) below:
-->

### Environment Details
<!--
Specify DevExtreme version, DevExtreme Vue Components version, Vue version and other environment details or notes you consider important.
-->
- devextreme version: 18.1.7
- devextreme-vue version: 18.1.7-alpaha.10
- vue version: 2.5.7


Pull request description:
Make it possible to instantiate a standalone dxValidator
Resolves #195 

Patch:
diff --git a/example/components/validation-example.vue b/example/components/validation-example.vue
index 3753a85..db66765 100644
--- a/example/components/validation-example.vue
+++ b/example/components/validation-example.vue
@@ -17,33 +17,91 @@
       <dx-validation-summary />
       <dx-button text="Submit" @click="validate"/>
     </dx-validation-group>
+    <br/>
+    <br/>
+    <h3>Custom value validation</h3>
+    <br/>
+    <div>
+      <button @click="validateCustom">Validate custom value</button>
+      <input v-model="customValue" />
+      <br/>
+      <div>Not empty: {{customValueIsNotEmpty}}</div>
+      <div>Not less than 0, not greater than 9: {{customValueIsDigit}}</div>
+      <div>Is valid: {{customValueIsValid}}</div>
+      <dx-validator ref="customValidator">
+        <dx-adapter :get-value="getCustomValue" />
+        <dx-required-rule message="Value is required." />
+      </dx-validator>
+    </div>
   </example-block>
 </template>
 
 <script>
 import ExampleBlock from "./example-block";
 
-import { DxButton, DxTextBox, DxValidationGroup, DxValidationSummary } from "../../src";
-import { DxValidator, DxEmailRule, DxRequiredRule} from "../../src/validator";
+import {
+  DxButton,
+  DxTextBox,
+  DxValidationGroup,
+  DxValidationSummary,
+  DxValidator
+} from "../../src";
+import { DxAdapter, DxEmailRule, DxRequiredRule } from "../../src/validator";
 
 export default {
   components: {
     ExampleBlock,
     DxButton,
-    DxTextBox, 
+    DxTextBox,
     DxValidator,
+    DxAdapter,
     DxEmailRule,
     DxRequiredRule,
     DxValidationGroup,
     DxValidationSummary
   },
+  created() {
+    const validatorComponent = new DxValidator({
+      propsData: {
+        adapter: {
+          getValue: this.getCustomValue
+        },
+        validationRules: [
+          { type: "range", min: 0, max: 9, message: "From 1 to 10" }
+        ]
+      }
+    }).$mount();
+
+    this.validator = validatorComponent.instance;
+  },
+  data() {
+    return {
+      customValue: 1,
+      customValueIsDigit: true,
+      customValueIsNotEmpty: true
+    };
+  },
+  computed: {
+    customValueIsValid() {
+      return this.customValueIsNotEmpty && this.customValueIsDigit;
+    }
+  },
   methods: {
     validate(params) {
       const result = params.validationGroup.validate();
       if (result.isValid) {
-          // form data is valid
-          params.validationGroup.reset();
+        // form data is valid
+        params.validationGroup.reset();
       }
+    },
+    getCustomValue() {
+      return this.customValue;
+    },
+    validateCustom() {
+      var result1 = this.$refs.customValidator.instance.validate();
+      var result2 = this.validator.validate();
+      this.customValueIsNotEmpty = result1.isValid;
+      this.customValueIsDigit = result2.isValid;
     }
   }
 };
diff --git a/src/core/component.ts b/src/core/component.ts
index a552435..70dcf51 100644
--- a/src/core/component.ts
+++ b/src/core/component.ts
@@ -6,6 +6,7 @@ import * as events from "devextreme/events";
 import { pullAllChildren } from "./children-processing";
 import Configuration, { bindOptionWatchers, subscribeOnUpdates } from "./configuration";
 import { IConfigurable } from "./configuration-component";
+import { IExtension, IExtensionComponentNode } from "./extension-component";
 import { camelize, toComparable } from "./helpers";
 
 interface IWidgetComponent extends IConfigurable {
@@ -25,6 +26,7 @@ const BaseComponent: VueConstructor = Vue.extend({
     render(createElement: (...args) => VNode): VNode {
         const children: VNode[] = [];
         pullAllChildren(this.$slots.default, children, (this as any as IWidgetComponent).$_config);
+        this.$_processChildren(children);
 
         return createElement(
             "div",
@@ -117,6 +119,10 @@ const BaseComponent: VueConstructor = Vue.extend({
             return {};
         },
 
+        $_processChildren(_children: VNode[]): void {
+            return;
+        },
+
         $_fillTemplate(template: any, name: string): object {
             return {
                 render: (data: any) => {
@@ -158,13 +164,21 @@ const DxComponent: VueConstructor = BaseComponent.extend({
                     (this as any).beginUpdate();
                 }
             };
-        }
+        },
+
+        $_processChildren(children: VNode[]): void {
+            children.forEach((childNode: VNode) => {
+                if (!childNode.componentOptions) { return; }
+
+                (childNode.componentOptions as any as IExtensionComponentNode).$_hasOwner = true;
+            });
+        },
     },
 
     mounted(): void {
         (this as any).$_createWidget(this.$el);
         (this as any as IWidgetComponent).$_instance.endUpdate();
-        this.$children.forEach((child: any) => {
+        this.$children.forEach((child: IExtension) => {
             if (child.$_isExtension) {
                 child.attachTo(this.$el);
             }
diff --git a/src/core/extension-component.ts b/src/core/extension-component.ts
index c96bfc4..719afae 100644
--- a/src/core/extension-component.ts
+++ b/src/core/extension-component.ts
@@ -1,12 +1,27 @@
 import { VueConstructor } from "vue";
 import { BaseComponent } from "./component";
 
+interface IExtension {
+    $_isExtension: boolean;
+    attachTo(element: any);
+}
+
+interface IExtensionComponentNode {
+    $_hasOwner: boolean;
+}
+
 const DxExtensionComponent: VueConstructor = BaseComponent.extend({
 
     created(): void {
         (this as any).$_isExtension = true;
     },
 
+    mounted() {
+        if (this.$vnode && (this.$vnode.componentOptions as any as IExtensionComponentNode).$_hasOwner) { return; }
+
+        this.attachTo(this.$el);
+    },
+
     methods: {
         attachTo(element: any) {
             (this as any).$_createWidget(element);
@@ -14,4 +29,8 @@ const DxExtensionComponent: VueConstructor = BaseComponent.extend({
     }
 });
 
-export { DxExtensionComponent };
+export {
+    DxExtensionComponent,
+    IExtension,
+    IExtensionComponentNode
+};


Test patch:
diff --git a/src/core/component.test.ts b/src/core/component.test.ts
index fa9d2a4..18bc3d1 100644
--- a/src/core/component.test.ts
+++ b/src/core/component.test.ts
@@ -1017,22 +1017,32 @@ describe("extension component", () => {
         }
     });
 
-    it("doesn't render", () => {
-        new TestExtensionComponent().$mount();
+    it("renders once if mounted manually and targets self element", () => {
+        const component = new TestExtensionComponent().$mount();
 
-        expect(ExtensionWidgetClass).toHaveBeenCalledTimes(0);
+        const expectedElement = component.$el;
+        const actualElement = ExtensionWidgetClass.mock.calls[0][0];
 
-        expect(Widget.beginUpdate).toHaveBeenCalledTimes(0);
-        expect(Widget.endUpdate).toHaveBeenCalledTimes(0);
+        expect(ExtensionWidgetClass).toHaveBeenCalledTimes(1);
+        expect(actualElement).toBe(expectedElement);
     });
 
-    it("destroys correctly", () => {
-        const component = new TestExtensionComponent().$mount();
+    it("renders once without parent element and targets self element", () => {
+        const vue = new Vue({
+            template: `<test-extension-component/>`,
+            components: {
+                TestExtensionComponent
+            }
+        }).$mount();
 
-        expect(component.$destroy.bind(component)).not.toThrow();
+        const expectedElement = vue.$el;
+        const actualElement = ExtensionWidgetClass.mock.calls[0][0];
+
+        expect(ExtensionWidgetClass).toHaveBeenCalledTimes(1);
+        expect(actualElement).toBe(expectedElement);
     });
 
-    it("renders inside component on parent element", () => {
+    it("renders once inside component and targets parent element", () => {
         new Vue({
             template: `<test-component>
                             <test-extension-component/>
@@ -1043,8 +1053,17 @@ describe("extension component", () => {
             }
         }).$mount();
 
+        const expectedElement = WidgetClass.mock.calls[0][0];
+        const actualElement = ExtensionWidgetClass.mock.calls[0][0];
+
         expect(ExtensionWidgetClass).toHaveBeenCalledTimes(1);
-        expect(ExtensionWidgetClass.mock.calls[0][0]).toBe(WidgetClass.mock.calls[0][0]);
+        expect(actualElement).toBe(expectedElement);
+    });
+
+    it("destroys correctly", () => {
+        const component = new TestExtensionComponent().$mount();
+
+        expect(component.$destroy.bind(component)).not.toThrow();
     });
 });