Repository: DevExpress/devextreme-react
Pull request #286 (2019-01-17 16:05:20)

Implement adding collection config-components at runtime
Cherry-pick


Patch:
diff --git a/src/core/nested-option.ts b/src/core/nested-option.ts
index b81813a..9d0fe99 100644
--- a/src/core/nested-option.ts
+++ b/src/core/nested-option.ts
@@ -2,11 +2,13 @@ import * as React from "react";
 
 type RegisterNestedOptionFunc = (component: React.ReactElement<any>) => any;
 type UpdateFunc = (newProps, prevProps) => void;
+type MakeDirtyFunc = () => void;
 
 interface INestedOptionMeta {
     optionName: string;
     registerNestedOption: RegisterNestedOptionFunc;
     updateFunc: UpdateFunc;
+    makeDirty?: MakeDirtyFunc;
 }
 
 class NestedOption<P> extends React.PureComponent<P, any> {
@@ -23,6 +25,8 @@ class NestedOption<P> extends React.PureComponent<P, any> {
         this._registerNestedOption = meta.registerNestedOption;
         this._updateFunc = meta.updateFunc;
 
+        if (meta.makeDirty) { meta.makeDirty(); }
+
         this._isAttached = !!this._registerNestedOption && !!this._updateFunc && !!this._optionFullName;
 
     }
@@ -48,12 +52,18 @@ class NestedOption<P> extends React.PureComponent<P, any> {
             this._updateFunc(clearProps(this.props), prevProps);
         }
     }
+
+    public componentWillUnmount() {
+        const meta = this.props as any as INestedOptionMeta;
+        if (meta.makeDirty) { meta.makeDirty(); }
+    }
 }
 
