0
\$\begingroup\$

This (minimal demo) code obviously works, but I am a bit taken aback by the hoops I needed to jump through (and little to no mention in Angular (v13) documentation about the technique). I'm still inexperienced with Angular in this regard...

I need to validate a form (Angular reactive form pattern) on both the field level and the form level:

  1. Field A (if supplied) needs to be exactly 3 or 4 characters long (field validation validateLength());
  2. At least one of Field A and Field B needs to be supplied (form validation validateOneOf(), but display error messages at the level of the applicable fields);
  3. The submit button should be disabled while the form is not valid.

I have found that in the form validator, I need to set errors for each field, BUT also clear errors for this validator if it is valid, WITHOUT removing any other errors. So is there any cleaner way to write validateOneOf()?

Template

<form [formGroup]="form" (ngSubmit)="onSubmit()">
  <div>
    <label>A</label><input type="text" formControlName="fieldA"/>
    <span *ngIf="form.get('fieldA').touched && form.get('fieldA').errors" class="errMsg">
      <span *ngIf="form.get('fieldA').errors.provideOneOf">Provide at least one of the values</span>
      <span *ngIf="form.get('fieldA').errors.length">Wrong length</span>
    </span>
  </div>

  <div>
    <label>B</label><input type="text" formControlName="fieldB"/>
    <span *ngIf="form.get('fieldB').touched && form.get('fieldB').errors" class="errMsg">
      <span *ngIf="form.get('fieldB').errors.provideOneOf">Provide at least one of the values</span>
    </span>
  </div>

  <div>
    <button type="submit" [disabled]="!form.valid">Submit</button>
  </div>
</form>

Component

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

@Component({
  selector: 'app-test-multi-field',
  templateUrl: './test-multi-field.component.html',
  styleUrls: ['./test-multi-field.component.css'],
})
export class TestMultiFieldComponent {
  form = this.formBuilder.group(
    {
      fieldA: ['', TestMultiFieldComponent.validateLength([3, 4])],
      fieldB: [''],
    },
    { validators: [TestMultiFieldComponent.validateOneOf(['fieldA', 'fieldB'])] }
  );

  constructor(private formBuilder: FormBuilder) {}

  static validateLength(lengths: number[]): ValidatorFn {
    return (control: FormControl): ValidationErrors | null => {
      const isValid = !control.value || lengths.some((len) => len === control.value?.length);
      return isValid ? null : { length: true };
    };
  }

  static validateOneOf(controls: string[]): ValidatorFn {
    const errKey = 'provideOneOf';
    const err = { [errKey]: true };

    return (form: FormGroup): ValidationErrors => {
      const isValid = controls.some((name) => form.get(name).value);
      controls.forEach((name) => {
        const errors = form.get(name).errors || {};
        if (isValid) {
          delete errors[errKey];
          form.get(name).setErrors(Object.keys(errors).length ? errors : null);
        } else {
          form.get(name).setErrors({ ...errors, ...err });
        }
      });
      return isValid ? null : err;
    };
  }

  onSubmit(): void {
    if (this.form.valid) {
      console.log('submitting presumed valid form');
    } else {
      console.log('tried to submit invalid form');
    }
  }
}
\$\endgroup\$

1 Answer 1

1
\$\begingroup\$

Your approach is pretty much straight forward, there is nothing I would complain about per se, just a few things I would do tweak.

I'd recommend moving the validator functions into a separate file, since you've made them form agnostic already. Also I would rather fine tune the error handling in your template than just removing the errors from the FormControll.

This would be my approach:

<form [formGroup]="form" (ngSubmit)="onSubmit()">
  <div>
    <label for="fieldA">A</label>
    <input
      id="fieldA"
      formControlName="fieldA"
      [ngClass]="{
        error: form.errors || (fieldA.invalid && fieldA.value !== '')
      }"
    />
    <span
      *ngIf="fieldA.invalid && (fieldA.dirty || fieldA.touched)"
      class="errMsg"
    >
      <span *ngIf="form.errors?.provideOneOf"
        >Provide at least one of the values</span
      >
      <span *ngIf="fieldA.value !== '' && fieldA.errors?.wrongLength"
        >Wrong length</span
      >
    </span>
  </div>
  <div>
    <label>B</label>
    <input
      type="text"
      formControlName="fieldB"
      [ngClass]="{ error: form.errors || fieldB.invalid }"
    />
    <span *ngIf="form.invalid && (fieldB.dirty || fieldB.touched)">
      <span *ngIf="form.errors?.provideOneOf"
        >Provide at least one of the values</span
      >
    </span>
  </div>

  <p>{{ form.errors | json }}</p>
  <p>{{ fieldA.errors | json }}</p>

  <div>
    <button
      type="submit"
      [disabled]="
        form.errors?.provideOneOf || (fieldA.value !== '' && fieldA.invalid)
      "
    >
      Submit
    </button>
  </div>
</form>
// custom.validators.ts
import {
  AbstractControl,
  FormGroup,
  ValidationErrors,
  ValidatorFn,
} from '@angular/forms';

export const validateOneOf = (formGroup: FormGroup): ValidationErrors | null => {
  let valid = Object.keys(formGroup.controls).some((key) => formGroup.controls[key]?.value !== '');
  return !valid ? { provideOneOf: true } : null;
};

export const validateLength = (min: number, max: number): ValidatorFn =>
  (control: AbstractControl): ValidationErrors | null => {
    return !new RegExp(`^.{${min},${max}}$`).test(control.value.toString())
      ? { wrongLength: true }
      : null;
  };
import { Component } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { validateLength, validateOneOf } from './custom.validators';

@Component({
  selector: 'app-test-multi-field',
  templateUrl: './test-multi-field.component.html',
  styles: ['h1 { font-family: Lato; } .error {border: 1px solid #FF0000}'],
})
export class TestMultiFieldComponent {
  form = new FormGroup(
    {
      fieldA: new FormControl('', validateLength(3, 4)),
      fieldB: new FormControl(''),
    },
    { validators: validateOneOf }
  );

  get fieldA() {
    return this.form.get('fieldA')!;
  }

  get fieldB() {
    return this.form.get('fieldB')!;
  }

  constructor() {}

  onSubmit(): void {
    if (this.form.valid) {
      console.log('submitting presumed valid form');
    } else {
      console.log('tried to submit invalid form');
    }
  }
}

View on stackblitz.com

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.