4

I'm implementing a validator to check which lines have the same name.

I want to display an error message saying: "This line is duplicated" below the input box.

enter image description here

Now I'm customizing the FormArray validation so that it shows the error message line by line but don't know what to do next.

In app.component.html file:

<h4 class="bg-primary text-white p-2">
  Check for duplicate names
</h4>

<div [formGroup]="formArray">
  <div class="px-3 py-1" 
    *ngFor="let group of formArray.controls" [formGroup]="group">
    Name <input formControlName="name"/> 
    Content <input formControlName="content" class="w-20" disabled/>
  </div>
</div>

<hr>
<pre>
<b>Value :</b>
{{formArray.value | json}}
<b>Valid :</b> {{formArray.valid}}
<b>Errors :</b>
{{formArray.errors | json}}
</pre>

In app.component.ts file:

import { Component } from '@angular/core';
import { FormGroup, FormControl, FormArray, ValidatorFn } from '@angular/forms';

export function hasDuplicate(): ValidatorFn {
  return (formArray: FormArray): { [key: string]: any } | null => {
    const names = formArray.controls.map(x => x.get('name').value);
    const check_hasDuplicate = names.some(
      (name, index) => names.indexOf(name, index + 1) != -1
    );

    return check_hasDuplicate ? { error: 'Has Duplicate !!!' } : null;
  };
}

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  formArray = new FormArray(
    [
      this.createGroup({ name: 'abc', content: '' }),
      this.createGroup({ name: 'abc', content: '' }),
      this.createGroup({ name: 'bcd', content: '' })
    ],
    hasDuplicate()
  );

  createGroup(data: any) {
    data = data || { name: null, content: null };
    return new FormGroup({
      name: new FormControl(data.name),
      content: new FormControl(data.content)
    });
  }
}

Link to Stackblitz

I tried custom validator for each FormGroup inside FormArray but it has to scan and reset the whole FormGroups validator via keyup event. I don't feel very good.

2 Answers 2

2

Exmaple

Here is how I did it. But it still uses quite a few loops to check and setError for the formcontrols in the formarray.

This is still very bad when done with big data. It's only slightly faster than setting up a Validator for each form control.

In my opinion, the two ways below are similar.

Way 1:

In app.component.html file:

<div [formGroup]="formArray">
  <div class="px-3 py-1" *ngFor="let group of formArray.controls" [formGroup]="group">
    Name <input formControlName="name"/>
    Content <input formControlName="content" class="w-20" disabled/>
    <small class="d-block text-danger" 
      *ngIf="group.get('name').errors?.duplicated">
      This line is duplicated
    </small>
  </div>
</div>

In app.component.ts file:

import { Component } from '@angular/core';
import { FormGroup, FormControl, FormArray, ValidatorFn, ValidationErrors } from '@angular/forms';

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  formArray = new FormArray(
    [
      this.createGroup({ name: 'abc', content: '' }),
      this.createGroup({ name: 'abc', content: '' }),
      this.createGroup({ name: 'bcd', content: '' }),
      this.createGroup({ name: 'bcd', content: '' }),
      this.createGroup({ name: '', content: '' })
    ],
    this.hasDuplicate('name')
  );

  createGroup(data: any) {
    data = data || { name: null, content: null };
    return new FormGroup({
      name: new FormControl(data.name),
      content: new FormControl(data.content)
    });
  }

  duplicates = [];

  hasDuplicate(key_form): ValidatorFn {
    return (formArray: FormArray): { [key: string]: any } | null => {
      if (this.duplicates) {
        for (var i = 0; i < this.duplicates.length; i++) {
          let errors = this.formArray.at(this.duplicates[i]).get(key_form).errors as Object || {};
          delete errors['duplicated'];
          this.formArray.at(this.duplicates[i]).get(key_form).setErrors(errors as ValidationErrors);
        }
      }

      let dict = {};
      formArray.value.forEach((item, index) => {
        dict[item.name] = dict[item.name] || [];
        dict[item.name].push(index);
      });
      let duplicates = [];
      for (var key in dict) {
        if (dict[key].length > 1) duplicates = duplicates.concat(dict[key]);
      }
      this.duplicates = duplicates;

      for (const index of duplicates) {
        formArray.at(+index).get(key_form).setErrors({ duplicated: true });
      }

      return duplicates.length > 0 ? { error: 'Has Duplicate !!!' } : null;
    };
  }
}

Link to Stackblitz

Way 2:

In app.component.ts file:

import { Component, OnInit } from '@angular/core';
import { FormGroup, FormControl, FormArray, ValidationErrors } from '@angular/forms';

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
  formArray = new FormArray([
    this.createGroup({ name: 'abc', content: '' }),
    this.createGroup({ name: 'abc', content: '' }),
    this.createGroup({ name: 'bcd', content: '' }),
    this.createGroup({ name: 'bcd', content: '' }),
    this.createGroup({ name: '', content: '' })
  ]);

  createGroup(data: any) {
    data = data || { name: null, content: null };
    return new FormGroup({
      name: new FormControl(data.name),
      content: new FormControl(data.content)
    });
  }

  duplicates = [];

  ngOnInit() {
    setTimeout(() => {
      this.checkDuplicates('name');
    });
    this.formArray.valueChanges.subscribe(x => {
      this.checkDuplicates('name');
    });
  }

  checkDuplicates(key_form) {
    for (const index of this.duplicates) {
      let errors = this.formArray.at(index).get(key_form).errors as Object || {};
      delete errors['duplicated'];
      this.formArray.at(index).get(key_form).setErrors(errors as ValidationErrors);
    }
    this.duplicates = [];

    let dict = {};
    this.formArray.value.forEach((item, index) => {
      dict[item.name] = dict[item.name] || [];
      dict[item.name].push(index);
    });
    for (var key in dict) {
      if (dict[key].length > 1)
        this.duplicates = this.duplicates.concat(dict[key]);
    }
    for (const index of this.duplicates) {
      this.formArray.at(index).get(key_form).setErrors({ duplicated: true });
    }
  }
}

