37
votes

How should I go about conditionally requiring a form field? I made a custom validator, but the conditional variables that I pass to the custom validator are static and remain their initial values. What should my custom validator look like to get updated conditional values? Perhaps there is a way to do this with Validators.required instead of a custom validator?

private foo: boolean = false;
private bar: boolean = true;

constructor(private _fb: FormBuilder) {
    function conditionalRequired(...conditions: boolean[]) {
      return (control: Control): { [s: string]: boolean } => {
        let required: boolean = true;
        for (var i = 0; i < conditions.length; i++) {
          if (conditions[i] === false) {
            required = false;
          }
        }
        if (required && !control.value) {
          return { required: true }
        }
      }
    }
    this.applyForm = _fb.group({
          'firstName': ['', Validators.compose([
            conditionalRequired(this.foo, !this.bar)
          ])],
          ...
    });
}

Update (May 17, 2016)

It's been a long time since posting this, but I'd like to reference the .include() and .exclude() methods available on the ControlGroup class for anyone out there who is trying to create this functionality. (docs) While there are probably use cases for a conditional Validator like above, I've found the inclusion and exclusion of controls, control groups, and control arrays to be a great way to handle this. Just set the required validator on the control you'd like and include/exclude it as you please. Hope this helps someone!

5
Looks fine to me ;-) - Thierry Templier
But when I change foo to true elsewhere, the custom validator still shows foo as false. Throwing a console.log(conditions) in the validator returns [false, false] when I know that foo actually is true. I need to get [true, false]. I'm assuming this is because the conditions are set once because it's inside the constructor. Any way of getting around this behavior? - Louis Cruz
The link to the docs in the update is broken - surfbird0713

5 Answers

21
votes

I wanted a more generic version of it so I have written an extra validator for it that can be composed together with other validators. I'm still just starting to look at the forms module so don't expect this to be the most efficient code ever or to work in edge cases, but it's a good start. It works for normal use cases and might serve as a good starting point for others.

Made for rc.4 and the new forms module, the revalidateOnChanges part may be horrible (not sure of the best way to cause this behaviour), use at your own risk! :)

How to use it

The validator takes two arguments, a conditional function that is given the formGroup and that is expected to return true if the validation is to apply and false otherwise, and a validator (which may be a composition). It will revalidate the field when the formGroup is updated, and it's can currently only check things inside the same formGroup, but that should be easy to fix.

this.formBuilder.group({
    vehicleType: ['', Validators.required],
    licencePlate: [
        '',
        ExtraValidators.conditional(
            group => group.controls.vehicleType.value === 'car',
            Validators.compose([
                Validators.required,
                Validators.minLength(6)
            ])
        ),
    ]
});

In this example you have two fields, vehicleType and licencePlate. The conditional statement will apply the composed validator (required and minLength) if the vehicleType is "car".

You can use compose to apply several different conditionals that may or may not apply at the same time. Here is a slightly more complex example:

this.formBuilder.group({
    country: ['', Validators.required],
    vehicleType: ['', Validators.required],
    licencePlate: [
        '',
        Validators.compose([
            ExtraValidators.conditional(
                group => group.controls.vehicleType.value === 'car',
                Validators.required
            ),
            ExtraValidators.conditional(
                group => group.controls.country.value === 'sweden',
                Validators.minLength(6)
            ),
        ])
    ]
});

In this case we apply required if type is "car" and we apply minLength if country is "Sweden". If only one condition applies only that validation will apply, if both conditions applies then both validations apply.

The validator itself

Note that the object comparison is just a simple brute force because we are working with small object, if you are using Ramda or something you could cut a lot of code.

export class ExtraValidators {
    static conditional(conditional, validator) {
        return function(control) {
            revalidateOnChanges(control);

            if (control && control._parent) {
                if (conditional(control._parent)) {
                    return validator(control);
                }
            }
        };
    }
}
function revalidateOnChanges(control): void {
    if (control && control._parent && !control._revalidateOnChanges) {
        control._revalidateOnChanges = true;
        control._parent
            .valueChanges
            .distinctUntilChanged((a, b) => {
                // These will always be plain objects coming from the form, do a simple comparison
                if(a && !b || !a && b) {
                    return false;
                } else if (a && b && Object.keys(a).length !== Object.keys(b).length) {
                    return false;
                } else if (a && b) {
                    for (let i in a) {
                        if(a[i] !== b[i]) {
                            return false;
                        }
                    }
                }
                return true;
            })
            .subscribe(() => {
                control.updateValueAndValidity();
            });

        control.updateValueAndValidity();
    }
    return;
}

NOTE: remember to import the operator:
import 'rxjs/add/operator/distinctUntilChanged';

10
votes

Following your comment I can see a potential problem. Since you provide conditions as primitive types to the function that creates the validators function, the values when calling the first one will be used. Even if they change after, the new values won't be taken into account.

To archive that you need to use an object for conditions as described below:

private foo: boolean = false;
private bar: boolean = true;

private conditions: any = {
  condition1: foo,
  condition2: !bar
};

constructor(private _fb: FormBuilder) {
    function conditionalRequired(conditions: any) {
      return (control: Control): { [s: string]: boolean } => {
        let required: boolean = true;
        for (var elt in conditions) {
          var condition = conditions[elt];
          if (conditions === false) {
            required = false;
          }
        }
        if (required && !control.value) {
          return { required: true };
        }
      }
    }
    this.applyForm = _fb.group({
          'firstName': ['', Validators.compose([
            conditionalRequired(conditions)
          ])],
          ...
    });
}

This way the conditions parameter can be used / updated by reference. To update your conditions, you need to do the following:

updateConditions() {
  this.conditions.condition1 = true;
  this.conditions.condition2 = true;
}

Here is a plunkr: https://plnkr.co/edit/bnX7p0?p=preview.

Edit

To run the validator when updating the conditions, you need to explicitly call the updateValueAndValidity method of the control. In this case, the valid attribute of both control and form will be updated accordingly:

updateConditions() {
  this.conditions.condition1 = true;
  this.conditions.condition2 = true;
  this.applyForm.controls.firstName.updateValueAndValidity();
}
8
votes

We can use setValidators() method to set dynamic validation for required after finding the controls -

this.applyForm.get('firstName').setValidators(setRequired());

setRequired() {
        if(1==1) {
            return [Validators.required];
        } else {
            return [];
        }   
    }
3
votes

I created a validator that accepts a function callback which allows me to make the validator reusable, but I can't seem to find a good way to make sure I call updateValueAndValidity() on the control with this validator without having to manually call it when the other control's value changes.

Validator

export class ValidationService {
    static conditionalRequired = (isRequiredFunction: Function) => {
        return (control: Control) => {
           if (!control.value && isRequiredFunction())
           ...

Component Page

private myControl1: Control = 
    new Control("", ValidationService.conditionalRequired(() => 
        { return this && this.model && this.model.SomeProperty === 'SomeValue' } ));

private myControl2: Control = 
    new Control("", 
    ValidationService.conditionalRequired(this.isControl2Required.bind(this)));

isControl2Required() { 
    return someCondition;
}
1
votes

Simply use enable() and disable() methods on AbstractControl.

this.myControl.valueChanges.subscribe(
      checked => {
        if (checked) {
          this.otherControl.enable();
        } else {
          this.otherControl.disable();
        }
      }
    );