2

I need some advice about ComponentFactoryResolver and architecture. I have n panels in my code (the backend is providing the number of panels) and in these panels, I have dynamic fields (the number is also provided by the backend). For example, every panel should have 4 input filed at the beginning. The fields can be removed or add, depending on user request. I have tried to solve this with the ComponentFactoryResolver, I have stuck a little bit.

First I have tried to have two nested loop, one for panels and one for fields - not working, the code below is never rendering on the page. It seems like ng template is not figuring out my dynamic fields - or I am missing something.

 <div #container *ngFor="let i of [1,2,3]"></div>

Second, I have moved the code from HTML to TypeScript, now I am using AfterViewInit cycle and I have managed to have dynamic filed on my page - but now I have a problem that all fields are shown in the first panel and there should be 4 fields per panel...

Also, buttons for adding and removing fields should work only for the concrete panel. For example: if I click on the second add button in the second panel, I show add filed in the second panel. In my case, this is only working for the first panel.

  1. Any idea how to solve this properly, as angular way?
  2. Do I use ComponentFactoryResolver properly?
  3. Why does the first solution with ngFor loop not work?
  4. How to use ComponentFactoryResolver and ngModel?
  5. Is this even possible, or I need to change my strategy completely?

I don't want to use some ngIf statement and define some number of fields. I want to learn the dynamic and generic way for solving this kind of issues.

I have made a plunker demo: https://plnkr.co/edit/FjCbThpmBmDcgixTpXOy?p=preview

Sorry about the long post. I hope that I have explained the issue very well. Any advice would be greatly appreciated.

Originator
  • 37
  • 6
  • In the end, I have resolved this task without ComponentFactoryResolver. For now, this is a solution for me: https://stackblitz.com/edit/angular-j3kfj8?file=app%2Fapp.component.ts Maybe this will help someone. Cheers! – Originator Apr 05 '18 at 12:15

1 Answers1

0

I have a similar requirement and used the ComponentFactoryResolver. However, I have put a wrapper around the ng-template like this:

