How to use reactive forms in a dynamic component

2019-01-15 16:14发布

Background

I receive client generated data from the server that contains HTML that I then use to create a dynamic component that gets injected and displayed in our client. The HTML I receive can contain one or many inputs that I need to bind to via Angular Reactive Forms.

Attempt 1:

I attempted to tackle this requirement by simply using the [innerHTML] property and creating dynamic Reactive Forms to bind to the inputs. However, that method fails due to technical limitations of using the innerHTML property. Once the HTML is rendered in the browser all properties are forced to lowercase text so any Angular directives or properties then fail. Such as *ngIf, *ngFor, [formGroup], formControlName, etc... Angular uses camelCase for just about everything and therefor once it is forced to lowercase text it all is ignored and this method is no longer a viable solution.

Attempt 2:

This time around I attempted to utilize Angulars NgTemplateOutlet to dynamically add the HTML to the component and then create and bind to a Reactive Form. This at first seemed like a great solution, but ultimately in order to get the html to render it requires the use of the [innerHTML] property, once again rendering this method useless (as described in my first attempt).

Attempt 3:

At last I discovered Dynamic Components and this solution is working partially. I can now succesfully create a well formed Angular HTML template that is rendered properly in the browser. However this only solves half of my requirement. At this point the HTML displays as expected, but I have been unable to create a Reactive Form and bind to the inputs.

The Problem

I now have a Dynamic Component that generates HTML that contains inputs that I need to bind to by creating a Reactive Form.

Attempt 4:

This attempt I placed all logic for creating the Reactive Form inside the Dynamic Component that gets created.

By using this method the dynamic components HTML is displayed, but I get a new error: "ERROR Error: formGroup expects a FormGroup instance. Please pass one in."

StackBlitz with error scenario

2条回答
放荡不羁爱自由
2楼-- · 2019-01-15 16:30

The Solution

Working StackBlitz with solution

The solution is to create the Reactive Form in the parent component. Then use Angulars dependency injection and inject the parent component into the Dynamic Component.

By injecting the parent component into the dynamic component you will have access to all of the parents components public properties including the reactive form. This solution demonstrates being able to create and use a Reactive Form to bind to the input in a dynamically generated component.

Full code below

import {
  Component, ViewChild, OnDestroy,
  AfterContentInit, ComponentFactoryResolver,
  Input, Compiler, ViewContainerRef, NgModule,
  NgModuleRef, Injector, Injectable
} from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import {
  ReactiveFormsModule, FormBuilder,
  FormGroup, FormControl, Validators
} from '@angular/forms';


@Injectable()
export class DynamicControlClass {
  constructor(public Key: string,
    public Validator: boolean,
    public minLength: number,
    public maxLength: number,
    public defaultValue: string,
    public requiredErrorString: string,
    public minLengthString: string,
    public maxLengthString: string,
    public ControlType: string
  ) { }
}

