-->

angular 5 nested form child component

2019-04-12 13:55发布

问题:

I have a series of forms (each managed by 1 component).

There is a pattern of inputs in these forms (e.g. many of them require to allow input of an address) that I have to refactor into re-usable components as they are used in multiple forms and I don't want to duplicate neither their logic nor their templates.

Each re-usable component would have to

  1. have its logic
  2. have its template containing input tags and no <form> tag
  3. have its client validation constraints
  4. possibly receive initial values from its parent
  5. be able to return the value of its fields to the parent as an object (e.g. address: {street: "...", "city": "...", ...})
  6. make the parent form invalid if its validation constraints are not satisfied
  7. make the parent form "touched" once its values have been changed by the user

From this tutorial for Angular2, I understand how to achieve objectives 1, 2 and 4.

The solution in the tutorial allows to achieve also the other objectives, but it does so by doing everything from the parent (see app.component.ts#initAddress).

How can I achieve 3, 5, 6 and 7, while declaring controls and their constraints within the child?

回答1:

If you want to provide everything in the child component you can try something like this.

import { Component, Input } from '@angular/core';
import { FormGroupDirective, ControlContainer, Validators, FormGroup, FormControl } from '@angular/forms';

@Component({
  selector: 'address',
  template: `
    <div formGroupName="address">
      <input formControlName="city" placeholder="city" (blur)="onTouched" />
      <input formControlName="country" placeholder="country" (blur)="onTouched" />
      <input formControlName="zipCode" placeholder="zipCode" (blur)="onTouched" />
    </div>
  `,
  styles: [`h1 { font-family: Lato; }`],
  viewProviders: [
    { provide: ControlContainer, useExisting: FormGroupDirective }
  ]
})
export class AddressComponent {
  private form: FormGroup;

  constructor(private parent: FormGroupDirective) { }

  ngOnInit() {
    this.form = this.parent.form;

    const city = new FormControl('', Validators.required);
    const country = new FormControl('', Validators.required);
    const zipCode = new FormControl('', Validators.required);

    const address = new FormGroup({ city, country, zipCode });

    this.form.addControl('address', address);
  }
}

Usage:

import { Component } from '@angular/core';
import { FormGroup } from '@angular/forms';

@Component({
  selector: 'my-app',
  template: `
  <form [formGroup]="form">
    <address></address>
  </form>

  {{ form.value | json }}
  `,
  styleUrls: ['./app.component.css'],

})
export class AppComponent {
  form: FormGroup;

  constructor() {
    this.form = new FormGroup({});
  }
}

Please note that I'm using the ReactiveFormsModule.

Live demo

Also make sure to check out Angular Forms - Kara Erickson. The presentation shows you how to create a reusable address component with implementations for both, template driven and reactive forms.



回答2:

You shouldn't use such implementation. It much more clean to use ControlValueAccessor.

ControlValueAccessor is an interface that allows to angular form module (classic or reactive) to write value or state and register callback to retrieve changes and event.

writeValue(value: Address): void { } // Allows angular to set a default value to the component (used by FormControl or ngModel)

registerOnChange(fn: (_: any) => void): void {} // Callback to be called when the component value change.

registerOnTouched(fn: (_: any) => void): void { } // Callback to be called when a "touch" event occurs on the component

setDisabledState(isDisabled: boolean): void { } // Allows angular to update the component disable state.

But you also need to provide a NG_VALUE_ACCESSOR

I wrote this quick and dirty example for an address component:

import { Component, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { Address } from './address';

@Component({
  selector: 'app-address-input',
  template: `
    <label for="number">Num: </label>
    <input type="number" [disabled]="disabled" name="number" id="number" (change)="numberUpdate($event)" value="{{value.num}}"/><br />
   <label for="street">Street: </label>
   <input type="text" [disabled]="disabled" (change)="streetUpdate($event)"name="street" id="street" value="{{value.street}}" /><br />
   <label for="city">City: </label>
   <input type="text" [disabled]="disabled" name="city" id="city" value="{{value.city}}" (change)="cityUpdate($event)" /><br />
   <label for="zipCode">Zip Code: </label>
   <input type="text" [disabled]="disabled" name="zipCode" id="zipCode" value="{{value.zipCode}}" (change)="zipCodeUpdate($event)" /><br />
  <label for="state">State: </label>
  <input type="text" [disabled]="disabled" name="state" id="state" value="{{value.state}}" (change)="stateUpdate($event)" /><br />
  <label for="country">Country: </label>
  <input type="text" [disabled]="disabled" name="country" id="country" value="{{value.country}}" (change)="countryUpdate($event)" />`,
  providers: [{
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => AddressInputComponent) // forward the reference,
    multi: true // allow multiple component in the same form
    }]
})
export class AddressInputComponent implements ControlValueAccessor {

  private _onChange = (_: any) => {};
  private _onTouched = (_: any) => {};
  disabled = false;

  private _value: Address = {num: undefined, street: undefined, city: undefined, state: undefined, zipCode: undefined, country: undefined}; // current value (Address is just an interface)

  set value(value: Address) { // interceptor for updating current value
    this._value = value;
   this._onChange(this._value);
  }

  get value() {
    return this._value;
  }

  writeValue(value: Address): void {
    if (value && value !== null) {
      this._value = value;
    }
  }

  registerOnChange(fn: (_: any) => void): void {
    this._onChange = fn;
  }

  registerOnTouched(fn: (_: any) => void): void {
    this._onTouched = fn; // didn't used it but you should for touch screen enabled devices.
  }

  setDisabledState(isDisabled: boolean): void {
    this.disabled = isDisabled;
  }

  numberUpdate(event: any) {
    // additional check or process
    this._updateValue(event.target.value, 'num')
  }

  streetUpdate(event: any) {
    // additional check or process
    this._updateValue(event.target.value, 'street')
  }

  cityUpdate(event: any) {
    // additional check or process
    this._updateValue(event.target.value, 'city')
  }

  zipCodeUpdate(event: any) {
    // additional check or process
    this._updateValue(event.target.value, 'zipCode')
  }

  stateUpdate(event: any) {
    // additional check or process
    this._updateValue(event.target.value, 'state')
  }

  countryUpdate(event: any) {
    // additional check or process
    this._updateValue(event.target.value, 'country');
  }

  private _updateValue(value: any, field: string) {
    const newValue = this._value;
    newValue[field] = value;
    this.value = newValue;
  }
}

Then in a form use it like any other form element:

<form [formGroup]="registerForm">
  <app-address-input formControlName="address"></app-address-input>
</form>

You could add more logic into the component. Here is a working example. Please keep in mind it's a quick example and should be rework for cleaner code.

https://stackblitz.com/edit/angular-4dgxqh