Repository: DevExpress/devextreme-react
Pull request #101 (2018-05-18 10:58:51)

Support controlled mode and default values in nested options


Patch:
diff --git a/build.config.js b/build.config.js
index 9d107af..d4680d0 100644
--- a/build.config.js
+++ b/build.config.js
@@ -20,5 +20,5 @@ module.exports = {
   componentFolder: './src/ui',
   indexFileName: './src/index.ts',
   baseComponent: '../core/component',
-  configComponent: '../core/configuration-component'
+  configComponent: '../core/nested-option'
 }
\ No newline at end of file
diff --git a/src/core/component.ts b/src/core/component.ts
index 56e2397..974d6d9 100644
--- a/src/core/component.ts
+++ b/src/core/component.ts
@@ -2,11 +2,11 @@ import * as React from "react";
 
 import * as events from "devextreme/events";
 
-import { createConfigurationComponent } from "./configuration-component";
 import { addPrefixToKeys, getNestedValue } from "./helpers";
-import { splitProps } from "./props-preprocessor";
+import { createConfigurationComponent } from "./nested-option";
 import { ITemplateMeta } from "./template";
 import { ITemplateWrapper, wrapTemplate } from "./template-wrapper";
+import { createWidgetConfig } from "./widget-config";
 
 const DX_REMOVE_EVENT = "dxremove";
 
@@ -15,6 +15,7 @@ interface IChildComponent {
     IsCollectionItem: boolean;
     OwnerType: any;
     OptionName: string;
+    DefaultsProps: Record<string, string>;
   };
   props: object;
 }
