Repository: DevExpress/devextreme-react
Pull request #268 (2018-12-25 14:48:16)

Docs: Add info about Template component, static items, event firing


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/example/list-example.tsx b/example/list-example.tsx
index 5a5e445..9262669 100644
--- a/example/list-example.tsx
+++ b/example/list-example.tsx
@@ -42,6 +42,12 @@ function ItemKeyGetter(data: any) {
     return data.text;
 }
 
+function ItemsRender(item: string) {
+    return <i>{item}</i>;
+}
+
+const listItems: string[] = ["orange", "apple", "potato"];
+
 // tslint:disable-next-line:max-classes-per-file
 export default class extends React.Component<any, { text: string; items: IListItemProps[]; }> {
 
@@ -77,8 +83,8 @@ export default class extends React.Component<any, { text: string; items: IListIt
                 <hr />
                 <h4>List with function template</h4>
                 <List
-                    items={["orange", "apple", "potato"]}
-                    itemRender={(item: string) => <i>{item}</i>}
+                    items={listItems}
+                    itemRender={ItemsRender}
                 />
                 <hr />
                 <h4>List with component template</h4>
diff --git a/src/core/component-base.ts b/src/core/component-base.ts
index 6327f6d..53d52c5 100644
--- a/src/core/component-base.ts
+++ b/src/core/component-base.ts
@@ -33,6 +33,9 @@ abstract class ComponentBase<P extends IHtmlOptions> extends React.PureComponent
   protected readonly _templateProps: ITemplateMeta[] = [];
   protected readonly _expectedChildren: Record<string, INestedOption>;
 
+  private _templateCallbacks: any[] = [];
+  private _templateTimeout: any;
+
   private readonly _templateHost: TemplateHost;
   private readonly _optionsManager: OptionsManager;
 
@@ -70,10 +73,12 @@ abstract class ComponentBase<P extends IHtmlOptions> extends React.PureComponent
   }
 
   public componentWillUnmount() {
+    this._clearTemplates();
     if (this._instance) {
       events.triggerHandler(this._element, DX_REMOVE_EVENT);
       this._instance.dispose();
     }
+    this._optionsManager.dispose();
   }
 
   protected _prepareChildren(args: any[] = []): any[] {
@@ -125,15 +130,30 @@ abstract class ComponentBase<P extends IHtmlOptions> extends React.PureComponent
   }
 
   private _updateTemplatesState(callback: any) {
-    this.setState((state: IState) => {
+    this._templateCallbacks.push(callback);
+
+    if (this._templateTimeout) { return; }
+
+    this._templateTimeout = setTimeout(() => {
+      this.setState((state: IState) => {
         const templates = { ...state.templates };
-        callback(templates);
-        return {
-            templates
-        };
+        for (const cb of this._templateCallbacks) {
+          cb(templates);
+        }
+        this._clearTemplates();
+        return { templates };
+      });
     });
   }
 
+  private _clearTemplates() {
+    this._templateCallbacks.length = 0;
+    if (this._templateTimeout) {
+      clearTimeout(this._templateTimeout);
+      this._templateTimeout = undefined;
+    }
+  }
+
   private _getElementProps(): Record<string, any> {
     const elementProps: Record<string, any> = {
       ref: (element: HTMLDivElement) => this._element = element
diff --git a/src/core/options-manager.ts b/src/core/options-manager.ts
index d098fd1..eb97e90 100644
--- a/src/core/options-manager.ts
+++ b/src/core/options-manager.ts
@@ -153,6 +153,13 @@ class OptionsManager {
         return this._registerNestedOption(component, expectedChildren, this._nestedOptions);
     }
 
+    public dispose() {
+        for (const optionName of Object.keys(this._guards)) {
+            window.clearTimeout(this._guards[optionName]);
+            delete this._guards[optionName];
+        }
+    }
+
     private _setOption(name: string, value: any): void {
         let actualValue = value;
         if (isEventHanlder(name, value)) {
diff --git a/src/core/template-wrapper.ts b/src/core/template-wrapper.ts
index b338452..9f96f1e 100644
--- a/src/core/template-wrapper.ts
+++ b/src/core/template-wrapper.ts
@@ -24,8 +24,9 @@ class TemplateWrapper extends React.PureComponent<ITemplateWrapperProps> {
                     style: { display: "none" },
                     ref: (element: HTMLDivElement) => this._removalListener = element
                 }),
-            )
-        , this.props.container);
+            ),
+            this.props.container
+        );
     }
 
     public componentDidMount() {
@@ -33,19 +34,21 @@ class TemplateWrapper extends React.PureComponent<ITemplateWrapperProps> {
             const onRendered: () => void = this.props.onRendered;
             setTimeout(() => onRendered());
         }
-        const restoreRemovedContent = () => {
-            // Let React remove it itself
-            const node = ReactDOM.findDOMNode(this);
-            if (node) {
-                this.props.container.appendChild(node);
-            }
-            this.props.container.appendChild(this._removalListener);
-        };
+
         events.one(this._removalListener, DX_REMOVE_EVENT, () => {
-            restoreRemovedContent();
+            this._restoreRemovedContent.call(this);
             this.props.onRemoved();
         });
     }
+
+    private _restoreRemovedContent() {
+        // Let React remove it itself
+        const node = ReactDOM.findDOMNode(this);
+        if (node) {
+            this.props.container.appendChild(node);
+        }
+        this.props.container.appendChild(this._removalListener);
+    }
 }
 
 export {


Test patch:
diff --git a/src/core/__tests__/component.test.tsx b/src/core/__tests__/component.test.tsx
index 4f8c1e1..07f724d 100644
--- a/src/core/__tests__/component.test.tsx
+++ b/src/core/__tests__/component.test.tsx
@@ -1,7 +1,7 @@
 import * as events from "devextreme/events";
 
 import { mount, React, shallow } from "./setup";
-import { TestComponent, Widget, WidgetClass } from "./test-component";
+import { fireOptionChange, TestComponent, Widget, WidgetClass } from "./test-component";
 
 describe("rendering", () => {
 
@@ -161,4 +161,16 @@ describe("disposing", () => {
         expect(handleDxRemove).toHaveBeenCalledTimes(1);
     });
 
+    it("remove option guards", () => {
+        const component = shallow(
+            <TestComponent option1={true} />
+        );
+
+        fireOptionChange("option1", false);
+        component.unmount();
+        jest.runAllTimers();
+
+        expect(Widget.option.mock.calls.length).toBe(0);
+    });
+
 });
diff --git a/src/core/__tests__/template.test.tsx b/src/core/__tests__/template.test.tsx
index f37496e..ebb1045 100644
--- a/src/core/__tests__/template.test.tsx
+++ b/src/core/__tests__/template.test.tsx
@@ -24,9 +24,11 @@ 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;
-    return render({
+    const result = render({
         container, model, onRendered
     });
+    jest.runAllTimers();
+    return result;
 }
 
 function renderItemTemplate(model?: any, container?: any, onRendered?: () => void): Element {
@@ -217,6 +219,7 @@ 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);
     });