@Component({
  selector: 'app',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent implements AfterContentInit, OnDestroy {
  @ViewChild('dynamicComponent', { read: ViewContainerRef }) _container: ViewContainerRef;
  public ackStringForm: FormGroup;
  public ctlClass: DynamicControlClass[];
  public formErrors: any = {};
  public group: any = {};
  public submitted: boolean = false;

  private cmpRef;

  constructor(
    private fb: FormBuilder,
    private componentFactoryResolver: ComponentFactoryResolver,
    private compiler: Compiler,
    private _injector: Injector,
    private _m: NgModuleRef<any>) {
    this.ctlClass = [
      new DynamicControlClass('formTextField', true, 5, 0, '', 'Please enter a value', 'Must be Minimum of 5 Characters', '', 'textbox')]
  }

  ngOnDestroy() {
    //Always destroy the dynamic component
    //when the parent component gets destroyed
    if (this.cmpRef) {
      this.cmpRef.destroy();
    }
  }

  ngAfterContentInit() {
    this.ctlClass.forEach(dyclass => {
      let minValue: number = dyclass.minLength;
      let maxValue: number = dyclass.maxLength;

      if (dyclass.Validator) {
        this.formErrors[dyclass.Key] = '';

        if ((dyclass.ControlType === 'radio') || (dyclass.ControlType === 'checkbox')) {
          this.group[dyclass.Key] = new FormControl(dyclass.defaultValue || null, [Validators.required]);
        }
        else {
          if ((minValue > 0) && (maxValue > 0)) {
            this.group[dyclass.Key] = new FormControl(dyclass.defaultValue || '', [Validators.required, <any>Validators.minLength(minValue), <any>Validators.maxLength(maxValue)]);
          }
          else if ((minValue > 0) && (maxValue === 0)) {
            this.group[dyclass.Key] = new FormControl(dyclass.defaultValue || '', [Validators.required, <any>Validators.minLength(minValue)]);
          }
          else if ((minValue === 0) && (maxValue > 0)) {
            this.group[dyclass.Key] = new FormControl(dyclass.defaultValue || '', [Validators.required, <any>Validators.maxLength(maxValue)]);
          }
          else if ((minValue === 0) && (maxValue === 0)) {
            this.group[dyclass.Key] = new FormControl(dyclass.defaultValue || '', [Validators.required]);
          }
        }
      }
      else {
        this.group[dyclass.Key] = new FormControl(dyclass.defaultValue || '');
      }
    });

    this.ackStringForm = new FormGroup(this.group);

    this.ackStringForm.valueChanges.subscribe(data => this.onValueChanged(data));

    this.onValueChanged();

    this.addComponent();
  }

  private addComponent() {
    let template = `  <div style="border: solid; border-color:green;">
                      <p>This is a dynamic component with an input using a reactive form </p>
                      <form [formGroup]="_parent.ackStringForm" class="form-row">
                      <input type="text" formControlName="formTextField"  required> 
                      <div *ngIf="_parent.formErrors.formTextField" class="alert alert-danger">
                      {{ _parent.formErrors.formTextField }}</div>
                      </form><br>
                      <button (click)="_parent.submitForm()"> Submit</button>
                      <br>
                      </div>
                      <br>
                      `;
    @Component({
      template: template,
      styleUrls: ['./dynamic.component.css']
    })
    class DynamicComponent {
      constructor(public _parent: AppComponent) {}
    }
    @NgModule({ 
      imports: [
        ReactiveFormsModule,
        BrowserModule
        ], 
        declarations: [DynamicComponent] 
    })
    class DynamicComponentModule { }

    const mod = this.compiler.compileModuleAndAllComponentsSync(DynamicComponentModule);
    const factory = mod.componentFactories.find((comp) =>
      comp.componentType === DynamicComponent
    );
    const component = this._container.createComponent(factory);
  }

  private onValueChanged(data?: any) {
    if (!this.ackStringForm) { return; }
    const form = this.ackStringForm;

    for (const field in this.formErrors) {
      // clear previous error message (if any)
      this.formErrors[field] = '';
      const control = form.get(field);

      if ((control && control.dirty && !control.valid) || (this.submitted)) {

        let objClass: any;

        this.ctlClass.forEach(dyclass => {
          if (dyclass.Key === field) {
            objClass = dyclass;
          }
        });

        for (const key in control.errors) {
          if (key === 'required') {
            this.formErrors[field] += objClass.requiredErrorString + ' ';
          }
          else if (key === 'minlength') {
            this.formErrors[field] += objClass.minLengthString + ' ';
          }
          else if (key === 'maxLengthString') {
            this.formErrors[field] += objClass.minLengthString + ' ';
          }
        }
      }
    }
  }

  public submitForm(){
    let value = this.ackStringForm.value.formTextField;
    alert(value);
  }
}
查看更多
兄弟一词,经得起流年.
3楼-- · 2019-01-15 16:52

If I am reading this correctly, your Template (HTML) is outrunning your component initialization, specifically on the FormGroup. The best way to prevent this from happening is to attach an *ngIf statement to your form on which you have bound your FormGroup. That way it won't render until your FormGroup has been defined.

<form *ngIf="ackStringForm" [formGroup]="ackStringForm" novalidate>
查看更多
登录 后发表回答