javascriptformsangularangular2-formsangular2-components

angular 2 model driven nested form components


What I have:

I am building an ionic 2 app and have built a basic angular 2 component that contains

I will refer to this as my input component

I have a page component with a form on it, and currently have text inputs. 1 regular input (password) and 1 input wrapped in my input component (username).

this is the relevant portion of my page component

ngOnInit() {
  this.loginForm = this.formBuilder.group({
    username: ['', Validators.required],
    password: ['', Validators.required]
  });
}

This is the page component template

<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">

  <!-- My input component -->
  <aw-input-text id="username" name="Username" [formInput]="loginForm.controls.username"></aw-input-text>

  <!-- A standard input control -->
  <ion-item [class.error]="loginForm.controls.password.errors">
    <ion-label floating>Password</ion-label>
    <ion-input type="text" value="" name="password" formControlName="password"></ion-input>
    <p *ngIf="loginForm.controls.password.errors">This field is required!</p>
  </ion-item>

  <button type="submit" class="custom-button" [disabled]="!loginForm.valid" block>Login</button>

</form>

This is the template for my input component

<!-- Component template -->
<form [formGroup]="formGroup">
    <ion-item>
        <ion-label floating>{{inputName}}</ion-label>
        <ion-input type="text" formControlName="inputValue"></ion-input>
        <p *ngIf="!formGroup.controls.inputValue.valid">This field is required!</p>
    </ion-item>
</form>

and this is the input component

import {Component, Input} from '@angular/core';
import {FormBuilder} from '@angular/forms';

@Component({
  selector: 'aw-input-text',
  templateUrl: 'build/shared/aw-input-text/aw-input-text.html'
})
export class AwInputText {

  @Input('formInput')
  public formInput;
  @Input('name')
  public inputName;
  public formGroup;
  constructor(private formBuilder: FormBuilder) {
  }

  ngOnInit() {
     this.formGroup = this.formBuilder.group({
        inputValue: this.formInput
     });
  }

}

The component renders correctly.

The problem:

The input inside the component doesn't update the valid state of the the form it is in.

When I fill out the username then the password the form becomes valid

When I fill out the password then the username the form remains invalid

So the form can see the valid state of the input component, it's just that the input component changing valid state doesn't trigger the form to update.

Possible solution 1

As described in this article and plunk

https://scotch.io/tutorials/how-to-build-nested-model-driven-forms-in-angular-2

https://plnkr.co/edit/clTbNP7MHBbBbrUp20vr?p=preview

I could modify my page component to create a form group that contains a nested form group for every form control that I want to use inside of my input component

ngOnInit() {
  this.loginForm = this.formBuilder.group({
    username: this.formBuilder.group({
      username: ['', Validators.required],
    }),
    password: ['', Validators.required],
  });
}

This solution fits the scenario in the article where they are adding an array of input controls, but in my case I think this feels hacky

Possible solution 2

Another hacky solution I have considered is using an @output directive from my input component to trigger an event on the page component that refreshes the form whenever the input component is updated.

Update to the input component

this.formGroup.controls.inputValue.valueChanges.subscribe(value => {
  this.formUpdated.emit({
    value: value
  })
});

Update to the page component

public onUpdated(value){
  this.loginForm.updateValueAndValidity();
}

and an update the page component template

<aw-input-text id="username" name="Username" (updated)="onUpdated($event)" [formInput]="loginForm.controls.username"></aw-input-text>

This does give me the desired functionality, but I think it also seems a bit hacky having an event handler on every form page to make the input component work.

The question

Is there a way for me to make my component update the valid state of the form it is in (keeping in mind that I would want to re-use this component multiple times in each form) without resorting to the solution described above.


Solution

  • Thanks for the answers guys. In the end we created two components, a custom-form component and a custom-input component.

    We nest as many custom-input components as we need inside the custom-form component and the custom-form component uses @ContentChildren to identify and register all the child custom-input components.

    This way we don't have to pass the form into every input, and we don't have a mess of nested form groups for every input.

    // Each CustomInputText component exposes a FormControl and 
    // a control definition which has additional info about the control
    @ContentChildren(CustomInputText, {descendants: true})
    public customInputComponents: QueryList<CustomInputText>;
    
    private initialised;
    
    public ngAfterContentChecked() {
      // Only initialise the form group once
      if (!this.initialised) {
        this.initialised = true;
        this.customInputComponents.forEach((input)=>{
            this.formGroup.addControl(input.controlDefinition.id, input.formControl); 
        });
      }
    }