From de8d677fc0c16a206a030578c967ac0fb3c1c931 Mon Sep 17 00:00:00 2001 From: Andrey Lukyanov Date: Tue, 5 Jun 2018 17:46:38 +0300 Subject: [PATCH] Add extension component (#116) --- example/text-box-example.tsx | 11 +++ src/core/component.test.tsx | 45 +++++++++- src/core/component.ts | 119 ++++++++++++++++++-------- src/core/template-helper.ts | 6 +- tools/integration-data-model.ts | 1 + tools/integration-data.json | 3 +- tools/src/component-generator.test.ts | 61 ++++++++++--- tools/src/component-generator.ts | 5 +- tools/src/generator.ts | 1 + 9 files changed, 195 insertions(+), 57 deletions(-) diff --git a/example/text-box-example.tsx b/example/text-box-example.tsx index 21d0a3bd..b966e1c6 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 { 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 controlled state value with change handling +
+ validation (required) + + + ); } diff --git a/src/core/component.test.tsx b/src/core/component.test.tsx index e545a96f..0827d96d 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( ); @@ -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

extends ExtensionComponent

{ + + constructor(props: P) { + super(props); + + this._WidgetClass = ExtensionWidgetClass; + } + } + + it("does not create widget on componentDidMount", () => { + shallow( + + ); + + expect(ExtensionWidgetClass).toHaveBeenCalledTimes(0); + }); + + it("creates widget on componentDidMount inside another component on same element", () => { + mount( + + + + ); + + expect(ExtensionWidgetClass).toHaveBeenCalledTimes(1); + expect(ExtensionWidgetClass.mock.calls[0][0]).toBe(WidgetClass.mock.calls[0][0]); + }); + + it("unmounts without errors", () => { + const component = shallow( + + ); + + expect(component.unmount.bind(component)).not.toThrow(); + }); +}); + describe("templates", () => { function renderTemplate(name: string, model?: any, container?: any): Element { model = model || {}; diff --git a/src/core/component.ts b/src/core/component.ts index 908ab1f7..dec40f2e 100644 --- a/src/core/component.ts +++ b/src/core/component.ts @@ -32,10 +32,11 @@ interface IState { templates: Record; } -class Component

extends React.PureComponent { - +abstract class ComponentBase

extends React.PureComponent { protected _WidgetClass: any; protected _instance: any; + protected _element: any; + protected readonly _defaults: Record; protected readonly _templateProps: ITemplateMeta[] = []; @@ -43,8 +44,6 @@ class Component

extends React.PureComponent { 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

extends React.PureComponent { ...nestedTemplates, ...this.findNestedTemplate(child) }; - args.push(this._registerNestedOption(child) || child); + args.push(this._preprocessChild(child) || child); }); } @@ -100,7 +99,17 @@ class Component

extends React.PureComponent { 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): React.ReactElement; + + 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

extends React.PureComponent { ...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 { + 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): IWidgetConfig { @@ -215,38 +250,48 @@ class Component

extends React.PureComponent { return result; } } +} - private _registerNestedOption(component: React.ReactElement): 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

extends ComponentBase

{ + 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) { + return this._registerExtension(component) || this._registerNestedOption(component) || component; + } + + private _registerExtension(component: React.ReactElement) { + 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

extends ComponentBase

{ + public componentDidMount() { + const onMounted = (this.props as Record).onMounted; + if (onMounted) { + onMounted((element) => { + this._createWidget(element); + }); + } + } + + protected _preprocessChild(component: React.ReactElement) { + 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 049be077..64b8226b 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; + private readonly _component: ComponentBase; - constructor(component: Component) { + constructor(component: ComponentBase) { this._component = component; this.wrapTemplate = this.wrapTemplate.bind(this); diff --git a/tools/integration-data-model.ts b/tools/integration-data-model.ts index 703f378f..9d41e898 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 3082fcd7..ca514a71 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.test.ts b/tools/src/component-generator.test.ts index d4cd5f7f..cf4013c2 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 { @@ -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 { + + 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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { diff --git a/tools/src/component-generator.ts b/tools/src/component-generator.ts index 945e0227..d64f7338 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 c75b656c..d57e4ac6 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,