Repository: DevExpress/devextreme-react
Pull request #273 (2018-12-27 16:57:25)

Fix complex option guards


Patch:
diff --git a/README.md b/README.md
index b297b60..3a2055f 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,10 @@ This project allows you to use [DevExtreme](http://js.devexpress.com) [React](ht
   * [Uncontrolled Mode](#uncontrolled-mode)
   * [Getting Widget Instance](#getting-widget-instance)
 * [Markup Customization](#markup-customization)
+  * [Rendering Function](#markup-customization-render)
+  * [Component](#markup-customization-component)
+  * [Template Component](#markup-customization-template-component)
+  * [Components with Transcluded Content](#markup-customization-transcluded)
 * [Configuration Components](#configuration-components)
   * [Basic usage](#configuration-components-basic)
   * [Collection Options](#configuration-components-collection)
@@ -84,7 +88,8 @@ In the controlled mode, a parent component passes a component's state using its
 - Share state between components in your app
 - Persist and restore state
 
-To manage a component's state, provide the value for a related property and handle the event that is fired when the value changes:
+To manage the component's state, provide a value for a related property and handle the event that is fired when the value changes.
+**Note that the event is fired only when the component changes its state internally (when a user interacts with the component). If you directly update the property value, the event is not fired.**
 
 ```jsx
 import React from 'react';
@@ -229,7 +234,57 @@ You can customize widget elements' appearance via the corresponding template pro
 To specify a DevExtreme React Component template, use the `Render` or `Component` suffix instead.
 If a widget has an option called `template` (e.g. Button's [template](https://js.devexpress.com/Documentation/ApiReference/UI_Widgets/dxButton/Configuration/#template) option) the corresponding React Component properties are called `render` and `component`.
 
-Use a property with the `Render` suffix to specify a rendering function:
+
+### <a name="markup-customization-render"></a>Rendering Function
+
+Use the `render` property or a property with the `Render` suffix to specify a function that renders a component's or component element's content.
+
+If a widget has the `template` option, use the `render` property to specify the rendering function:
+
+```jsx
+import React from 'react';
+import ReactDOM from 'react-dom';
+
+import Button from 'devextreme-react/button';
+
+import 'devextreme/dist/css/dx.common.css';
+import 'devextreme/dist/css/dx.light.compact.css';
+
+class CounterButton extends React.Component {
+    constructor(props) {
+        super(props);
+        this.state = {
+            counter: 0
+        };
+
+        this.handleClick = this.handleClick.bind(this);
+    }
+
+    render() {
+        return (
+            <Button
+                text={this.props.text}
+                render={(btn) => <div style={{ padding: 20 }}>{btn.text} (<b>{this.state.counter}</b>)</div>}
+                onClick={this.handleClick}
+            />
+        );
+    }
+
+    handleClick() {
+        this.setState({
+            counter: this.state.counter + 1
+        });
+    }
+}
+
+ReactDOM.render(
+    <CounterButton text='Click me!' />,
+    document.getElementById('root')
+);
+```
+
+For an option with the `template` suffix, use the corresponding property with the `Render` suffix to specify a rendering function:
+
 ```jsx
 import React from 'react';
 import ReactDOM from 'react-dom';
@@ -250,9 +305,15 @@ ReactDOM.render(
     document.getElementById('root')
 );
 ```
+
+### <a name="markup-customization-component"></a>Component
+
+You can also customize a widget's or widget element's content using a component instead of a rendering function. Pass the component or an inline function that creates the component to the `component` property or a property with the `Component` suffix. If you pass an inline function, a new component will be created each time the widget is rendered.
+
 Functional Components can cause unnecessary render calls. In such cases, consider using the [PureComponent](https://reactjs.org/docs/react-api.html#reactpurecomponent) or the [shouldComponentUpdate method](https://reactjs.org/docs/react-component.html#shouldcomponentupdate).
 
-A template component can be specified using a property with the `Component` suffix:
+The following example demonstrates how to use the `itemComponent` property to customize the widget item's appearance:
+
 ```jsx
 import React from 'react';
 import ReactDOM from 'react-dom';
@@ -287,49 +348,63 @@ ReactDOM.render(
 
 **Note: You cannot use the `key` prop in template components because it is a special [React prop](https://reactjs.org/warnings/special-props.html ). Use `dxkey` instead.**
 
-Use the `render` property to specify a rendering function for a widget with `template` option:
+### <a name="markup-customization-template-component"></a>Template Component
+To customize a widget element with a template component, specify the template name in the template component's `name` property and assign this name to the widget's template option. The template markup can be specified as a component (the `component` property) or a rendering function (the `render` property). Alternatively, you can put the markup directly into the template component as shown in the example below.
+
 ```jsx
 import React from 'react';
 import ReactDOM from 'react-dom';
 
-import Button from 'devextreme-react/button';
+import DataGrid, { Column } from "devextreme-react/data-grid"; 
+import { Template } from "devextreme-react/core/template";
 
-import 'devextreme/dist/css/dx.common.css';
-import 'devextreme/dist/css/dx.light.compact.css';
+import { data } from './data.js';
 
-class CounterButton extends React.Component {
+class Example extends React.Component {
     constructor(props) {
         super(props);
         this.state = {
-            counter: 0
+            expandAll: true
         };
 
-        this.handleClick = this.handleClick.bind(this);
+        this._handleToolbarPreparing = this._handleToolbarPreparing.bind(this);
     }
 
     render() {
         return (
-            <Button
-                text={this.props.text}
-                render={(btn) => <div style={{ padding: 20 }}>{btn.text} (<b>{this.state.counter}</b>)</div>}
-                onClick={this.handleClick}
-            />
+            <React.Fragment>
+                <DataGrid 
+                    dataSource={ data }
+                    onToolbarPreparing={this._handleToolbarPreparing}
+                >
+                    <GroupPanel visible={true} />
+                    <Grouping autoExpandAll={this.state.expandAll} />
+                    <Column dataField="firstName"/>
+                    <Column dataField="lastName" caption="Last Name" defaultVisible={true}/>
+
+                    <Template name={"toolbarLabel"}>
+                        {this.state.expandAll ? <b>All data is expanded</b> : <b>All data is collapsed</b>}
+                    </Template>
+                </DataGrid>
+            </React.Fragment>
         );
     }
-
-    handleClick() {
-        this.setState({
-            counter: this.state.counter + 1
+    
+    _handleToolbarPreparing(args) {
+        args.toolbarOptions.items.unshift({
+            location: "after",
+            template: "toolbarLabel"
         });
     }
 }
 
 ReactDOM.render(
-    <CounterButton text='Click me!' />,
+    <Example />,
     document.getElementById('root')
 );
 ```
 
+### <a name="markup-customization-transcluded"></a>Components with Transcluded Content
 The components that displays content in an overlaying window (for example, [ScrollView](https://js.devexpress.com/Documentation/ApiReference/UI_Widgets/dxScrollView/)), allow to specify the content as component children:
 
 ```jsx
@@ -470,6 +545,31 @@ Note that configuration components are not provided for options that accept a ty
 the DataGrid's [editorOptions](https://js.devexpress.com/Documentation/ApiReference/UI_Widgets/dxDataGrid/Configuration/columns/#editorOptions), Form's [editorOptions](https://js.devexpress.com/Documentation/ApiReference/UI_Widgets/dxForm/Item_Types/SimpleItem/#editorOptions), Toolbar's [widget](https://js.devexpress.com/Documentation/ApiReference/UI_Widgets/dxToolbar/Default_Item_Template/#options) options.
 
 
+### <a name="configuration-components-template"></a>Put Markup Into Configuration Components ###
+
+If a widget's configuration component supports the `template` option, you can put markup into this component. The following example demonstrates how to specify a list item's markup.
+
+```jsx
+import React from 'react';
+import ReactDOM from 'react-dom';
+
+import List, { Item } from 'devextreme-react/list';
+
+import 'devextreme/dist/css/dx.common.css';
+import 'devextreme/dist/css/dx.light.compact.css';
+
+ReactDOM.render(
+    (
+    <List>
+      <Item>orange</Item>
+      <Item>white</Item>
+      <Item>black</Item>
+    </List>
+    ),
+    document.getElementById('root')
+);
+```
+
 ## <a name="devextreme-validation"></a>DevExtreme Validation ##
 DevExtreme React editors support built-in [data validation](https://js.devexpress.com/Documentation/Guide/Widgets/Common/UI_Widgets/Data_Validation/).
 
diff --git a/src/core/component-base.ts b/src/core/component-base.ts
index 53d52c5..3800af0 100644
--- a/src/core/component-base.ts
+++ b/src/core/component-base.ts
@@ -1,4 +1,7 @@
 import * as events from "devextreme/events";
+
+import * as commonUtils from "devextreme/core/utils/common";
+
 import * as React from "react";
 
 import OptionsManager, { INestedOption } from "./options-manager";
@@ -34,7 +37,7 @@ abstract class ComponentBase<P extends IHtmlOptions> extends React.PureComponent
   protected readonly _expectedChildren: Record<string, INestedOption>;
 
   private _templateCallbacks: any[] = [];
-  private _templateTimeout: any;
+  private _isTemplatePostponed: boolean = false;
 
   private readonly _templateHost: TemplateHost;
   private readonly _optionsManager: OptionsManager;
@@ -132,9 +135,10 @@ abstract class ComponentBase<P extends IHtmlOptions> extends React.PureComponent
   private _updateTemplatesState(callback: any) {
     this._templateCallbacks.push(callback);
 
-    if (this._templateTimeout) { return; }
+    if (this._isTemplatePostponed) { return; }
 
-    this._templateTimeout = setTimeout(() => {
+    this._isTemplatePostponed = true;
+    commonUtils.deferUpdate(() => {
       this.setState((state: IState) => {
         const templates = { ...state.templates };
         for (const cb of this._templateCallbacks) {
@@ -148,10 +152,7 @@ abstract class ComponentBase<P extends IHtmlOptions> extends React.PureComponent
 
   private _clearTemplates() {
     this._templateCallbacks.length = 0;
-    if (this._templateTimeout) {
-      clearTimeout(this._templateTimeout);
-      this._templateTimeout = undefined;
-    }
+    this._isTemplatePostponed = false;
   }
 
   private _getElementProps(): Record<string, any> {
diff --git a/src/core/options-manager.ts b/src/core/options-manager.ts
index eb97e90..b59a1d6 100644
--- a/src/core/options-manager.ts
+++ b/src/core/options-manager.ts
@@ -83,9 +83,11 @@ class OptionsManager {
         }
 
         let optionValue;
+        let optionName;
 
         const nestedOption = this._nestedOptions[e.name];
         if (nestedOption) {
+            optionName = e.fullName;
             const nestedOptionObj = separateProps(
                 nestedOption.elementEntries[0].element.props,
                 nestedOption.defaults,
@@ -102,10 +104,11 @@ class OptionsManager {
                 return;
             }
 
-            if (!nestedOption.isCollectionItem) {
-                optionValue = getNestedValue(nestedOptionObj, e.fullName.split(".").slice(1));
-            }
+            if (nestedOption.isCollectionItem) { return; }
+
+            optionValue = getNestedValue(nestedOptionObj, e.fullName.split(".").slice(1));
         } else {
+            optionName = e.name;
             optionValue = this._optionValueGetter(e.name);
         }
 
@@ -113,7 +116,7 @@ class OptionsManager {
             return;
         }
 
-        this._setGuard(e.fullName, optionValue);
+        this._setGuard(optionName, optionValue);
     }
 
     public processChangedValues(newProps: Record<string, any>, prevProps: Record<string, any>): void {


Test patch:
diff --git a/src/core/__tests__/props-updating.test.tsx b/src/core/__tests__/props-updating.test.tsx
index 5ea763e..fd5d052 100644
--- a/src/core/__tests__/props-updating.test.tsx
+++ b/src/core/__tests__/props-updating.test.tsx
@@ -15,6 +15,7 @@ interface IControlledComponentProps {
     onControlledOptionChanged?: () => void;
     everyOption?: number;
     anotherOption?: string;
+    complexOption?: object;
 }
 
 // tslint:disable:max-classes-per-file
@@ -220,6 +221,17 @@ describe("option control", () => {
         expect(Widget.option.mock.calls[0]).toEqual(["everyOption", 123]);
     });
 
+    it("rolls complex option value back", () => {
+        shallow(
+            <ControlledComponent complexOption={{a: 123}} />
+        );
+
+        fireOptionChange("complexOption.a", 234);
+        jest.runAllTimers();
+        expect(Widget.option.mock.calls.length).toBe(1);
+        expect(Widget.option.mock.calls[0]).toEqual(["complexOption", {a: 123}]);
+    });
+
     it("rolls option value back if value has no changes", () => {
         const component = shallow(
             <ControlledComponent everyOption={123} anotherOption={"const"} />
@@ -281,9 +293,9 @@ describe("option defaults control", () => {
 
 });
 
-describe("nested option control", () => {
+describe("cfg-component option control", () => {
 
-    it("rolls nested option value back", () => {
+    it("rolls cfg-component option value back", () => {
         mount(
             <ControlledComponent>
                 <NestedComponent a={123} />
@@ -296,7 +308,7 @@ describe("nested option control", () => {
         expect(Widget.option.mock.calls[0]).toEqual(["nestedOption.a", 123]);
     });
 
-    it("rolls nested option value if parent object changes another field", () => {
+    it("rolls cfg-component option value if parent object changes another field", () => {
         mount(
             <ControlledComponent>
                 <NestedComponent a={123} />
@@ -309,7 +321,7 @@ describe("nested option control", () => {
         expect(Widget.option.mock.calls[0]).toEqual(["nestedOption.a", 123]);
     });
 
-    it("rolls nested option value and preserves parent object", () => {
+    it("rolls cfg-component option value and preserves parent object", () => {
         mount(
             <ControlledComponent>
                 <NestedComponent a={123} />
@@ -322,7 +334,7 @@ describe("nested option control", () => {
         expect(Widget.option.mock.calls[0]).toEqual(["nestedOption.a", 123]);
     });
 
-    it("rolls nested option value back if value has no changes", () => {
+    it("rolls cfg-component option value back if value has no changes", () => {
         const component = shallow(
             <ControlledComponent>
                 <NestedComponent a={123} b="const" />
@@ -338,7 +350,7 @@ describe("nested option control", () => {
         expect(Widget.option.mock.calls[1]).toEqual(["nestedOption.b", "const"]);
     });
 
-    it("apply nested option change if value really change", () => {
+    it("apply cfg-component option change if value really change", () => {
         const component = shallow(
             <ControlledComponent>
                 <NestedComponent a={123} b="const" />
@@ -353,7 +365,7 @@ describe("nested option control", () => {
         expect(Widget.option.mock.calls[0]).toEqual(["nestedOption.a", 234]);
     });
 
-    it("does not control not specified nested option", () => {
+    it("does not control not specified cfg-component option", () => {
         shallow(
             <ControlledComponent>
                 <NestedComponent a={123} />
@@ -367,7 +379,7 @@ describe("nested option control", () => {
 
 });
 
-describe("nested option defaults control", () => {
+describe("cfg-component option defaults control", () => {
 
     it("pass nested default values to widget", () => {
         mount(
@@ -390,7 +402,7 @@ describe("nested option defaults control", () => {
         expect(WidgetClass.mock.calls[0][1]).not.toHaveProperty("defaultControlledOption");
     });
 
-    it("ignores nested option with default prefix", () => {
+    it("ignores cfg-component option with default prefix", () => {
         mount(
             <ControlledComponent>
                 <NestedComponent defaultC="default" />
diff --git a/src/core/__tests__/template.test.tsx b/src/core/__tests__/template.test.tsx
index ebb1045..cdaea21 100644
--- a/src/core/__tests__/template.test.tsx
+++ b/src/core/__tests__/template.test.tsx
@@ -24,11 +24,10 @@ function renderTemplate(name: string, model?: any, container?: any, onRendered?:
     model = model || {};
     container = container || document.createElement("div");
     const render = WidgetClass.mock.calls[0][1].integrationOptions.templates[name].render;
-    const result = render({
+
+    return render({
         container, model, onRendered
     });
-    jest.runAllTimers();
-    return result;
 }
 
 function renderItemTemplate(model?: any, container?: any, onRendered?: () => void): Element {
@@ -219,7 +218,6 @@ function testTemplateOption(testedOption: string) {
         parentElement.removeChild(removeListener);
         parentElement.removeChild(templateContent);
         events.triggerHandler(removeListener, "dxremove");
-        jest.runAllTimers();
         component.update();
         expect(Object.getOwnPropertyNames(component.state("templates")).length).toBe(0);
     });