Repository: DevExpress/devextreme-react
Pull request #333 (2019-04-18 15:40:57)

Provide `index` for the item templates
Fixes [T726496](https://www.devexpress.com/Support/Center/Question/Details/T726496/there-is-no-way-to-access-the-item-index-in-a-template-in-a-collection-widget).
Config option `useLegacyTemplatEngine` will be set to `false` after all the depended code such as Demos will be prepared.


Patch:
diff --git a/example/app.tsx b/example/app.tsx
index 7615ce4..04adbb2 100644
--- a/example/app.tsx
+++ b/example/app.tsx
@@ -21,6 +21,9 @@ import ToolbarExample from "./toolbar-example";
 import ValidationExample from "./validation-example";
 
 import { Button, NumberBox } from "../src";
+import config from "../src/core/config";
+
+config({ useLegacyTemplateEngine: false });
 
 ReactDOM.render(
   <div>
diff --git a/example/data-grid-example.tsx b/example/data-grid-example.tsx
index 70eeb8e..4598ef3 100644
--- a/example/data-grid-example.tsx
+++ b/example/data-grid-example.tsx
@@ -17,21 +17,21 @@ import NumberBox from "../src/number-box";
 
 import { sales } from "./data";
 
-const DetailComponent = (props: any) => {
+const DetailComponent = ({ data: { data } }: any) => {
     return (
         <p>Row data:
             <br/>
-            {JSON.stringify(props.data)}
+            {JSON.stringify(data)}
         </p>
     );
 };
 
 const CityComponent = (props: any) => {
-    return <i>{props.displayValue}</i>;
+    return <i>{props.data.displayValue}</i>;
 };
 
 const RegionComponent = (props: any) => {
-    return <b>{props.displayValue}</b>;
+    return <b>{props.data.displayValue}</b>;
 };
 
 export default class extends React.Component<any, { expandAll: boolean, pageSize: number }> {
diff --git a/example/list-example.tsx b/example/list-example.tsx
index 6a8c7e0..6e2670c 100644
--- a/example/list-example.tsx
+++ b/example/list-example.tsx
@@ -9,7 +9,10 @@ import TextBox from "../src/text-box";
 import Example from "./example-block";
 
 interface IListItemProps {
-    text: string;
+    data: {
+        text: string;
+    };
+    index: number;
 }
 
 class Item extends React.Component<IListItemProps, { counter: number }> {
@@ -24,9 +27,11 @@ class Item extends React.Component<IListItemProps, { counter: number }> {
     }
 
     public render() {
+        const { data: { text }, index } = this.props;
         return (
             <i onClick={this.handleClick}>
-                Component template for item {this.props.text}. <b>Clicks: {this.state.counter}</b>
+                {index + 1}. Component template for item {text}.
+                <b>Clicks: {this.state.counter}</b>
             </i>
         );
     }
diff --git a/example/scheduler-example.tsx b/example/scheduler-example.tsx
index 232718e..db44be0 100644
--- a/example/scheduler-example.tsx
+++ b/example/scheduler-example.tsx
@@ -7,12 +7,12 @@ import { appointments } from "./data";
 
 class DateCell extends React.PureComponent<any> {
     public render() {
-        const now = this.props.date;
+        const { date: now, text } = this.props.data;
         const start = new Date(now.getFullYear(), 0, 0).getTime();
         const dayNumber = Math.floor((now - start) / (1000 * 60 * 60 * 24));
         return (
             <div style={{ height: "50px", color: now.getDay() % 6 === 0 ? "red" : "" }}>
-                <h4>{this.props.text}</h4>
+                <h4>{text}</h4>
                 <h5>{dayNumber}</h5>
             </div>
         );
diff --git a/src/core/config.ts b/src/core/config.ts
new file mode 100644
index 0000000..761f007
--- /dev/null
+++ b/src/core/config.ts
@@ -0,0 +1,18 @@
+interface IOptions {
+    useLegacyTemplateEngine: boolean;
+  }
+
+let config: IOptions = {
+    useLegacyTemplateEngine: true
+};
+
+function setOptions(options: Partial<IOptions>): void {
+    config = {...config, ... options};
+}
+
+function getOption<TName extends keyof IOptions>(optionName: TName): IOptions[TName] {
+    return config[optionName];
+}
+
+export default setOptions;
+export { getOption };
diff --git a/src/core/dx-template.ts b/src/core/dx-template.ts
index 6f90be8..6303654 100644
--- a/src/core/dx-template.ts
+++ b/src/core/dx-template.ts
@@ -1,6 +1,7 @@
 import * as React from "react";
 
 import { generateID } from "./helpers";
+import { ITemplateArgs } from "./template";
 import { ITemplateUpdater } from "./template-updater";
 import { ITemplateWrapperProps, TemplateWrapper } from "./template-wrapper";
 
@@ -16,7 +17,7 @@ interface IDxTemplateData {
 }
 
 function createDxTemplate(
-    createContentProvider: () => (model: any) => any,
+    createContentProvider: () => (props: ITemplateArgs) => any,
     templateUpdater: ITemplateUpdater,
     keyFn?: (data: any) => string
 ): IDxTemplate {
@@ -40,19 +41,20 @@ function createDxTemplate(
             const container = unwrapElement(data.container);
 
             templateUpdater.setTemplate(templateId, () => {
-                const model = data.model;
-                if (model && model.hasOwnProperty("key")) {
-                    model.dxkey = model.key;
-                }
+                const props: ITemplateArgs = {
+                    data: data.model,
+                    index: data.index
+                };
+
                 const contentProvider = createContentProvider();
                 return React.createElement<ITemplateWrapperProps>(
                     TemplateWrapper,
                     {
-                        content: contentProvider(model),
+                        content: contentProvider(props),
                         container,
                         onRemoved: () => {
                             templateUpdater.removeTemplate(templateId);
-                            renderedModels.delete(model);
+                            renderedModels.delete(props);
                         },
                         onRendered: data.onRendered,
                         key: templateId
diff --git a/src/core/template-host.ts b/src/core/template-host.ts
index a32f011..cf5e11c 100644
--- a/src/core/template-host.ts
+++ b/src/core/template-host.ts
@@ -1,7 +1,8 @@
 import * as React from "react";
 
+import { getOption as getConfigOption } from "./config";
 import { createDxTemplate, IDxTemplate } from "./dx-template";
-import { ITemplateMeta, ITemplateProps } from "./template";
+import { ITemplateArgs, ITemplateMeta, ITemplateProps } from "./template";
 import { ITemplateUpdater } from "./template-updater";
 
 type PropsGetter = (propName: string) => any;
@@ -14,9 +15,26 @@ interface IIntegrationDescr {
     useChildren: (name: string) => boolean;
 }
 
+function normalizeProps(props: ITemplateArgs): ITemplateArgs | ITemplateArgs["data"] {
+    if (getConfigOption("useLegacyTemplateEngine")) {
+        const model = props.data;
+        if (model && model.hasOwnProperty("key")) {
+            model.dxkey = model.key;
+        }
+        return model;
+    }
+    return props;
+}
+
 const contentCreators = {
-    component: (name: string, propsGetter: PropsGetter) => React.createElement.bind(null, propsGetter(name)),
-    render: (name: string, propsGetter: PropsGetter) => propsGetter(name),
+    component: (name: string, propsGetter: PropsGetter) => (props: ITemplateArgs) => {
+        props = normalizeProps(props);
+        return React.createElement.bind(null, propsGetter(name))(props);
+    },
+    render: (name: string, propsGetter: PropsGetter) => (props: ITemplateArgs) => {
+        normalizeProps(props);
+        return propsGetter(name)(props.data, props.index);
+    },
     children: (_: string, propsGetter: PropsGetter) => () => propsGetter("children")
 };
 
diff --git a/src/core/template.ts b/src/core/template.ts
index 48965ec..81ac652 100644
--- a/src/core/template.ts
+++ b/src/core/template.ts
@@ -16,6 +16,11 @@ interface ITemplateProps {
     keyFn?: (data: any) => string;
 }
 
+interface ITemplateArgs {
+    data: any;
+    index?: number;
+}
+
 class Template extends React.PureComponent<ITemplateProps, any> {
     public render() {
         return null;
@@ -52,6 +57,7 @@ function findProps(child: React.ReactElement<any>): ITemplateProps | undefined {
 export {
     ITemplateMeta,
     ITemplateProps,
+    ITemplateArgs,
     Template,
     findProps
 };


Test patch:
diff --git a/src/core/__tests__/config.test.tsx b/src/core/__tests__/config.test.tsx
new file mode 100644
index 0000000..60673ba
--- /dev/null
+++ b/src/core/__tests__/config.test.tsx
@@ -0,0 +1,72 @@
+import config, { getOption } from "../../core/config";
+import { mount, React} from "./setup";
+import { TestComponent, WidgetClass } from "./test-component";
+
+class ComponentWithTemplates extends TestComponent {
+    protected _templateProps = [{
+        tmplOption: "item",
+        render: "itemRender",
+        component: "itemComponent",
+        keyFn: "itemKeyFn"
+    }];
+
+    constructor(props: any) {
+        super(props);
+    }
+}
+
+describe("useLegacyTemplateEngine", () => {
+    const originalValue = getOption("useLegacyTemplateEngine");
+
+    beforeEach(() => {
+        config({ useLegacyTemplateEngine: true });
+    });
+
+    afterEach(() => {
+        config({ useLegacyTemplateEngine: originalValue });
+    });
+
+    it("works for render-function template", () => {
+        const ItemTemplate = (data: any) => (
+            <div className={"template"}>
+                value: {data.value}, key: {data.key}, dxkey: {data.dxkey}
+            </div>
+        );
+
+        const component = mount(
+            <ComponentWithTemplates itemRender={ItemTemplate} />
+        );
+
+        const render = WidgetClass.mock.calls[0][1].integrationOptions.templates.item.render;
+
+        render({
+            container: document.createElement("div"),
+            model: { value: "Value", key: "key_1" }
+        });
+
+        component.update();
+        expect(component.find(".template").text()).toBe("value: Value, key: key_1, dxkey: key_1");
+    });
+
+    it("works for component template", () => {
+        const ItemTemplate = (props: any) => (
+            <div className={"template"}>
+                value: {props.value}, key: {props.key}, dxkey: {props.dxkey}
+            </div>
+        );
+
+        const component = mount(
+            <ComponentWithTemplates itemComponent={ItemTemplate} />
+        );
+
+        const render = WidgetClass.mock.calls[0][1].integrationOptions.templates.item.render;
+
+        render({
+            container: document.createElement("div"),
+            model: { value: "Value", key: "key_1" }
+        });
+
+        component.update();
+        expect(component.find(".template").text()).toBe("value: Value, key: , dxkey: key_1");
+    });
+});
diff --git a/src/core/__tests__/template.test.tsx b/src/core/__tests__/template.test.tsx
index 241bcaf..70d579c 100644
--- a/src/core/__tests__/template.test.tsx
+++ b/src/core/__tests__/template.test.tsx
@@ -1,10 +1,21 @@
 import * as events from "devextreme/events";
 
+import config, { getOption as getConfigOption } from "../../core/config";
 import ConfigurationComponent from "../../core/nested-option";
 import { Template } from "../../core/template";
 import { mount, React, shallow } from "./setup";
 import { TestComponent, Widget, WidgetClass } from "./test-component";
 
+const originalLegacyOption = getConfigOption("useLegacyTemplateEngine");
+
+beforeEach(() => {
+    config({ useLegacyTemplateEngine: false });
+});
+
+afterEach(() => {
+    config({ useLegacyTemplateEngine: originalLegacyOption });
+});
+
 // tslint:disable-next-line:max-classes-per-file
 class ComponentWithTemplates extends TestComponent {
 
@@ -20,21 +31,36 @@ class ComponentWithTemplates extends TestComponent {
     }
 }
 
-function renderTemplate(name: string, model?: any, container?: any, onRendered?: () => void): Element {
+function renderTemplate(name: string, model?: any, container?: any, index?: number, onRendered?: () => void): Element {
     model = model || {};
     container = container || document.createElement("div");
     const render = WidgetClass.mock.calls[0][1].integrationOptions.templates[name].render;
 
     return render({
-        container, model, onRendered
+        container, model, ...(index && { index }), onRendered
     });
 }
 
-function renderItemTemplate(model?: any, container?: any, onRendered?: () => void): Element {
-    return renderTemplate("item", model, container, onRendered);
+function renderItemTemplate(model?: any, container?: any, index?: number, onRendered?: () => void): Element {
+    return renderTemplate("item", model, container, index, onRendered);
 }
 
 function testTemplateOption(testedOption: string) {
+    let prepareTemplate = (render) => render;
+
+    if (testedOption === "itemComponent") {
+        prepareTemplate = (render) => {
+            // tslint:disable-next-line:max-classes-per-file
+            class ItemComponent extends React.PureComponent {
+                public props: { data: any, index?: number };
+                public render() {
+                    return render(this.props.data, this.props.index);
+                }
+            }
+            return ItemComponent;
+        };
+    }
+
     it("pass integrationOptions to widget", () => {
         const elementOptions: Record<string, any> = {};
         elementOptions[testedOption] = () => <div>Template</div>;
@@ -54,7 +80,9 @@ function testTemplateOption(testedOption: string) {
 
     it("renders", () => {
         const elementOptions: Record<string, any> = {};
-        elementOptions[testedOption] = (props: any) => <div className={"template"}>Template {props.text}</div>;
+        elementOptions[testedOption] = prepareTemplate((data: any) => (
+            <div className={"template"}>Template {data.text}</div>
+        ));
 
         const component = mount(React.createElement(ComponentWithTemplates, elementOptions));
 
@@ -129,7 +157,7 @@ function testTemplateOption(testedOption: string) {
 
     it("renders template removeEvent listener", () => {
         const elementOptions: Record<string, any> = {};
-        elementOptions[testedOption] = (props: any) => <div>Template {props.text}</div>;
+        elementOptions[testedOption] = prepareTemplate((data: any) => <div>Template {data.text}</div>);
         const component = mount(React.createElement(ComponentWithTemplates, elementOptions));
 
         const container = document.createElement("div");
@@ -140,7 +168,9 @@ function testTemplateOption(testedOption: string) {
 
     it("renders template removeEvent listener for table", () => {
         const elementOptions: Record<string, any> = {};
-        elementOptions[testedOption] = (props: any) => <tbody><tr><td>Template {props.text}</td></tr></tbody>;
+        elementOptions[testedOption] = prepareTemplate((data: any) => (
+            <tbody><tr><td>Template {data.text}</td></tr></tbody>
+        ));
         const component = mount(React.createElement(ComponentWithTemplates, elementOptions));
 
         const container = document.createElement("table");
@@ -153,11 +183,13 @@ function testTemplateOption(testedOption: string) {
 
     it("calls onRendered callback", () => {
         const elementOptions: Record<string, any> = {};
-        elementOptions[testedOption] = (props: any) => <div className={"template"}>Template {props.text}</div>;
+        elementOptions[testedOption] = prepareTemplate((data: any) => (
+            <div className={"template"}>Template {data.text}</div>
+        ));
         const component = mount(React.createElement(ComponentWithTemplates, elementOptions));
         const onRendered: () => void = jest.fn();
 
-        renderItemTemplate({ text: "with data" }, undefined, onRendered);
+        renderItemTemplate({ text: "with data" }, undefined, undefined, onRendered);
         component.update();
         jest.runAllTimers();
         expect(onRendered).toBeCalled();
@@ -174,7 +206,9 @@ function testTemplateOption(testedOption: string) {
 
     it("has templates in state with unique ids", () => {
         const elementOptions: Record<string, any> = {};
-        elementOptions[testedOption] = (props: any) => <div className={"template"}>Template {props.text}</div>;
+        elementOptions[testedOption] = prepareTemplate((data: any) => (
+            <div className={"template"}>Template {data.text}</div>
+        ));
         const component = shallow(React.createElement(ComponentWithTemplates, elementOptions));
 
         renderItemTemplate({ text: 1 });
@@ -187,7 +221,9 @@ function testTemplateOption(testedOption: string) {
 
     it("has templates in state with ids genetated with keyExpr", () => {
         const elementOptions: Record<string, any> = {};
-        elementOptions[testedOption] = (props: any) => <div className={"template"}>Template {props.text}</div>;
+        elementOptions[testedOption] = prepareTemplate((data: any) => (
+            <div className={"template"}>Template {data.text}</div>
+        ));
         elementOptions.itemKeyFn = (data) => data.text;
         const component = shallow(React.createElement(ComponentWithTemplates, elementOptions));
 
@@ -202,7 +238,9 @@ function testTemplateOption(testedOption: string) {
 
     it("removes deleted nodes from state", () => {
         const elementOptions: Record<string, any> = {};
-        elementOptions[testedOption] = (props: any) => <div className={"template"}>Template {props.text}</div>;
+        elementOptions[testedOption] = prepareTemplate((data: any) => (
+            <div className={"template"}>Template {data.text}</div>
+        ));
         const component = mount(React.createElement(ComponentWithTemplates, elementOptions));
 
         renderItemTemplate();
@@ -237,20 +275,39 @@ describe("function template", () => {
         component.update();
         expect(component.find(".template").html()).toBe('<div class="template">Template with data</div>');
     });
+
+    it("renders index", () => {
+        const itemRender: any = jest.fn((_, index: number) => {
+            return <div className={"template"}>Index {index}</div>;
+        });
+        const component = mount(
+            <ComponentWithTemplates itemRender={itemRender} />
+        );
+        renderItemTemplate(undefined, undefined, 5);
+
+        expect(itemRender).toBeCalled();
+        component.update();
+        expect(component.find(".template").html()).toBe('<div class="template">Index 5</div>');
+    });
 });
 
 describe("component template", () => {
     testTemplateOption("itemComponent");
 
-    it("renders key prop", () => {
-        const ItemTemplate = (props: any) => <div className={"template"}>key: {props.key}, dxkey: {props.dxkey}</div>;
+    it("renders index", () => {
+        const ItemTemplate = (props: any) => (
+            <div className={"template"}>
+                value: {props.data.value}, index: {props.index}
+            </div>
+        );
+
         const component = mount(
             <ComponentWithTemplates itemComponent={ItemTemplate} />
         );
 
-        renderItemTemplate({ key: "key_1" });
+        renderItemTemplate({ value: "Value" }, undefined, 5);
         component.update();
-        expect(component.find(".template").html()).toBe('<div class="template">key: , dxkey: key_1</div>');
+        expect(component.find(".template").text()).toBe("value: Value, index: 5");
     });
 });