Linking two fields in an Angular validator

Viewed 1763

I have an Angular 9 form where four fields are related. One is a checkbox, and the rest are inputs. When the checkbox is checked, the inputs should not be empty, but when it is not checked, it doesn't matter. I want to make validators for this, so that errors appear only when a field is empty and the first field is set to true.

I have also considered creating a local boolean representing the state of the checkmark, and passing this to the validator like so.

export function linkedFieldValidator(toggler: boolean): ValidatorFn {
  console.log('updated');
  return (control: AbstractControl): {[key: string]: any} | null => {
    return (toggler && control.value === '') ? {linkedField: {value: control.value}} : null;
  };
}

...
field: new FormControl('', linkedFieldValidator(this.checkboxvalue)),
...

This doesn't work, however, I imagine since it only passes the value of the boolean once and doesn't update after. Even calling updateValueAndValidity() doesn't work, which is odd to me (if this is not it, then what is its purpose?).

The structure of my FormGroup looks something like this:

this.form = this.formBuilder.group({
  name: new FormControl(''), // don't care
  address: new FormControl(''), // don't care
  car: new FormControl(false), // do care - this is the checkmark
  license_plate: new FormControl('', Validators.pattern(MY_LICENSE_PLATE_REGEX)), // shouldn't be empty when car
  mileage: new FormControl('') // shouldn't be empty when car
  hair: new FormControl(false), // do care - this is the checkmark
  hair_color: new FormControl(''), // shouldn't be empty when hair
});

As you can see, I have a couple of FormControlls through each other and I only want a couple of them to be linked. Another important thing to note is that, while the whole form can be made invalid if one of these conditions is violated, I want to be able to address each error individually so that I can display proper messages in the proper places.

I have no more ideas, can anyone help me? I am using reactive forms.

2 Answers

Edit 1:

DEMO with validation on the sub-form (where the inputs are located) and also on the main form

Original answer:

You can use cross-fields validation to check multiple fields in combination. Here I am having 1 checkbox (initial state is false or unchecked) and 3 input fields. In my custom validator I just check the value and set the corresponding form validation errors:

DEMO

export class ProfileEditorComponent {
  myForm = new FormGroup({
    'checker': new FormControl(false),
    'name': new FormControl(),
    'middleName': new FormControl(),
    'lastName': new FormControl()
}, { validators: myCustomValidator });

  constructor(private fb: FormBuilder) { }
}

export const myCustomValidator : ValidatorFn = (control: FormGroup): ValidationErrors | null => {
  const checker = control.get('checker').value;
  const name = control.get('name');
  if (checker) {
    if (name.value===null || name.value==="") {
      return {'firstNameMissing': true};
      // TODO: do the same for the other fields or any field combination
    } else {
      return null;
    }
  }
  return null;
};

Then the showing of the error messages is done as follows:

  <label>
    First Name:
    <input type="text" formControlName="name">
  </label>
  <div *ngIf="myForm.errors?.firstNameMissing && (myForm.touched || myForm.dirty)" class="cross-validation-error-message alert alert-danger">
    First name required.
  </div>

The problem is that you're passing only the initial value to the linkFieldValidator function.

In order to have the value dynamically, you could pass the linkFieldValidator through the FormGroup, like this:

readonly formGroup = this.formBuilder.group(
  {
    checkbox: '',
    name: ''
  },
  { validator: linkedFieldValidator }
);

Full sample:

import { ChangeDetectionStrategy, Component } from '@angular/core';
import {
  AbstractControl,
  FormBuilder,
  FormGroup,
  ValidationErrors,
  ValidatorFn,
} from '@angular/forms';

export const linkedFieldValidator = (formGroup: FormGroup): ValidationErrors | null => {
  const [checkboxFormControlValue, nameFormControlValue] = [
    formGroup.get('checkbox')!.value,
    formGroup.get('name')!.value
  ];

  return checkboxFormControlValue && !nameFormControlValue
    ? { linkedField: { value: nameFormControlValue } }
    : null;
};

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: 'input-overview-example',
  styleUrls: ['input-overview-example.css'],
  templateUrl: 'input-overview-example.html'
})
export class InputOverviewExample {
  readonly formGroup = this.formBuilder.group(
    {
      checkbox: '',
      name: ''
    },
    { validator: linkedFieldValidator }
  );

  constructor(private readonly formBuilder: FormBuilder) {}
}

DEMO


Edit 1: If you need the error to reside in each form control, you can change your linkedFieldValidator to:

export const linkedFieldValidator = (formGroup: FormGroup): null => {
  const { value: checkboxFormControlValue } = formGroup.get('checkbox')!;
  const inputFormControls = [
    formGroup.get('input1')!,
    formGroup.get('input2')!,
    formGroup.get('input3')!,
  ];

  inputFormControls.forEach(inputFormControl => {
    const { value } = inputFormControl;
    const errors = checkboxFormControlValue && !value ? { linkedField: { value } } : null;
    inputFormControl.setErrors(errors);
  });

  return null;
};

Note that if you need to keep other errors, you may need do some handling before setErrors.

DEMO

Edit 2:

For a generic approach that you can have multiple linked fields, you can do something like this:

type LinkedFormControl = Record<string, string | readonly string[]>;

const arrayify = <T>(itemOrItems: T | readonly T[]): readonly T[] => {
  return Array.isArray(itemOrItems) ? itemOrItems : [itemOrItems];
};

const getErrorObjectSanitized = <T extends object>(obj: T): T | null => {
  return Object.keys(obj).length === 0 ? null : obj;
};

const getErrorsFor = (
  checkerValue: boolean,
  formControl: FormControl,
): object | null => {
  const { errors, value } = formControl;
  const { error, ...oldErrors } = errors || {};
  const processedErrors = {
    ...(checkerValue && !value ? { error: true } : {}),
    ...oldErrors,
  };

  return getErrorObjectSanitized(processedErrors);
};

export const linkedFieldValidator = (linkedFormControls: LinkedFormControl) => {
  return (formGroup: FormGroup): ValidationErrors | null => {
    Object.keys(linkedFormControls).forEach(key => {
      const { value: checkerValue } = formGroup.get(key)!;
      const dependentKeys = arrayify(linkedFormControls[key]);

      dependentKeys
        .map(dependentKey => formGroup.get(dependentKey)!)
        .forEach((dependentFormControl: FormControl) => {
          dependentFormControl.setErrors(
            getErrorsFor(checkerValue, dependentFormControl),
          );
        });
    });

    return null;
  };
};

... and the call would be like this:

{
  validator: linkedFieldValidator({
    car: ['license_plate', 'mileage'],
    hair: 'hair_color',
  }),
},

DEMO

Related