How to test custom cross field validators in Angular 18

Posted on: 26-10-2024

Angular 18 Testing Custom validator Reactive forms
  1. We need a custom validator
  2. We need a reactive form
  3. We need to edit our template
  4. We need to test our validator
#

Creating a custom cross field validator

This validator checks whether the first field (A) comes before the other field (B).

It returns an errorMap if a validation check fails and returns null if all is well.

/**
 * Checks whether control A comes before control B
 * @param controlNameA the name of control A which holds a Date string
 * @param controlNameB the name of control B which holds a Date string
 */
export function comesBefore(controlNameA: string, controlNameB: string): ValidatorFn {
  return (controls: AbstractControl): ValidationErrors | null => {
    const errorMap = { notBefore: true };

    // retrieve the controls using the control names
    const controlA: string = controls.get(controlNameA)?.value;
    const controlB: string = controls.get(controlNameB)?.value;

    // if for some reason the control values are falsy return an error map
    if (!controlA || !controlB) {
      return errorMap;
    }

    // convert the string values to Dates
    const dateA: Date = new Date(controlA);
    const dateB: Date = new Date(controlB);

    // if dateA comes after dateB, return an error map
    if (dateA > dateB) {
      return errorMap;
    }

    // no errors are present => dateA comes before dateB
    return null;
  }
}
#

Creating a reactive form

Now that we have our custom validator, let's make a reactive form that uses it.

Note that we passed the custom validator not to a specific control but to the FormGroup, a FormGroup can take another argument which in turn can accept an array of validators.

@Component({
  selector: 'app-my-form',
  standalone: true,
  imports: [
    ReactiveFormsModule
  ],
  templateUrl: './my-form.component.html',
  styleUrl: './my-form.component.css'
})
export class MyFormComponent {
  form = new FormGroup({
    startDate: new FormControl(''),
    endDate: new FormControl('')
  }, {
    validators: [comesBefore('startDate', 'endDate')]
  });
}
#

Displaying errors in the template

This is just a simple form with two inputs.

We want to validate that the start date comes before the end date and display an error message if this is not the case.

Note the @if statement in the template which checks wether the form has an error named notBefore. It also checks whether the form is dirty (if a value has been altered) and displays an error message if that is the case.

<form [formGroup]="form">
  <div class="input-wrapper">
    <label>Start date:</label>
    <input type="date" name="startDate" formControlName="startDate">
    @if (form.hasError('notBefore') && form.dirty) {
      <p class="error-message">Start date must come before the end date!</p>
    }
  </div>

  <div class="input-wrapper">
    <label>End date:</label>
    <input type="date" name="endDate" formControlName="endDate">
  </div>

  <button type="submit">Submit</button>
</form>

This is what the result looks like:

#

Testing the validator

describe('comes-before validator', () => {
  it('should return no error if control A comes before control B', () => {
    const currentDate = new Date();
    const previousYear = new Date();
    previousYear.setFullYear(previousYear.getFullYear() - 1);

    const result = comesBefore(
      'controlA',
      'controlB'
    )(
      new FormGroup({
        controlA: new FormControl(previousYear.toDateString()),
        controlB: new FormControl(currentDate.toDateString()),
      })
    );

    const errorMap = null;
    expect(result).toEqual(errorMap);
  });

  it('should return an error map if control A does not come before control B', () => {
    const currentDate = new Date();
    const previousYear = new Date();
    previousYear.setFullYear(previousYear.getFullYear() - 1);

    const result = comesBefore(
      'controlA',
      'controlB'
    )(
      new FormGroup({
        controlA: new FormControl(currentDate.toDateString()),
        controlB: new FormControl(previousYear.toDateString()),
      })
    );

    const errorMap = { notBefore: true };
    expect(result).toEqual(errorMap);
  });
});

Note this part:

const result = comesBefore(
  'controlA',
  'controlB'
)(
  new FormGroup({
    controlA: new FormControl(previousYear.toDateString()),
    controlB: new FormControl(currentDate.toDateString()),
  })
);

Because the comesBefore validator returns a ValidatorFn it needs to be called again with an AbstractControl argument.

comesBefore(...)(...)

And FormGroup extends AbstractControl thus satisfies this requirement.

Hope this helped!