@@ -40,7 +41,7 @@ class Component<P> extends React.PureComponent<P, IState> {
     super(props);
     this._optionChangedHandler = this._optionChangedHandler.bind(this);
     this._prepareProps = this._prepareProps.bind(this);
-    this._setTempaltesState = this._setTempaltesState.bind(this);
+    this._setTemplatesState = this._setTemplatesState.bind(this);
 
     this.state = {
       templates: {}
@@ -79,19 +80,8 @@ class Component<P> extends React.PureComponent<P, IState> {
       return React.createElement.apply(this, args);
   }
 
-  public updateNested(
-    optionName: string,
-    newProps: Record<string, any>,
-    prevProps: Record<string, any>
-  ): void {
-    this._processChangedValues(
-      addPrefixToKeys(newProps, optionName + "."),
-      addPrefixToKeys(prevProps, optionName + ".")
-    );
-  }
-
   public componentDidMount() {
-    const props = this.getActualProps();
+    const props = this.getActualProps({ includeDefaults: true });
 
     const preparedProps = this._prepareProps(props);
 
@@ -117,7 +107,7 @@ class Component<P> extends React.PureComponent<P, IState> {
     }
 
     const optionName = e.fullName;
-    const optionValue = getNestedValue(this.getActualProps(), optionName.split("."));
+    const optionValue = getNestedValue(this.getActualProps({ includeDefaults: false }), optionName.split("."));
 
     if (optionValue === undefined || optionValue === null) {
       return;
@@ -142,17 +132,17 @@ class Component<P> extends React.PureComponent<P, IState> {
     templates: Record<string, any>,
     nestedTemplates: Record<string, any>
   } {
-    const props = splitProps(rawProps, this._defaults, this._templateProps);
-    const options = props.options;
-    props.options = {};
+    const widgetConfig = createWidgetConfig(rawProps, this._defaults, this._templateProps);
+    const options = widgetConfig.options;
+    widgetConfig.options = {};
 
     if (options) {
       Object.keys(options).forEach((key) => {
-        props.options[key] = this._wrapEventHandler(options[key], key);
+        widgetConfig.options[key] = this._wrapEventHandler(options[key], key);
       });
     }
 
-    return props;
+    return widgetConfig;
   }
 
   private _wrapEventHandler(optionValue: any, optionName: string): any {
@@ -220,10 +210,10 @@ class Component<P> extends React.PureComponent<P, IState> {
       ? React.createElement.bind(this, component)
       : render.bind(this);
 
-    return wrapTemplate(templateProp, this._setTempaltesState);
+    return wrapTemplate(templateProp, this._setTemplatesState);
   }
 
-  private _setTempaltesState(callback: (templates: Record<string, any>) => void) {
+  private _setTemplatesState(callback: (templates: Record<string, any>) => void) {
     this.setState((state: IState) => {
       const templates = { ...state.templates };
       callback(templates);
@@ -243,14 +233,30 @@ class Component<P> extends React.PureComponent<P, IState> {
       const optionName = configComponent.type.OptionName;
       return createConfigurationComponent(
         component,
-        (newProps, prevProps) => this.updateNested(optionName, newProps, prevProps)
+        (newProps, prevProps) => {
+          const newOptions = createWidgetConfig(newProps, configComponent.type.DefaultsProps, []).options;
+          this._updateNested(optionName, newOptions, prevProps);
+        }
       );
     }
 
     return component;
   }
 
-  private getActualProps(): Record<string, any> {
+  private _updateNested(
+    optionName: string,
+    newProps: Record<string, any>,
+    prevProps: Record<string, any>
+  ): void {
+    this._processChangedValues(
+      addPrefixToKeys(newProps, optionName + "."),
+      addPrefixToKeys(prevProps, optionName + ".")
+    );
+  }
+
+  private getActualProps(options: {
+    includeDefaults: boolean
+  }): Record<string, any> {
 
     const nestedOptions: Record<string, any> = {};
     const children: any = this.props.children;
@@ -263,15 +269,21 @@ class Component<P> extends React.PureComponent<P, IState> {
       nested.forEach((child) => {
         if (child && child.type && child.type.OwnerType && this instanceof child.type.OwnerType) {
 
+          const widgetConfig = createWidgetConfig(child.props, child.type.DefaultsProps, []);
+          const childOptions = {
+            ...(options.includeDefaults ? widgetConfig.defaults : undefined),
+            ...widgetConfig.options
+          };
+
           if (child.type.IsCollectionItem) {
 
             if (nestedOptions[child.type.OptionName] === null || nestedOptions[child.type.OptionName] === undefined) {
               nestedOptions[child.type.OptionName] = [];
             }
 
-            nestedOptions[child.type.OptionName].push(child.props);
+            nestedOptions[child.type.OptionName].push(childOptions);
           } else {
-            nestedOptions[child.type.OptionName] = child.props;
+            nestedOptions[child.type.OptionName] = childOptions;
           }
         }
       });
diff --git a/src/core/configuration-component.ts b/src/core/nested-option.ts
similarity index 82%
rename from src/core/configuration-component.ts
rename to src/core/nested-option.ts
index 8e67767..b89bd09 100644
--- a/src/core/configuration-component.ts
+++ b/src/core/nested-option.ts
@@ -1,5 +1,4 @@
 import * as React from "react";
-// import { splitProps } from "./props-preprocessor";
 
 type UpdateFunc = (newProps, prevProps) => void;
 
@@ -7,7 +6,7 @@ interface IUpdater {
     update?: UpdateFunc;
 }
 
-class ConfigurationComponent<P> extends React.PureComponent<P & IUpdater, any> {
+class NestedOption<P> extends React.PureComponent<P & IUpdater, any> {
 
     public render() {
         return null;
@@ -19,6 +18,7 @@ class ConfigurationComponent<P> extends React.PureComponent<P & IUpdater, any> {
 
         const prevProps = { ...(this.props as {} & IUpdater) };
         delete prevProps.update;
+
         if (nextProps.update) {
             nextProps.update(newProps, prevProps);
         }
@@ -33,5 +33,5 @@ function createConfigurationComponent<P>(baseElement: React.ReactElement<P>, upd
     return React.cloneElement(baseElement, extraProps);
 }
 
-export default ConfigurationComponent;
+export default NestedOption;
 export { createConfigurationComponent, IUpdater, UpdateFunc };
diff --git a/src/core/props-preprocessor.ts b/src/core/widget-config.ts
similarity index 83%
rename from src/core/props-preprocessor.ts
rename to src/core/widget-config.ts
index c841655..3016de8 100644
--- a/src/core/props-preprocessor.ts
+++ b/src/core/widget-config.ts
@@ -1,16 +1,18 @@
 import { Children, ReactElement } from "react";
 import { ITemplateMeta, Template } from "./template";
 
-function splitProps(
+interface IWidgetConfig {
+    defaults: Record<string, any>;
+    options: Record<string, any>;
+    templates: Record<string, any>;
+    nestedTemplates: Record<string, any>;
+}
+
+function createWidgetConfig(
     props: Record<string, any>,
     defaultsProps: Record<string, string>,
     templateProps: ITemplateMeta[]
-): {
-    defaults: Record<string, any>,
-    options: Record<string, any>,
-    templates: Record<string, any>,
-    nestedTemplates: Record<string, any>
-} {
+): IWidgetConfig {
     const defaults: Record<string, any> = {};
     const options: Record<string, any> = {};
     const templates: Record<string, any> = {};
@@ -47,4 +49,4 @@ function splitProps(
     return { defaults, options, templates, nestedTemplates };
 }
 
-export { splitProps };
+export { createWidgetConfig, IWidgetConfig };
diff --git a/tools/src/component-generator.ts b/tools/src/component-generator.ts
index acaf938..77d2671 100644
--- a/tools/src/component-generator.ts
+++ b/tools/src/component-generator.ts
@@ -151,7 +151,7 @@ const renderComponent: (model: {
 import <#= it.widgetName #>, { IOptions <#? !it.hasExtraOptions #>as <#= it.optionsName #> <#?#>} from "devextreme/<#= it.dxExportPath #>";<#? it.renderedPropTypings #>
 import { PropTypes } from "prop-types";<#?#>
 import BaseComponent from "<#= it.baseComponentPath #>";
-<#? it.nestedOptions #>import ConfigurationComponent from "<#= it.configComponentPath #>";
+<#? it.nestedOptions #>import NestedOption from "<#= it.configComponentPath #>";
 <#?#><#? it.hasExtraOptions #>
 interface <#= it.optionsName #> extends IOptions {<#~ it.templates :template #>
   <#= template.render #>?: (props: any) => React.ReactNode;
@@ -176,7 +176,7 @@ class <#= it.name #> extends BaseComponent<<#= it.optionsName #>> {
 };<#?#><#? it.nestedOptions #>
 // tslint:disable:max-classes-per-file<#~ it.nestedOptions :opt #>
 
-class <#= opt.className #> extends ConfigurationComponent<<#= opt.rendered #>> {<#? opt.isCollectionItem #>
+class <#= opt.className #> extends NestedOption<<#= opt.rendered #>> {<#? opt.isCollectionItem #>
   public static IsCollectionItem = true;<#?#>
   public static OwnerType = <#= it.name #>;
   public static OptionName = "<#= opt.name #>";


Test patch:
diff --git a/src/core/component.test.tsx b/src/core/component.test.tsx
index 6f48ffe..1067abe 100644
--- a/src/core/component.test.tsx
+++ b/src/core/component.test.tsx
@@ -3,7 +3,7 @@ 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 ConfigurationComponent from "../core/configuration-component";
+import ConfigurationComponent from "../core/nested-option";
 import { Template } from "../core/template";
 
 const eventHandlers: { [index: string]: (e?: any) => void}  = {};
@@ -463,6 +463,18 @@ describe("controlled mode", () => {
         expect(Widget.option.mock.calls[0]).toEqual([ "nestedOption.a", 234 ]);
     });
 
+    it("does not control not specified nested option", () => {
+        shallow(
+            <ControlledComponent>
+                <NestedComponent a={123} />
+            </ControlledComponent>
+        );
+
+        eventHandlers.optionChanged({ fullName: "nestedOption.b", value: "abc"});
+        jest.runAllTimers();
+        expect(Widget.option.mock.calls.length).toBe(0);
+    });
+
     it("pass default values to widget", () => {
         mount(
             <ControlledComponent defaultControlledOption={"default"}/>
@@ -474,10 +486,13 @@ describe("controlled mode", () => {
 
     it("pass nested default values to widget", () => {
         mount(
-            <ControlledComponent defaultControlledOption={"default"}/>
+            <ControlledComponent>
+                <NestedComponent defaultC="default" />
+            </ControlledComponent>
         );
 
-        expect(WidgetClass.mock.calls[0][1].controlledOption).toBe("default");
+        expect(WidgetClass.mock.calls[0][1].nestedOption.c).toBe("default");
+        expect(WidgetClass.mock.calls[0][1].nestedOption).not.toHaveProperty("defaultC");
     });
 
     it("does not control option with default prefix", () => {
@@ -489,6 +504,17 @@ describe("controlled mode", () => {
         expect(Widget.option.mock.calls.length).toBe(0);
     });
 
+    it("does not control nested option with default prefix", () => {
+        mount(
+            <ControlledComponent>
+                <NestedComponent defaultC="default" />
+            </ControlledComponent>
+        );
+        eventHandlers.optionChanged({ fullName: "nestedOption.c", value: "changed"});
+        jest.runAllTimers();
+        expect(Widget.option.mock.calls.length).toBe(0);
+    });
+
     it("ignores 3rd-party changes in default props", () => {
         const component = mount(
             <ControlledComponent defaultControlledOption={"default"}/>
@@ -500,12 +526,42 @@ describe("controlled mode", () => {
         expect(Widget.option.mock.calls.length).toBe(0);
     });
 
+    it("ignores 3rd-party changes in nested default props", () => {
+        const component = shallow(
+            <ControlledComponent>
+                <NestedComponent defaultC="default" />
+            </ControlledComponent>
+
+        );
+        const nested = component.find(NestedComponent).dive();
+
+        nested.setProps({
+            defaultC: "changed"
+        });
+        jest.runAllTimers();
+        expect(Widget.option.mock.calls.length).toBe(0);
+    });
+
     it("does not pass default values to widget if controlledOption set", () => {
         mount(
             <ControlledComponent defaultControlledOption={"default"} controlledOption={"controlled"}/>
         );
 
+        expect(Widget.option.mock.calls.length).toBe(0);
         expect(WidgetClass.mock.calls[0][1].controlledOption).toBe("controlled");
+        expect(WidgetClass.mock.calls[0][1]).not.toHaveProperty("defaultControlledOption");
+    });
+
+    it("does not pass nested default values to widget if controlledOption set", () => {
+        mount(
+            <ControlledComponent>
+                <NestedComponent defaultC="default" c="controlled" />
+            </ControlledComponent>
+        );
+
+        expect(Widget.option.mock.calls.length).toBe(0);
+        expect(WidgetClass.mock.calls[0][1].nestedOption.c).toBe("controlled");
+        expect(WidgetClass.mock.calls[0][1].nestedOption).not.toHaveProperty("defaultC");
     });
 
     it("does not fire events when option changed while props updating", () => {
@@ -542,7 +598,7 @@ describe("controlled mode", () => {
 
     class ControlledComponent extends TestComponent<IControlledComponentProps> {
 
-        protected _defaults = { // tslint:disable-line:variable-name
+        protected _defaults = {
             defaultControlledOption : "controlledOption"
         };
     } // tslint:disable-line:max-classes-per-file
@@ -555,6 +611,10 @@ describe("controlled mode", () => {
     }> {
         public static OwnerType = ControlledComponent;
         public static OptionName = "nestedOption";
+
+        public static DefaultsProps = {
+            defaultC : "c"
+        };
     } // tslint:disable-line:max-classes-per-file
 });
 
diff --git a/tools/src/component-generator.test.ts b/tools/src/component-generator.test.ts
index 8e31efc..1a0ae5e 100644
--- a/tools/src/component-generator.test.ts
+++ b/tools/src/component-generator.test.ts
@@ -263,7 +263,7 @@ export {
         const EXPECTED = `
 import dxCLASS_NAME, { IOptions as ICLASS_NAMEOptions } from "devextreme/DX/WIDGET/PATH";
 import BaseComponent from "BASE_COMPONENT_PATH";
-import ConfigurationComponent from "CONFIG_COMPONENT_PATH";
+import NestedOption from "CONFIG_COMPONENT_PATH";
 
 class CLASS_NAME extends BaseComponent<ICLASS_NAMEOptions> {
 
@@ -275,7 +275,7 @@ class CLASS_NAME extends BaseComponent<ICLASS_NAMEOptions> {
 }
 // tslint:disable:max-classes-per-file
 
-class CLASS_NAMEOpt_1 extends ConfigurationComponent<{
+class CLASS_NAMEOpt_1 extends NestedOption<{
   sub_opt_2?: TYPE_1;
   sub_opt_3?: {
     sub_sub_opt_4?: TYPE_2;
@@ -340,7 +340,7 @@ export {
         const EXPECTED = `
 import dxCLASS_NAME, { IOptions as ICLASS_NAMEOptions } from "devextreme/DX/WIDGET/PATH";
 import BaseComponent from "BASE_COMPONENT_PATH";
-import ConfigurationComponent from "CONFIG_COMPONENT_PATH";
+import NestedOption from "CONFIG_COMPONENT_PATH";
 
 class CLASS_NAME extends BaseComponent<ICLASS_NAMEOptions> {
 
@@ -352,7 +352,7 @@ class CLASS_NAME extends BaseComponent<ICLASS_NAMEOptions> {
 }
 // tslint:disable:max-classes-per-file
 
-class CLASS_NAMEOpt_1 extends ConfigurationComponent<{
+class CLASS_NAMEOpt_1 extends NestedOption<{
   sub_opt_2?: TYPE_1;
 }> {
   public static IsCollectionItem = true;