Angular 2. How to use array of objects for controls in Reactive Forms

I need to dynamic create textarea for forms. I have the following model:

this.fields = {
      isRequired: true,
      type: {
        options: [
          {
            label: 'Option 1',
            value: '1'
          },
          {
            label: 'Option 2',
            value: '2'
          }
        ]
      }
    };

And form:

this.userForm = this.fb.group({
      isRequired: [this.fields.isRequired, Validators.required],
      //... here a lot of other controls
      type: this.fb.group({
         options: this.fb.array(this.fields.type.options),
      })
});

Part of template:

<div formGroupName="type">
       <div formArrayName="options">
         <div *ngFor="let option of userForm.controls.type.controls.options.controls; let i=index">
            <textarea [formControlName]="i"></textarea>
         </div>
      </div>
</div>

So, as you can see I have an array of objects and I want to use label property to show it in a textarea. Now I see [object Object]. If I change options to a simple string array, like: ['Option 1', 'Option 2'], then all works fine. But I need to use objects. So, instead of:

<textarea [formControlName]="i"></textarea>

I have tried:

<textarea [formControlName]="option[i].label"></textarea>

But, it doesn't work.
How can I use an array of objects?

This is Plunkr


Solution 1:

You need to add a FormGroup, which contains your label and value. This also means that the object created by the form, is of the same build as your fields object.

ngOnInit() {
  // build form
  this.userForm = this.fb.group({
    type: this.fb.group({
      options: this.fb.array([]) // create empty form array   
    })
  });

  // patch the values from your object
  this.patch();
}

After that we patch the value with the method called in your OnInit:

patch() {
  const control = <FormArray>this.userForm.get('type.options');
  this.fields.type.options.forEach(x => {
    control.push(this.patchValues(x.label, x.value))
  });
}

// assign the values
patchValues(label, value) {
  return this.fb.group({
    label: [label],
    value: [value]
  })    
}

Finally, here is a

Demo

Solution 2:

The answer from AJT_82 was so useful to me, I thought I would share how I reused his code and built a similar example - one that might have a more common use case, which is inviting several people to sign-up at once. Like this: form screenshot

I thought this might help others, so that's why I am adding it here.

You can see the form is a simple array of text inputs for emails, with a custom validator loaded on each one. You can see the JSON structure in the screenshot - see the pre line in the template (thanks to AJT), a very useful idea whilst developing to see if your model and controls are wired up!

So first, declare the objects we need. Note that 3 empty strings are the model data (which we will bind to the text inputs):

      public form: FormGroup;
      private control: FormArray;
      private emailsModel = { emails: ['','','']} // the model, ready to hold the emails
      private fb : FormBuilder;

The constructor is clean (for easier testing, just inject my userService to send the form data to after submit):

      constructor(
        private _userService: UserService,
      ) {}

The form is built in the init method, including storing a reference to the emailsArray control itself so we can check later whether its children (the actual inputs) are touched and if so, do they have errors:

      ngOnInit() {
        this.fb = new FormBuilder;
        this.form = this.fb.group({
          emailsArray: this.fb.array([])
        });
        this.control = <FormArray>this.form.controls['emailsArray'];
        this.patch();    
      }
    
      private patch(): void {
        // iterate the object model and extra values, binding them to the controls
        this.emailsModel.emails.forEach((item) => {
          this.control.push(this.patchValues(item));
        })
      }

This is what builds each input control (of type AbstracControl) with the validator:

      private patchValues(item): AbstractControl {
        return this.fb.group({
          email: [item, Validators.compose([emailValidator])] 
        })
      }

The 2 helper methods to check if the input was touched and if the validator raised an error (see the template to see how they are used - notice I pass the index value of the array from the *ngFor in the template):

      private hasError(i):boolean {
        // const control = <FormArray>this.form.controls['emailsArray'];
        return this.control.controls[i].get('email').hasError('invalidEmail');
      }
      private isTouched(i):boolean {
        // const control = <FormArray>this.form.controls['emailsArray'];
        return this.control.controls[i].get('email').touched;
      }