@Component({
    selector: 'tn-dynamic',
    template: `<ng-template #container></ng-template>`,
    providers: [SubscriptionManagerService],
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class DynamicComponent<T>
    extends DynamicComponentBase<T, T, ComponentData>
    implements OnChanges, OnDestroy {

    @Input()
    set componentData(data: ComponentData) {
        if (!data) {
            return;
        }

        try {
            let type: Type<T> = getComponentType(data.type);

            this._factory = this.resolver.resolveComponentFactory(type);
            let injector = Injector.create([], this.vcRef.parentInjector);
            this._factoryComponent = this.container.createComponent(this._factory, 0, injector);
            this._currentComponent = this._factoryComponent.instance;
    this._factoryComponent.location.nativeElement.classList.add('tn-dynamic-child');

        } catch (er) {
            console.error(`The type ${data.type.library}
.${data.type.key} has not been registered. Check entryComponents?`);
            throw er;
        }
    }

    // Handle ngOnChanges, ngOnDestroy
}

Then I would put my loop around my component <tn-dynamic [componentData]="myComponentData">

Your componentData contains the type of control, so you would have another service that would return the right type based on the requested type.

When you start using resolver as such, your inputs/outputs are not assigned. So you have to handle that yourself.

ngOnChanges(changes: SimpleChanges) {
    let propertyWatch = this.getPropertyWatch();
    for (let change in changes) {
        if (change === propertyWatch) {
            let data = this.getComponentData(changes[change].currentValue);
            if (data) {
                if (data.inputs) {
                    this.assignInputs(data.inputs);
                }

                if (data.outputs) {
                    this.assignOutputs(data.outputs);
                }

                if (this.implementsOnChanges()) {
                    let dynamiChanges = DynamicChanges.create(data);
                    if (dynamiChanges) {
                        (<OnChanges><any>this._currentComponent).ngOnChanges(dynamiChanges);
                    }
                }
            }
        }
    }
}

private unassignVariables() {
    if (this.factory && this.factory.inputs) {
        for (let d of this.factory.inputs) {
            this._currentComponent[d.propName] = null;
        }
    }
}

protected assignInputs(inputs: ComponentInput) {
    for (let key in inputs) {
        if (inputs[key] !== undefined) {
            this._currentComponent[key] = inputs[key];
        }
    }
}

private assignOutputs(outputs: ComponentOutput) {
    for (let key in outputs) {
        if (outputs[key] !== undefined) {
            let eventEmitter: EventEmitter<any> = this._currentComponent[key];
            let subscription = eventEmitter.subscribe(m => outputs[key](m));
            this.sm.add(subscription);
        }
    }
}

Then, I found that it was better to handle my form inputs with formControl rather than ngModel. Especially when it comes to handle the validators. If you keep with ngModel, you will not be able to add/remove validators easily. However, creating a dynamic component WITH a [formControl] attached to the control generated with the ComponentFactoryResolver seems to be impossible. So I had to compile template on the fly. So I create a control with another service that looks like this:

const COMPONENT_NAME = 'component';

@Injectable()
export class RuntimeComponent {
    constructor(
        private compiler: Compiler,
        @Optional() @Inject(DEFAULT_IMPORTS_TOKEN)
        protected defaultImports: DefaultImports
    ) {
    }

    protected createNewComponent(tmpl: string, args: any[]): Type<any> {
        @Component({
            selector: 'tn-runtime-component',
            template: tmpl,
        })
        class CustomDynamicComponent<T> implements AfterViewInit, DynamicComponentData<T> {
            @ViewChild(COMPONENT_NAME)
            component: T;

            constructor(
                private cd: ChangeDetectorRef
            ) { }

            ngAfterViewInit() {
                this.cd.detectChanges();
            }
        }

        Object.defineProperty(CustomDynamicComponent.prototype, 'args', {
            get: function () {
                return args;
            }
        });

        // a component for this particular template
        return CustomDynamicComponent;
    }

    protected createComponentModule(componentType: any) {
        let imports = [
            CommonModule,
            FormsModule,
            ReactiveFormsModule
        ];

        if (this.defaultImports && this.defaultImports.imports) {
            imports.push(...this.defaultImports.imports);
        }

        @NgModule({
            imports: imports,
            declarations: [
                componentType
            ],
        })
        class RuntimeComponentModule {
        }
        // a module for just this Type
        return RuntimeComponentModule;
    }

    public createComponentFactoryFromStringSync(template: string, attributeValues?: any[]) {
        let type = this.createNewComponent(template, attributeValues);
        let module = this.createComponentModule(type);

        let mwcf = this.compiler.compileModuleAndAllComponentsSync(module);
        return mwcf.componentFactories.find(m => m.componentType === type);
    }

    public createComponentFactoryFromMetadataSync(selector: string, attributes: { [attribute: string]: any }) {
        let keys = Object.keys(attributes);
        let attributeValues = Object.values(attributes);
        let attributeString = keys.map((attribute, index) => {
            let isValueAFunctionAsString = typeof attributeValues[index] === 'function' ? '($event)' : '';
            return `${attribute}="args[${index}]${isValueAFunctionAsString}"`;
        }).join(' ');

        let template = `<${selector} #${COMPONENT_NAME} ${attributeString}></${selector}>`;
        return this.createComponentFactoryFromStringSync(template, attributeValues);
    }
}

My code is not perfect, and this is why I give you only the important parts. You have to play with the idea and make it work your way. I should write a blog post about this one day :)

I looked at your plunker and you are NOT using the #name properly when using a ngFor. You won't get it properly in TypeScript if your loop is working.

Also, you can't do *ngFor="" on an ng-template. So your loop is just not working. See https://toddmotto.com/angular-ngfor-template-element

Good luck!

jsgoupil
  • 3,788
  • 3
  • 38
  • 53
  • Hi, thank you for quick replay. About ngFor and ng-template, you are right. But it is the same if I try with simple div element... – Originator Apr 02 '18 at 08:54
  • You're in a loop, you can't reach for that element. If you want to reach to the element IN the loop, you can use this trick: https://stackoverflow.com/questions/32693061/how-can-i-select-an-element-in-a-component-template/41371577#41371577 – jsgoupil Apr 02 '18 at 08:58
  • I have looked at your code and it seems to me, that we have a totally different request. I only use text filed as dynamic fields, there is no need for the component type. These dynamic fields are always simple input fields - (always some string from the backend and user can also type some string into). Maybe you can provide me a plunker example to have the better understanding of your code? I have tried to use your code in my plunker but no luck... – Originator Apr 02 '18 at 11:01
  • Sorry, that's the max I can share at the moment. Try to fix your loop. And you will see, you can't use #container in a loop. So you should have something like `tn-dynamic`. That one will have #container. – jsgoupil Apr 03 '18 at 22:53
  • I have managed to fix my loop. But I am still struggling with removing the filed (it only removes last filed, but it should remove all the fields). Also, the binding will be very challenging. I have made a new stack blize example: https://stackblitz.com/edit/angular-dwmgfb?file=app%2Fdcl-wrapper.component.ts – Originator Apr 04 '18 at 09:26
  • That is still incorrect. You cannot use `#name` INSIDE a ngFor. To add/remove entries, change the variable `panels`. Don't reach in your child object. – jsgoupil Apr 04 '18 at 17:57