Repository: DevExpress/devextreme-react (js)

Issue:
It's impossible to use DevExtreme Validator
<!--
  Note: if the issue is a general React question, please check React Docs first: https://reactjs.org/docs
  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' -->
bug report

#### Steps to Reproduce
<!-- If the current behavior is a bug, please provide the steps to reproduce -->
Add Validator to any editor like TextBox
```jsx
      <TextBox defaultValue={"required"}>
        <Validator validationRules={this.validationRules}/>
      </TextBox>
```
#### Current Behavior
Error in the console: E0120 - Adapter for a DevExpressValidator component cannot be configured. 
#### Expected Behavior
No errors. Validation rules are applied to editor.
#### 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:
-->
https://stackblitz.com/edit/devextreme-react-bpyjro?file=index.js


Pull request description:
Add extension component
Fix #115 

Patch:
diff --git a/example/text-box-example.tsx b/example/text-box-example.tsx
index 21d0a3b..b966e1c 100644
--- a/example/text-box-example.tsx
+++ b/example/text-box-example.tsx
@@ -4,11 +4,17 @@ import Example from "./example-block";
 import dxTextBox from "devextreme/ui/text_box";
 import { Button } from "../src/ui/button";
 import { TextBox } from "../src/ui/text-box";
+import { Validator } from "../src/ui/validator";
 
 export default class extends React.Component<any, { text: string; uncontrolledText: string; }> {
 
     private textBox: dxTextBox;
 
+    private validationRules = [{
+        type: "required",
+        message: "this is required"
+    }];
+
     constructor(props: any) {
         super(props);
         this.state = {
@@ -39,6 +45,11 @@ export default class extends React.Component<any, { text: string; uncontrolledTe
                 <br />
                 controlled state value with change handling
                 <TextBox value={this.state.text} onValueChanged={this.handleChange} valueChangeEvent="input" />
+                <br />
+                validation (required)
+                <TextBox valueChangeEvent="input" defaultValue={"required text"}>
+                    <Validator validationRules={this.validationRules} />
+                </TextBox>
             </Example>
         );
     }
diff --git a/src/core/component.ts b/src/core/component.ts
index 908ab1f..dec40f2 100644
--- a/src/core/component.ts
+++ b/src/core/component.ts
@@ -32,10 +32,11 @@ interface IState {
   templates: Record<string, IWrappedTemplateInfo>;
 }
 
-class Component<P> extends React.PureComponent<P, IState> {
-
+abstract class ComponentBase<P> extends React.PureComponent<P, IState> {
   protected _WidgetClass: any;
   protected _instance: any;
+  protected _element: any;
+
   protected readonly _defaults: Record<string, string>;
 
   protected readonly _templateProps: ITemplateMeta[] = [];
@@ -43,8 +44,6 @@ class Component<P> extends React.PureComponent<P, IState> {
   private readonly _templateHelper: TemplateHelper;
   private readonly _optionsManager: OptionsManager;
 
-  private _element: any;
-
   constructor(props: P) {
     super(props);
     this._prepareProps = this._prepareProps.bind(this);
@@ -80,7 +79,7 @@ class Component<P> extends React.PureComponent<P, IState> {
               ...nestedTemplates,
               ...this.findNestedTemplate(child)
             };
-            args.push(this._registerNestedOption(child) || child);
+            args.push(this._preprocessChild(child) || child);
           });
       }
 
@@ -100,7 +99,17 @@ class Component<P> extends React.PureComponent<P, IState> {
       return React.createElement.apply(this, args);
   }
 
-  public componentDidMount() {
+  public componentWillUnmount() {
+    if (this._instance) {
+      events.triggerHandler(this._element, DX_REMOVE_EVENT);
+      this._instance.dispose();
+    }
+  }
+
+  protected abstract _preprocessChild(component: React.ReactElement<any>): React.ReactElement<any>;
+
+  protected _createWidget(element?: Element) {
+    element = element || this._element;
     const nestedProps = this._optionsManager.getNestedOptionsObjects();
     const props = {
         ...(this.props as any),
@@ -116,14 +125,40 @@ class Component<P> extends React.PureComponent<P, IState> {
       ...this._getIntegrationOptions(preparedProps.templates, preparedProps.nestedTemplates)
     };
 
-    this._instance = new this._WidgetClass(this._element, options);
+    this._instance = new this._WidgetClass(element, options);
     this._optionsManager.setInstance(this._instance);
     this._instance.on("optionChanged", this._optionsManager.handleOptionChange);
   }
 
-  public componentWillUnmount() {
-    events.triggerHandler(this._element, DX_REMOVE_EVENT);
-    this._instance.dispose();
+  protected _registerNestedOption(component: React.ReactElement<any>): any {
+    const configComponent = component as any as INestedOption;
+    if (
+      configComponent && configComponent.type &&
+      configComponent.type.OptionName &&
+      configComponent.type.OwnerType && this instanceof configComponent.type.OwnerType
+    ) {
+      const optionName = configComponent.type.OptionName;
+
+      this._optionsManager.addNestedOption(
+          optionName,
+          component,
+          configComponent.type.DefaultsProps,
+          configComponent.type.IsCollectionItem
+      );
+
+      return createConfigurationComponent(
+        component,
+        (newProps, prevProps) => {
+          const newOptions = separateProps(newProps, configComponent.type.DefaultsProps, []).options;
+          this._optionsManager.processChangedValues(
+            addPrefixToKeys(newOptions, optionName + "."),
+            addPrefixToKeys(prevProps, optionName + ".")
+          );
+        }
+      );
+    }
+
+    return null;
   }
 
   private _prepareProps(rawProps: Record<string, any>): IWidgetConfig {
@@ -215,38 +250,48 @@ class Component<P> extends React.PureComponent<P, IState> {
       return result;
     }
   }
+}
 
-  private _registerNestedOption(component: React.ReactElement<any>): any {
-    const configComponent = component as any as INestedOption;
-    if (
-      configComponent && configComponent.type &&
-      configComponent.type.OptionName &&
-      configComponent.type.OwnerType && this instanceof configComponent.type.OwnerType
-    ) {
-      const optionName = configComponent.type.OptionName;
+// tslint:disable-next-line:max-classes-per-file
+class Component<P> extends ComponentBase<P> {
+  private readonly _extensions: Array<(element: Element) => void> = [];
 
-      this._optionsManager.addNestedOption(
-          optionName,
-          component,
-          configComponent.type.DefaultsProps,
-          configComponent.type.IsCollectionItem
-      );
+  public componentDidMount() {
+    this._createWidget();
+    this._extensions.forEach((extension) => extension.call(this, this._element));
+  }
 
-      return createConfigurationComponent(
-        component,
-        (newProps, prevProps) => {
-          const newOptions = separateProps(newProps, configComponent.type.DefaultsProps, []).options;
-          this._optionsManager.processChangedValues(
-            addPrefixToKeys(newOptions, optionName + "."),
-            addPrefixToKeys(prevProps, optionName + ".")
-          );
-        }
-      );
+  protected _preprocessChild(component: React.ReactElement<any>) {
+    return this._registerExtension(component) || this._registerNestedOption(component) || component;
+  }
+
+  private _registerExtension(component: React.ReactElement<any>) {
+    if (!ExtensionComponent.isPrototypeOf(component.type)) {
+      return null;
     }
 
-    return null;
+    return React.cloneElement(component, {
+      onMounted: (callback: any) => {
+        this._extensions.push(callback);
+      }
+    });
+  }
+}
+
+// tslint:disable-next-line:max-classes-per-file
+class ExtensionComponent<P> extends ComponentBase<P> {
+  public componentDidMount() {
+    const onMounted = (this.props as Record<string, any>).onMounted;
+    if (onMounted) {
+      onMounted((element) => {
+        this._createWidget(element);
+      });
+    }
+  }
+
+  protected _preprocessChild(component: React.ReactElement<any>) {
+    return component;
   }
 }
 
-export default Component;
-export { IState, ITemplateMeta };
+export { IState, ITemplateMeta, ComponentBase, Component, ExtensionComponent };
diff --git a/src/core/template-helper.ts b/src/core/template-helper.ts
index 049be07..64b8226 100644
--- a/src/core/template-helper.ts
+++ b/src/core/template-helper.ts
@@ -1,6 +1,6 @@
 import * as React from "react";
 
-import Component, { IState } from "./component";
+import { ComponentBase, IState } from "./component";
 import { generateID } from "./helpers";
 import { ITemplateWrapperProps, TemplateWrapper } from "./template-wrapper";
 
@@ -26,9 +26,9 @@ interface IWrappedTemplateInfo extends ITemplateInfo {
 }
 
 class TemplateHelper {
-    private readonly _component: Component<any>;
+    private readonly _component: ComponentBase<any>;
 
-    constructor(component: Component<any>) {
+    constructor(component: ComponentBase<any>) {
         this._component = component;
 
         this.wrapTemplate = this.wrapTemplate.bind(this);
diff --git a/tools/integration-data-model.ts b/tools/integration-data-model.ts
index 703f378..9d41e89 100644
--- a/tools/integration-data-model.ts
+++ b/tools/integration-data-model.ts
@@ -6,6 +6,7 @@ export interface IModel {
 export interface IWidget {
   exportPath: string;
   isEditor: boolean;
+  isExtension: boolean;
   name: string;
   options: IProp[];
   templates: string[];
diff --git a/tools/integration-data.json b/tools/integration-data.json
index 3082fcd..ca514a7 100644
--- a/tools/integration-data.json
+++ b/tools/integration-data.json
@@ -91348,7 +91348,8 @@
             }
           ]
         }
-      ]
+      ],
+      "isExtension": true
     },
     {
       "name": "DxVectorMap",
diff --git a/tools/src/component-generator.ts b/tools/src/component-generator.ts
index 945e022..d64f733 100644
--- a/tools/src/component-generator.ts
+++ b/tools/src/component-generator.ts
@@ -17,6 +17,7 @@ interface IComponent {
     baseComponentPath: string;
     configComponentPath: string;
     dxExportPath: string;
+    isExtension?: boolean;
     subscribableOptions?: IOption[];
     nestedComponents?: INestedComponent[];
     templates?: string[];
@@ -121,6 +122,7 @@ function generate(component: IComponent): string {
         renderedImports: renderImports({
             dxExportPath: component.dxExportPath,
             baseComponentPath: component.baseComponentPath,
+            baseComponentName: component.isExtension ? "ExtensionComponent" : "Component",
             configComponentPath: component.configComponentPath,
             widgetName,
             optionsAliasName: hasExtraOptions ? undefined : optionsName,
@@ -202,6 +204,7 @@ const renderImports: (model: {
     dxExportPath: string;
     configComponentPath: string;
     baseComponentPath: string;
+    baseComponentName: string;
     widgetName: string;
     optionsAliasName: string;
     hasExtraOptions: boolean;
@@ -216,7 +219,7 @@ const renderImports: (model: {
     `import { PropTypes } from "prop-types";` + `\n` +
 `<#?#>` +
 
-`import BaseComponent from "<#= it.baseComponentPath #>";` + `\n` +
+`import { <#= it.baseComponentName #> as BaseComponent } from "<#= it.baseComponentPath #>";` + `\n` +
 
 `<#? it.hasNestedComponents #>` +
     `import NestedOption from "<#= it.configComponentPath #>";` + `\n` +
diff --git a/tools/src/generator.ts b/tools/src/generator.ts
index c75b656..d57e4ac 100644
--- a/tools/src/generator.ts
+++ b/tools/src/generator.ts
@@ -71,6 +71,7 @@ function mapWidget(
       baseComponentPath: baseComponent,
       configComponentPath: configComponent,
       dxExportPath: raw.exportPath,
+      isExtension: raw.isExtension,
       templates: raw.templates,
       subscribableOptions: subscribableOptions.length > 0 ? subscribableOptions : null,
       nestedComponents: nestedOptions.length > 0 ? nestedOptions : null,


Test patch:
diff --git a/src/core/component.test.tsx b/src/core/component.test.tsx
index e545a96..0827d96 100644
--- a/src/core/component.test.tsx
+++ b/src/core/component.test.tsx
@@ -2,7 +2,7 @@ import * as events from "devextreme/events";
 import { configure as configureEnzyme, mount, shallow } from "enzyme";
 import * as Adapter from "enzyme-adapter-react-16";
 import * as React from "react";
-import Component from "../core/component";
+import { Component, ExtensionComponent } from "../core/component";
 import ConfigurationComponent from "../core/nested-option";
 import { Template } from "../core/template";
 
@@ -52,7 +52,7 @@ describe("component rendering", () => {
         expect(component.type()).toBe("div");
     });
 
-    it("create widget then on componnetDidMount", () => {
+    it("create widget on componentDidMount", () => {
         shallow(
             <TestComponent />
         );
@@ -98,6 +98,47 @@ describe("component rendering", () => {
     });
 });
 
+describe("extension component", () => {
+    const ExtensionWidgetClass = jest.fn(() => Widget);
+
+    // tslint:disable-next-line:max-classes-per-file
+    class TestExtensionComponent<P = any> extends ExtensionComponent<P> {
+
+        constructor(props: P) {
+            super(props);
+
+            this._WidgetClass = ExtensionWidgetClass;
+        }
+    }
+
+    it("does not create widget on componentDidMount", () => {
+        shallow(
+            <TestExtensionComponent />
+        );
+
+        expect(ExtensionWidgetClass).toHaveBeenCalledTimes(0);
+    });
+
+    it("creates widget on componentDidMount inside another component on same element", () => {
+        mount(
+            <TestComponent>
+                <TestExtensionComponent />
+            </TestComponent>
+        );
+
+        expect(ExtensionWidgetClass).toHaveBeenCalledTimes(1);
+        expect(ExtensionWidgetClass.mock.calls[0][0]).toBe(WidgetClass.mock.calls[0][0]);
+    });
+
+    it("unmounts without errors", () => {
+        const component = shallow(
+            <TestExtensionComponent/>
+        );
+
+        expect(component.unmount.bind(component)).not.toThrow();
+    });
+});
+
 describe("templates", () => {
     function renderTemplate(name: string, model?: any, container?: any): Element {
         model = model || {};
diff --git a/tools/src/component-generator.test.ts b/tools/src/component-generator.test.ts
index d4cd5f7..cf4013c 100644
--- a/tools/src/component-generator.test.ts
+++ b/tools/src/component-generator.test.ts
@@ -7,7 +7,7 @@ import dxCLASS_NAME, {
     IOptions as ICLASS_NAMEOptions
 } from "devextreme/DX/WIDGET/PATH";
 
-import BaseComponent from "BASE_COMPONENT_PATH";
+import { Component as BaseComponent } from "BASE_COMPONENT_PATH";
 
 class CLASS_NAME extends BaseComponent<ICLASS_NAMEOptions> {
 
@@ -34,6 +34,41 @@ export {
     ).toBe(EXPECTED);
 });
 
+it("generates extension component", () => {
+    //#region EXPECTED
+    const EXPECTED = `
+import dxCLASS_NAME, {
+    IOptions as ICLASS_NAMEOptions
+} from "devextreme/DX/WIDGET/PATH";
+
+import { ExtensionComponent as BaseComponent } from "BASE_COMPONENT_PATH";
+
+class CLASS_NAME extends BaseComponent<ICLASS_NAMEOptions> {
+
+  public get instance(): dxCLASS_NAME {
+    return this._instance;
+  }
+
+  protected _WidgetClass = dxCLASS_NAME;
+}
+export {
+  CLASS_NAME,
+  ICLASS_NAMEOptions
+};
+`.trimLeft();
+    //#endregion
+
+    expect(
+        generate({
+            name: "CLASS_NAME",
+            baseComponentPath: "BASE_COMPONENT_PATH",
+            configComponentPath: null,
+            dxExportPath: "DX/WIDGET/PATH",
+            isExtension: true
+        })
+    ).toBe(EXPECTED);
+});
+
 describe("template-props generation", () => {
 
     it("processes option", () => {
@@ -43,7 +78,7 @@ import dxCLASS_NAME, {
     IOptions
 } from "devextreme/DX/WIDGET/PATH";
 
-import BaseComponent from "BASE_COMPONENT_PATH";
+import { Component as BaseComponent } from "BASE_COMPONENT_PATH";
 
 interface ICLASS_NAMEOptions extends IOptions {
   optionRender?: (props: any) => React.ReactNode;
@@ -89,7 +124,7 @@ import dxCLASS_NAME, {
     IOptions
 } from "devextreme/DX/WIDGET/PATH";
 
-import BaseComponent from "BASE_COMPONENT_PATH";
+import { Component as BaseComponent } from "BASE_COMPONENT_PATH";
 
 interface ICLASS_NAMEOptions extends IOptions {
   optionRender?: (props: any) => React.ReactNode;
@@ -141,7 +176,7 @@ import dxCLASS_NAME, {
     IOptions
 } from "devextreme/DX/WIDGET/PATH";
 
-import BaseComponent from "BASE_COMPONENT_PATH";
+import { Component as BaseComponent } from "BASE_COMPONENT_PATH";
 
 interface ICLASS_NAMEOptions extends IOptions {
   render?: (props: any) => React.ReactNode;
@@ -190,7 +225,7 @@ import dxCLASS_NAME, {
     IOptions
 } from "devextreme/DX/WIDGET/PATH";
 
-import BaseComponent from "BASE_COMPONENT_PATH";
+import { Component as BaseComponent } from "BASE_COMPONENT_PATH";
 
 interface ICLASS_NAMEOptions extends IOptions {
   defaultOption1?: someType;
@@ -235,7 +270,7 @@ import dxCLASS_NAME, {
     IOptions
 } from "devextreme/DX/WIDGET/PATH";
 
-import BaseComponent from "BASE_COMPONENT_PATH";
+import { Component as BaseComponent } from "BASE_COMPONENT_PATH";
 
 interface ICLASS_NAMEOptions extends IOptions {
   defaultOption1?: someType;
@@ -283,7 +318,7 @@ import dxCLASS_NAME, {
     IOptions as ICLASS_NAMEOptions
 } from "devextreme/DX/WIDGET/PATH";
 
-import BaseComponent from "BASE_COMPONENT_PATH";
+import { Component as BaseComponent } from "BASE_COMPONENT_PATH";
 import NestedOption from "CONFIG_COMPONENT_PATH";
 
 class CLASS_NAME extends BaseComponent<ICLASS_NAMEOptions> {
@@ -384,7 +419,7 @@ import dxCLASS_NAME, {
     IOptions as ICLASS_NAMEOptions
 } from "devextreme/DX/WIDGET/PATH";
 
-import BaseComponent from "BASE_COMPONENT_PATH";
+import { Component as BaseComponent } from "BASE_COMPONENT_PATH";
 import NestedOption from "CONFIG_COMPONENT_PATH";
 
 class CLASS_NAME extends BaseComponent<ICLASS_NAMEOptions> {
@@ -463,7 +498,7 @@ import dxCLASS_NAME, {
     IOptions as ICLASS_NAMEOptions
 } from "devextreme/DX/WIDGET/PATH";
 
-import BaseComponent from "BASE_COMPONENT_PATH";
+import { Component as BaseComponent } from "BASE_COMPONENT_PATH";
 import NestedOption from "CONFIG_COMPONENT_PATH";
 
 class CLASS_NAME extends BaseComponent<ICLASS_NAMEOptions> {
@@ -544,7 +579,7 @@ import dxCLASS_NAME, {
 } from "devextreme/DX/WIDGET/PATH";
 
 import { PropTypes } from "prop-types";
-import BaseComponent from "BASE_COMPONENT_PATH";
+import { Component as BaseComponent } from "BASE_COMPONENT_PATH";
 
 class CLASS_NAME extends BaseComponent<ICLASS_NAMEOptions> {
 
@@ -588,7 +623,7 @@ import dxCLASS_NAME, {
 } from "devextreme/DX/WIDGET/PATH";
 
 import { PropTypes } from "prop-types";
-import BaseComponent from "BASE_COMPONENT_PATH";
+import { Component as BaseComponent } from "BASE_COMPONENT_PATH";
 
 class CLASS_NAME extends BaseComponent<ICLASS_NAMEOptions> {
 
@@ -636,7 +671,7 @@ import dxCLASS_NAME, {
 } from "devextreme/DX/WIDGET/PATH";
 
 import { PropTypes } from "prop-types";
-import BaseComponent from "BASE_COMPONENT_PATH";
+import { Component as BaseComponent } from "BASE_COMPONENT_PATH";
 
 class CLASS_NAME extends BaseComponent<ICLASS_NAMEOptions> {
 
@@ -683,7 +718,7 @@ import dxCLASS_NAME, {
 } from "devextreme/DX/WIDGET/PATH";
 
 import { PropTypes } from "prop-types";
-import BaseComponent from "BASE_COMPONENT_PATH";
+import { Component as BaseComponent } from "BASE_COMPONENT_PATH";
 
 class CLASS_NAME extends BaseComponent<ICLASS_NAMEOptions> {