How to allow nested components to be tracked by their parent and get values from their parent in Angular?

12,117

Solution 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.

Solution 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

Share:
12,117
Cec
Author by

Cec

Updated on June 12, 2022

Comments

  • Cec
    Cec almost 2 years

    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?

  • Cec
    Cec about 6 years
    Hi Tomasz, don't look at me for the downvote, but still this is actually the scenario I'd like to avoid, as I don't want the parent to define the child
  • Tomasz Kula
    Tomasz Kula about 6 years
    @Cec check out my edit. Everything is declared in the child component.
  • Cec
    Cec about 6 years
    Hi @Tomasz, who would be the injected formGroup? Am I correct in thinking that this would allow only one nesting level?
  • Tomasz Kula
    Tomasz Kula about 6 years
    It will inject the closest form group directive. In this example it's the one in the my-app component.
  • Cec
    Cec about 6 years
    Hi, in this solution the parent provides the validation constraints for the child address. Is it possible to define them in the child implementing ControlValueAccessor? (I don't want to repeat these constraints wherever I use the child component)
  • JEY
    JEY about 6 years
    Yes you can just add it into the update method and return null or undefined until address is valid
  • Cec
    Cec about 6 years
    Can you elaborate a bit on this? I mean there are many update methods in the address component atm. Or if u could update the stackblitz so that I can literally "see" what you mean :)
  • JEY
    JEY about 6 years
    I updated the stackblitz to change where the validation occurs.
  • Cec
    Cec about 6 years
    Returning null would work only if the address is required, but in cases where it is optional...
  • JEY
    JEY about 6 years
    Then the validation should occurs at the form definition and not in the component itself. The validator i created previously could be reused in any form. Required is base on null and undefined that's why i returned null.
  • JEY
    JEY about 6 years
    here is a quick example stackblitz.com/edit/angular-eu5zns you have everything know.
  • Mcanic
    Mcanic over 5 years
    Thank you for this solution. I think it's the easiest way to not have to know all formcontrols in the most parent component. This way validation works from bottom to top