Link to Stackblitz

Sign up to request clarification or add additional context in comments.

2 Comments

.setErrors( Object.keys(errors as ValidationErrors)?.length ? (errors as ValidationErrors) : null ); not .setErrors(errors as ValidationErrors);
exactly, just wanted to write the same
1

It sounds like what you are trying to achieve is:

  • Watch each FormControl (nested in a FormGroup) to see whether it's value is the same as the value of any other FormControl (nested in a FormGroup) in a given FormArray

At a high level you then have two things you need to achieve:

  • Get the value of an individual FormControl and compare it to a list of all of the other values (i.e. as you already have done in your hasDuplicate validator function)
  • Assign an error to the individual FormGroups which contain the FormControls which are duplicate

The problem with creating a validator that will sit on the FormArray is that the error you return will be assigned to the FormArray itself and not the individual FormGroups. For example, if hasDuplicate() returns an error , you will have this resulting form structure:

formArray: {
    error: 'Has Duplicate !!!',
    controls: [
        formGroup1: { error: null },
        formGroup2: { error: null },
        formGroup3: { error: null }
        ...
    ]
}

What you would prefer to be able to attach your errors on an individual basis would be this:

formArray: {
    error: null,
    controls: [
        formGroup1: { error: 'Has Duplicate !!!' },
        formGroup2: { error: 'Has Duplicate !!!' },
        formGroup3: { error: null }
        ...
    ]
}

To achieve this you will then have to create validator functions which affect the FormGroup instead, so that when their validation condition returns true, it will correctly update the error property of the FormGroup which is in error and not the FormArray.

But how then would the FormGroup know the values of the other FormGroups to know whether it's a duplicate as it doesn't have this information? Turns out that validators are just functions, so you can pass them arguments, e.g.

export function groupIsDuplicate( formArray: FormArray ): ValidatorFn {
  return ( formGroup: FormGroup ): Record<string, any> | null => {
    const names: string[] =
      formArray
        ?.controls
        ?.map(
          ( formGroup: FormGroup ) => formGroup?.get( 'name' )?.value
        );

    const isDuplicate: boolean =
      names
        ?.filter(
          ( name: string ) => name === formGroup?.get( 'name' )?.value
        )
        ?.length > 1;

    return isDuplicate ? { error: 'Has Duplicate !!!' } : null
  }
}

As you can see we can access the FormArray by passing it into the validator as an argument. You could even pass in the formArray.value instead as in this specific example we are only interested in the FormArray value (but in other use cases you may also be interested in other properties of the FormArray, e.g. it's errors).

Now if you just assigned this to each FormGroup in the createGroup() function when the FormArray is created, the value of formArray that is used to get the list of values won't be updated if the FormArray changes. Your validator would be stuck looking at the original array, which would not be ideal if you wanted to add or remove FormGroups in future.

What you need to do instead is make sure that this validator is reapplied every time the FormArray is updated so that you can pass in an updated formArray argument with the latest set of names.

ngOnInit() {
   // ...
   this.watchForFormArrayChanges()
}

watchForFormArrayChanges() {
  this.formArray
    ?.valueChanges
    ?.pipe(
      startWith(
        this.formArray?.value
      )
    )
    ?.subscribe(
      () => this.setDuplicateValidation( this.formArray )
    )
}

setDuplicateValidation( formArray: FormArray ) {
  formArray
    ?.controls
    ?.forEach(
      ( formGroup: FormGroup ) => {
        formGroup?.clearValidators()
        formGroup?.setValidators( [ controlIsDuplicate( formArray ) ] )
      }
    )
}

This is a very heavy handed approach resetting all validators constantly in response to any value in the FormArray changing. You could improve on the performance of the above by only updating the validators if the value of the FormControl within the FormGroup had changed etc. But for the purposes of the question this should get you closer to where you originally needed to be

4 Comments

I wrote below that: "I tried custom validator for each FormGroup inside FormArray but it has to scan and reset the whole FormGroups validator via keyup event. I don't feel very good.". I understand what you say. But I needed a more groundbreaking idea.
The solution I suggested doesn't use keyup events to trigger the change. It uses formArray.valueChanges, which is an observable (series of events) that will emit any time the content of anything nested within it changes. tektutorialshub.com/angular/valuechanges-in-angular-forms
There is one more approach if you want to keep your array-level validator, which I wouldn't recommend, but that is to still manually set and clear errors on each of the FormGroups using setErrors(), e.g. formGroup.setErrors( { error: 'Has Duplicate !!!' } ) to set and formGroup.setErrors() to clear
Thanks for your suggestion. I'm trying to write a Validator instead of using valueChanges. I noticed when the content inside the FormArray changes, it also calls the Validator similar to valueChanges. Using setErrors() is a good idea.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.