Here's the validator:

    export function emailValidator(control: FormControl): { [key: string]: any } {
        var emailRegexp = /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,3}$/;
        if (control.value && !emailRegexp.test(control.value)) {
            return { invalidEmail: true };
        }
    }

And the template:

    <form [formGroup]="form" (ngSubmit)="onSubmit(form.value)" class="text-left">
        <div formArrayName="emailsArray">
            <div *ngFor="let child of form.controls.emailsArray.controls; let i=index">
                <div class="form-group" formGroupName="{{i}}">
                    <input formControlName="email" 
                           class="form-control checking-field" 
                           placeholder="Email" type="text">
                    <span class="help-block" *ngIf="isTouched(i)">
                        <span class="text-danger" 
                              *ngIf="hasError(i)">Invalid email address
                        </span>
                    </span>
                </div>                   
            </div>
        </div>
        <pre>{{form.value | json }}</pre>            
        <div class="form-group text-center">
          <button class="btn btn-main btn-block" type="submit">INVITE</button>
        </div>
    </form>

For what it's worth, I had started with this awful mess - but if you look at the code below, you might more easily understand the code above!

      public form: FormGroup;
      public email1: AbstractControl;
      public email2: AbstractControl;
      public email3: AbstractControl;
      public email4: AbstractControl;
      public email5: AbstractControl;
    
      constructor(
        fb: FormBuilder
      ) { 
           this.form = fb.group({
          'email1': ['', Validators.compose([emailValidator])],
          'email2': ['', Validators.compose([emailValidator])],
          'email3': ['', Validators.compose([emailValidator])],
          'email4': ['', Validators.compose([emailValidator])],
          'email5': ['', Validators.compose([emailValidator])],
            });
        this.email1 = this.form.controls['email1'];
        this.email2 = this.form.controls['email2'];
        this.email3 = this.form.controls['email3'];
        this.email4 = this.form.controls['email4'];
        this.email5 = this.form.controls['email5'];
      }

and the above used 5 of these divs in the template - not very DRY!

    <div class="form-group">
        <input [formControl]="email1" class="form-control checking-field" placeholder="Email" type="text"> 
        <span class="help-block" *ngIf="form.get('email1').touched">
            <span class="text-danger" *ngIf="form.get('email1').hasError('invalidEmail')">Invalid email address</span>
        </span> 
    </div>

Solution 3:

I guess it's not possible with FormControlName.

You could use ngModel .. take a look at your modified plunker:

http://plnkr.co/edit/0DXSIUY22D6Qlvv0HF0D?p=preview

@Component({
  selector: 'my-app',
  template: `
    <hr>
    <form [formGroup]="userForm" (ngSubmit)="submit(userForm.value)">
     <input type="checkbox" formControlName="isRequired"> Required Field
    <div formGroupName="type">
       <div formArrayName="options">
         <div *ngFor="let option of userForm.controls.type.controls.options.controls; let i=index">
            <label>{{ option.value.label }}</label><br />

            <!-- change your textarea -->
            <textarea [name]="i" [(ngModel)]="option.value.value" [ngModelOptions]="{standalone: true}" ></textarea>
         </div>
      </div>
    </div>

    <button type="submit">Submit</button>
    </form>
    <br>
    <pre>{{userForm.value | json }}</pre>
  `,
})
export class App {
  name:string;
  userForm: FormGroup;
  fields:any;

  ngOnInit() {
    this.fields = {
      isRequired: true,
      type: {
        options: [
          {
            label: 'Option 1',
            value: '1'
          },
          {
            label: 'Option 2',
            value: '2'
          }
        ]
      }
    };

    this.userForm = this.fb.group({
      isRequired: [this.fields.isRequired, Validators.required],
      //... here a lot of other controls
      type: this.fb.group({
         // .. added map-function
         options: this.fb.array(this.fields.type.options.map(o => new FormControl(o))),
      })
    });
  }

  submit(value) {
    console.log(value);
  }

  constructor(private fb: FormBuilder) {  }

  addNumber() {
    const control = <FormArray>this.userForm.controls['numbers'];
    control.push(new FormControl())
  }
}