-function clearProps(props) {
-    const result = { ...props };
+function clearProps(props: any) {
+    const result: INestedOptionMeta = { ...props };
     delete result.registerNestedOption;
     delete result.updateFunc;
+    delete result.makeDirty;
     return result;
 }
 
diff --git a/src/core/options-manager.ts b/src/core/options-manager.ts
index b59a1d6..44912cb 100644
--- a/src/core/options-manager.ts
+++ b/src/core/options-manager.ts
@@ -46,13 +46,14 @@ class OptionsManager {
     private _instance: any;
 
     private _updatingProps: boolean;
+    private _dirtyOptions: Record<string, INestedConfigDescr> = {};
 
     constructor(optionValueGetter: (name: string) => any, templateHost: TemplateHost) {
         this._optionValueGetter = optionValueGetter;
         this._templateHost = templateHost;
+
         this._setOption = this._setOption.bind(this);
         this._registerNestedOption = this._registerNestedOption.bind(this);
-
         this.registerNestedOption = this.registerNestedOption.bind(this);
         this.handleOptionChange = this.handleOptionChange.bind(this);
         this.processChangedValues = this.processChangedValues.bind(this);
@@ -86,7 +87,7 @@ class OptionsManager {
         let optionName;
 
         const nestedOption = this._nestedOptions[e.name];
-        if (nestedOption) {
+        if (nestedOption && nestedOption.elementEntries.length > 0) {
             optionName = e.fullName;
             const nestedOptionObj = separateProps(
                 nestedOption.elementEntries[0].element.props,
@@ -143,10 +144,18 @@ class OptionsManager {
             this._updatingProps = false;
             this._instance.endUpdate();
         }
+
+        Object.keys(this._dirtyOptions).forEach((optionName) => {
+            const optionDescr = this._dirtyOptions[optionName];
+            const optionValue = this._getNestedOptionObj(optionDescr, false);
+            this._setOption(optionName, optionValue);
+        });
+
+        this._dirtyOptions = {};
     }
 
     public getNestedOptionsObjects(): Record<string, any> {
-        return this._getNestedOptionsObjects(this._nestedOptions);
+        return this._getNestedOptionsObjects(this._nestedOptions, true);
     }
 
     public registerNestedOption(
@@ -180,38 +189,55 @@ class OptionsManager {
     }
 
     private _getNestedOptionsObjects(
-        optionsCollection: Record<string, INestedConfigDescr>
+        optionsCollection: Record<string, INestedConfigDescr>,
+        templateRegistrationRequired: boolean
     ): Record<string, any> {
         const configComponents: Record<string, any> = {};
 
         Object.keys(optionsCollection).forEach((key) => {
             const configComponent = optionsCollection[key];
-            const options = configComponent.elementEntries.map((e, index) => {
-                const props = separateProps(e.element.props,
-                    configComponent.defaults,
-                    configComponent.templates);
+            configComponents[configComponent.optionName] = this._getNestedOptionObj(
+                configComponent,
+                templateRegistrationRequired
+            );
+        });
+
+        return configComponents;
+    }
+
+    private _getNestedOptionObj(
+        configComponent: INestedConfigDescr,
+        templateRegistrationRequired: boolean
+    ): Record<string, any> | any {
+        const options = configComponent.elementEntries.map((e, index) => {
+            const props = separateProps(e.element.props,
+                configComponent.defaults,
+                configComponent.templates);
 
+            if (templateRegistrationRequired) {
                 this._templateHost.add({
                     useChildren: (optionName) => optionName === "template" && !!e.element.props.children,
                     props: props.templates,
                     templateProps: configComponent.templates,
-                    ownerName: `${configComponent.optionName}${configComponent.isCollectionItem ? `[${index}]` : ""}`,
+                    ownerName: this.buildFullOptionName(
+                        configComponent.optionName,
+                        configComponent.isCollectionItem ? index : undefined
+                    ),
                     propsGetter: (prop) => configComponent.elementEntries[index].element.props[prop]
                 });
+            }
 
-                return {
-                    ...e.predefinedProps,
-                    ...props.defaults,
-                    ...props.options,
-                    ...this._getNestedOptionsObjects(e.children)
-                };
-            });
-            configComponents[configComponent.optionName] = configComponent.isCollectionItem
-                ? options
-                : options[options.length - 1];
+            return {
+                ...e.predefinedProps,
+                ...props.defaults,
+                ...props.options,
+                ...this._getNestedOptionsObjects(e.children, templateRegistrationRequired)
+            };
         });
 
-        return configComponents;
+        return configComponent.isCollectionItem
+            ? options
+            : options[options.length - 1];
     }
 
     private _registerNestedOption(
@@ -235,7 +261,7 @@ class OptionsManager {
         const optionName = resolvedNested.optionName;
         const isCollectionItem = resolvedNested.isCollectionItem;
 
-        const entry = ensureNestedOption(
+        const option = ensureNestedOption(
             optionName,
             owningCollection,
             nestedOptionClass.type.DefaultsProps,
@@ -243,11 +269,11 @@ class OptionsManager {
             isCollectionItem
         );
 
-        const index = isCollectionItem ? `[${entry.elementEntries.length}]` : "";
-        let optionFullName = `${optionName}${index}`;
-        if (ownerFullName) {
-            optionFullName = `${ownerFullName}.${optionFullName}`;
-        }
+        const optionFullName = this.buildFullOptionName(
+            optionName,
+            isCollectionItem ? option.elementEntries.length : undefined,
+            ownerFullName
+        );
 
         const nestedOptionMeta: INestedOptionMeta = {
             optionName,
@@ -267,12 +293,17 @@ class OptionsManager {
                     addPrefixToKeys(newOptions, optionFullName + "."),
                     addPrefixToKeys(prevProps, optionFullName + ".")
                 );
+            },
+            makeDirty: () => {
+                if (this._instance && option.isCollectionItem) {
+                    this._dirtyOptions[option.optionName] = option;
+                }
             }
         };
 
         const optionComponent = createOptionComponent(element, nestedOptionMeta);
 
-        entry.elementEntries.push({
+        option.elementEntries.push({
             element,
             children: nestedOptionsCollection,
             predefinedProps: nestedOptionClass.type.PredefinedProps
@@ -281,6 +312,20 @@ class OptionsManager {
         return optionComponent;
     }
 
+    private buildFullOptionName(optionName: string, index?: number, ownerName?: string, ): string {
+        let optionFullName = optionName;
+
+        if (index !== undefined) {
+            optionFullName += `[${index}]`;
+        }
+
+        if (ownerName) {
+            optionFullName = `${ownerName}.${optionFullName}`;
+        }
+
+        return optionFullName;
+    }
+
     private _setGuard(optionName: string, optionValue: any): void {
         if (this._guards[optionName] !== undefined) {
             return;


Test patch:
diff --git a/src/core/__tests__/nested-option.test.tsx b/src/core/__tests__/nested-option.test.tsx
index f6f84a7..0db235c 100644
--- a/src/core/__tests__/nested-option.test.tsx
+++ b/src/core/__tests__/nested-option.test.tsx
@@ -222,14 +222,15 @@ describe("nested option", () => {
 
     it("is pulled after update", () => {
 
-        const component = shallow(
+        const TestContainer = (props: any) => (
             <TestComponent>
-                <NestedComponent a={123} />
+                <NestedComponent a={props.value} />
             </TestComponent>
         );
-        const nested = component.find(NestedComponent).dive();
 
-        nested.setProps({ a: 456 });
+        mount(<TestContainer value={123} />)
+            .setProps({ value: 456 });
+
         jest.runAllTimers();
         expect(Widget.option.mock.calls.length).toBe(1);
         expect(Widget.option.mock.calls[0]).toEqual(["option.a", 456]);
@@ -249,6 +250,64 @@ describe("nested option", () => {
         expect(Widget.option.mock.calls[0]).toEqual(["option.a", 456]);
     });
 
+    it("updates widget option when collection item added", () => {
+        const TestContainer = (props: any) => {
+            const nesteds = props.children.map((child: any) => (
+                <CollectionNestedComponent c={child.c} d={child.d} key={child.key} />
+            ));
+
+            return (<TestComponent>{nesteds}</TestComponent>);
+        };
+
+        const children = [
+            { c: 123, d: "abc", key: 1 },
+            { c: 456, d: "def", key: 2 },
+        ];
+        mount(<TestContainer children={children} />)
+            .setProps({
+                children: [
+                    { c: 123, d: "abc", key: 1 },
+                    { c: 456, d: "def", key: 2 },
+                    { c: 789, d: "ghi", key: 3 }
+                ]
+            });
+
+        jest.runAllTimers();
+        expect(Widget.option.mock.calls.length).toBe(1);
+        expect(Widget.option.mock.calls[0]).toEqual(["itemOptions", [
+            { c: 123, d: "abc" },
+            { c: 456, d: "def" },
+            { c: 789, d: "ghi" }
+        ]]);
+    });
+
+    it("updates widget option when collection item removed", () => {
+        const TestContainer = (props: any) => {
+            const nesteds = props.children.map((child: any) => (
+                <CollectionNestedComponent c={child.c} d={child.d} key={child.key} />
+            ));
+
+            return (<TestComponent>{nesteds}</TestComponent>);
+        };
+
+        const children = [
+            { c: 123, d: "abc", key: 1 },
+            { c: 456, d: "def", key: 2 },
+        ];
+        mount(<TestContainer children={children} />)
+            .setProps({
+                children: [
+                    { c: 123, d: "abc", key: 1 }
+                ]
+            });
+
+        jest.runAllTimers();
+        expect(Widget.option.mock.calls.length).toBe(1);
+        expect(Widget.option.mock.calls[0]).toEqual(["itemOptions", [
+            { c: 123, d: "abc" }
+        ]]);
+    });
+
 });
 
 describe("nested sub-option", () => {
@@ -389,17 +448,19 @@ describe("nested sub-option", () => {
 
     it("is pulled after update", () => {
 
-        const component = shallow(
+        const TestContainer = (props: any) => (
             <TestComponent>
                 <NestedComponent a={123} >
-                    <SubNestedComponent d={"abc"} />
+                    <SubNestedComponent d={props.value} />
                 </NestedComponent>
             </TestComponent>
         );
-        const nested = component.find(NestedComponent).dive();
-        const subNested = nested.find(SubNestedComponent).dive();
 
-        subNested.setProps({ d: "def" });
+        mount(<TestContainer value={"abc"} />)
+            .setProps({
+                value: "def"
+            });
+
         jest.runAllTimers();
         expect(Widget.option.mock.calls.length).toBe(1);
         expect(Widget.option.mock.calls[0]).toEqual(["option.subOption.d", "def"]);
diff --git a/src/core/__tests__/props-updating.test.tsx b/src/core/__tests__/props-updating.test.tsx
index fd5d052..a950317 100644
--- a/src/core/__tests__/props-updating.test.tsx
+++ b/src/core/__tests__/props-updating.test.tsx
@@ -86,35 +86,36 @@ describe("option update", () => {
     });
 
     it("updates nested collection item", () => {
-        const node = shallow(
+        const TestContainer = (props: any) => (
             <TestComponentWithExpectation>
-                <CollectionNestedComponent a={123}/>
+                <CollectionNestedComponent a={props.value} />
             </TestComponentWithExpectation>
         );
 
-        const nested = node
-            .find(CollectionNestedComponent).dive();
+        mount(<TestContainer value={123} />)
+            .setProps({
+                value: 234
+            });
 
-        nested.setProps({ a: 234 });
         jest.runAllTimers();
         expect(Widget.option.mock.calls.length).toBe(1);
         expect(Widget.option.mock.calls[0]).toEqual(["items[0].a", 234]);
     });
 
     it("updates sub-nested collection item", () => {
-        const node = shallow(
+        const TestContainer = (props: any) => (
             <TestComponentWithExpectation>
                 <CollectionNestedComponent>
-                    <CollectionSubNestedComponent a={123}/>
+                    <CollectionSubNestedComponent a={props.value} />
                 </CollectionNestedComponent>
             </TestComponentWithExpectation>
         );
 
-        const nested = node
-            .find(CollectionNestedComponent).dive()
-            .find(CollectionSubNestedComponent).dive();
+        mount(<TestContainer value={123} />)
+            .setProps({
+                value: 234
+            });
 
-        nested.setProps({ a: 234 });
         jest.runAllTimers();
         expect(Widget.option.mock.calls.length).toBe(1);
         expect(Widget.option.mock.calls[0]).toEqual(["items[0].subItems[0].a", 234]);
@@ -335,15 +336,17 @@ describe("cfg-component option control", () => {
     });
 
     it("rolls cfg-component option value back if value has no changes", () => {
-        const component = shallow(
+        const TestContainer = (props: any) => (
             <ControlledComponent>
-                <NestedComponent a={123} b="const" />
+                <NestedComponent a={props.value} b="const" />
             </ControlledComponent>
         );
-        const nested = component.find(NestedComponent).dive();
+
+        const container = mount(<TestContainer value={123} />);
 
         fireOptionChange("nestedOption.b", "changed");
-        nested.setProps({ a: 234 });
+        container.setProps({ value: 234 });
+
         jest.runAllTimers();
         expect(Widget.option.mock.calls.length).toBe(2);
         expect(Widget.option.mock.calls[0]).toEqual(["nestedOption.a", 234]);
@@ -351,15 +354,20 @@ describe("cfg-component option control", () => {
     });
 
     it("apply cfg-component option change if value really change", () => {
-        const component = shallow(
+        const TestContainer = (props: any) => (
             <ControlledComponent>
-                <NestedComponent a={123} b="const" />
+                <NestedComponent a={props.value} b="const" />
             </ControlledComponent>
+
         );
-        const nested = component.find(NestedComponent).dive();
 
+        const container = mount(<TestContainer value={123} />);
         fireOptionChange("nestedOption.a", 234);
-        nested.setProps({ a: 234 });
+
+        container.setProps({
+            value: 234
+        });
+
         jest.runAllTimers();
         expect(Widget.option.mock.calls.length).toBe(1);
         expect(Widget.option.mock.calls[0]).toEqual(["nestedOption.a", 234]);
@@ -415,17 +423,18 @@ describe("cfg-component option defaults control", () => {
     });
 
     it("ignores 3rd-party changes in nested default props", () => {
-        const component = shallow(
+
+        const TestContainer = (props: any) => (
             <ControlledComponent>
-                <NestedComponent defaultC="default" />
+                <NestedComponent defaultC={props.optionDefValue} />
             </ControlledComponent>
-
         );
-        const nested = component.find(NestedComponent).dive();
 
-        nested.setProps({
-            defaultC: "changed"
-        });
+        mount(<TestContainer optionDefValue={"default"} />)
+            .setProps({
+                optionDefValue: "changed"
+            });
+
         jest.runAllTimers();
         expect(Widget.option.mock.calls.length).toBe(0);
     });