How to tackle creating complex form with lots of c

2020-02-08 06:41发布

问题:

Let's say that my generated html from angular2 app looks like this:

<app>
<form [formGroup]="myForm" (ngSubmit)="onSubmit(myForm.value)">
<panel-component>
    <mid-component>
        <inner-component-with-inputs>
            <input/>
        <inner-component-with-inputs>
    <mid-component>
</panel-component>
<panel-component>
    <mid-component>
        <inner-component-with-inputs>
            <input/>
        <inner-component-with-inputs>
    <mid-component>
</panel-component>

<!-- many many many fields -->

<button type="submit">Submit</button>
</form>
</app>

How can I set up my outer <form> in such a way that I can validate all inner inputs on submit? Do I have to pass myForm through @Input() all the way down from panel-component to inner-component-with-inputs? Or is there other way?

In my application I have very big form with multiple panels, subpanels, tabs, modals etc. and I need to be able to validate it all at once on submit.

All the tutorials and resources on the internet talk only about forms spanning one component/template.

回答1:

A common pattern you will see throughout the Angular source code, when parent/child relationships are involved, is the parent type adding itself as a provider to itself. What this does is allow child component to inject the parent. And there will on only be one instance of the parent component all the way down the component tree because of hierarchical DI. Below is an example of what that might look like

export abstract class FormControlContainer {
  abstract addControl(name: string, control: FormControl): void;
  abstract removeControl(name: string): void;
}

export const formGroupContainerProvider: any = {
  provide: FormControlContainer,
  useExisting: forwardRef(() => NestedFormComponentsComponent)
};

@Component({
  selector: 'nested-form-components',
  template: `
    ...
  `,
  directives: [REACTIVE_FORM_DIRECTIVES, ChildComponent],
  providers: [formGroupContainerProvider]
})
export class ParentComponent implements FormControlContainer {
  form: FormGroup = new FormGroup({});

  addControl(name: string, control: FormControl) {
    this.form.addControl(name, control);
  }

  removeControl(name: string) {
    this.form.removeControl(name);
  }
}

Some notes:

  • We're using an interface/abstract parent (FormControlContainer) for a couple reasons

    1. It decouples the ParentComponent from the ChildComponent. The child doesn't need to know anything about the specific ParentComponent. All it knows about is the FormControlContainer and the contract that is has.
    2. We only expose methods on the ParentComponent that want, through the interface contract.
  • We only advertise ParentComponent as FormControlContainer, so the latter is what we will inject.

  • We create a provider in the form of the formControlContainerProvider and then add that provider to the ParentComponent. Because of hierarchical DI, now all the children have access to the parent.

  • If you are unfamiliar with forwardRef, this is a great article

Now in the child(ren) you can just do

@Component({
  selector: 'child-component',
  template: `
    ...
  `,
  directives: [REACTIVE_FORM_DIRECTIVES]
})
export class ChildComponent implements OnDestroy {
  firstName: FormControl;
  lastName: FormControl;

  constructor(private _parent: FormControlContainer) {
    this.firstName = new FormControl('', Validators.required);
    this.lastName = new FormControl('', Validators.required);
    this._parent.addControl('firstName', this.firstName);
    this._parent.addControl('lastName', this.lastName);
  }

  ngOnDestroy() {
    this._parent.removeControl('firstName');
    this._parent.removeControl('lastName');
  }
}

IMO, this is a much better design than passing the FormGroup through @Inputs. As stated earlier, this is a common design throughout the Angular source, so I think it's safe to say that it's an acceptable pattern.

If you wanted to make the child components more reusable, you could make the constructor parameter @Optional().

Below is the complete source I used to test the above examples

import {
  Component, OnInit, ViewChildren, QueryList, OnDestroy, forwardRef, Injector
} from '@angular/core';
import {
  FormControl,
  FormGroup,
  ControlContainer,
  Validators,
  FormGroupDirective,
  REACTIVE_FORM_DIRECTIVES
} from '@angular/forms';


export abstract class FormControlContainer {
  abstract addControl(name: string, control: FormControl): void;
  abstract removeControl(name: string): void;
}

export const formGroupContainerProvider: any = {
  provide: FormControlContainer,
  useExisting: forwardRef(() => NestedFormComponentsComponent)
};

@Component({
  selector: 'nested-form-components',
  template: `
    <form [formGroup]="form">
      <child-component></child-component>
      <div>
        <button type="button" (click)="onSubmit()">Submit</button>
      </div>
    </form>
  `,
  directives: [REACTIVE_FORM_DIRECTIVES, forwardRef(() => ChildComponent)],
  providers: [formGroupContainerProvider]
})
export class NestedFormComponentsComponent implements FormControlContainer {

  form = new FormGroup({});

  onSubmit(e) {
    if (!this.form.valid) {
      console.log('form is INVALID!')
      if (this.form.hasError('required', ['firstName'])) {
        console.log('First name is required.');
      }
      if (this.form.hasError('required', ['lastName'])) {
        console.log('Last name is required.');
      }
    } else {
      console.log('form is VALID!');
    }
  }

  addControl(name: string, control: FormControl): void {
    this.form.addControl(name, control);
  }

  removeControl(name: string): void {
    this.form.removeControl(name);
  }
}

@Component({
  selector: 'child-component',
  template: `
    <div>
      <label for="firstName">First name:</label>
      <input id="firstName" [formControl]="firstName" type="text"/>
    </div>
    <div>
      <label for="lastName">Last name:</label>
      <input id="lastName" [formControl]="lastName" type="text"/>
    </div>
  `,
  directives: [REACTIVE_FORM_DIRECTIVES]
})
export class ChildComponent implements OnDestroy {
  firstName: FormControl;
  lastName: FormControl;

  constructor(private _parent: FormControlContainer) {
    this.firstName = new FormControl('', Validators.required);
    this.lastName = new FormControl('', Validators.required);
    this._parent.addControl('firstName', this.firstName);
    this._parent.addControl('lastName', this.lastName);
  }


  ngOnDestroy() {
    this._parent.removeControl('firstName');
    this._parent.removeControl('lastName');
  }
}


回答2:

There is easier way to pass formGroup and formControl into lower component - using @Inputs. Plunker: https://plnkr.co/edit/pd30ru?p=preview

In FormComponent (MgForms) [main] we do:

in code:

this.form = this.formBuilder.group(formFields);

in template:

<form [formGroup]="form" novalidate>

  <div class="mg-form-element" *ngFor="let element of fields">
    <div class="form-group">
      <label class="center-block">{{element.description?.label?.text}}:

        <div [ngSwitch]="element.type">
          <!--textfield component-->
          <div *ngSwitchCase="'textfield'"class="form-control">
            <mg-textfield
              [group]="form"
              [control]="form.controls[element.fieldId]"
              [element]="element">
            </mg-textfield>
          </div>    

          <!--numberfield component-->
          <div *ngSwitchCase="'numberfield'"class="form-control">
            <mg-numberfield
              [group]="form"
              [control]="form.controls[element.fieldId]"
              [element]="element">
            </mg-numberfield>
          </div>
        </div>

      </label>
    </div>
  </div>

</form>

In FieldComponent (MgNumberfield) [inner] we do:

in code:

@Input() group;
@Input() control;
@Input() element;

in template:

<div [formGroup]="group">
  <input
    type="text"
    [placeholder]="element?.description?.placeholder?.text"
    [value]="control?.value"
    [formControl]="control">
</div>