DEV Community

Cover image for Angular validation common functions
Ayyash
Ayyash

Posted on • Originally published at garage.sekrab.com

Angular validation common functions

There are some repetitive validation functions that I would like to move for their own common file, after which, I want to turn the validation function into another unobtrusive pattern.

The final result should look like this

<cr-input placeholder="placeholder">
  <input crinput ... validator="functionName" />
</cr-input>
Enter fullscreen mode Exit fullscreen mode

Let’s create popular validators and see what it takes.

Date

The date and time fields are handled by browsers pretty well; they don’t need a lot of work. What we need to cover in our patterns is future dates, past dates, and date ranges.

// final result I want to work with
<cr-input placeholder="When?">
  <input crinput type="date" id="appointment" formControlName="appointment" 
    validator="future" />
  <ng-container helptext>Future date</ng-container>
</cr-input>
Enter fullscreen mode Exit fullscreen mode

Let’s change validator attribute into a validation function. We will follow the same method of patterns.

// new validators.ts

export const futureValidator = (control: AbstractControl): ValidationErrors | null => {
  // date is yyyy-mm-dd, should be int eh future
  const today = Date.now();

  if (!control.value) return null;
  const value = new Date(control.value);

  if (!value || +value > +today) {
    return null;
  }
  return {
    future: true
  };
};

// create a map to use globally
export const InputValidators = new Map<string, ValidatorFn>([
  ['future', futureValidator],
]);
Enter fullscreen mode Exit fullscreen mode

In the directive, we’ll check if the validation function already exists. If not, we’ll add it.

// input.directive

// add a new input
@Input() validator?: string;

// in validate handler, just add it
validate(control: AbstractControl): ValidationErrors | null {

  if (this.validator) {
    const _validator = InputValidators.get(this.validator);
    if (_validator && !control.hasValidator(_validator)) {
      control.addValidators(_validator);
    }
  }
  // ... 
}  
Enter fullscreen mode Exit fullscreen mode

There is another way to access the form input. However, it relies on having formControl defined and casting an Abstract control to a Form control. It’s ugly. And dependent on the form itself. I’m not using.

That’s it. We don’t need to validate. Then we just need to add a custom error message every time. For now that’s fine.

<cr-input placeholder="When?" error="Required and must be in the future">
  <input crinput type="date" id="appointment" formControlName="appointment" 
    validator="future" [required]="true" />
  <ng-container helptext>Future date</ng-container>
</cr-input>
Enter fullscreen mode Exit fullscreen mode

A past date is straight forward.

Enrich the inline validator

We can improve it a bit, let’s pass parameters unobtrusively too, like this

<input crinput type="date" id="birthdate" formControlName="birthdate" validator="pastFn"
[params]="{date: someDate}"  />

someDate = new Date(2000, 1, 1);
Enter fullscreen mode Exit fullscreen mode

That is what I call garnish, let’s add it, but let’s be very careful. If we ever have to fix a bug in it, we roll back to good old custom validation. I fixed a couple as I was writing this article! I suggest you implement your favorite method.

 // input.directive
@Input() validator?: string;
@Input() params?: any;

validate(control: AbstractControl): ValidationErrors | null {
    if (this.validator) {
        const _validator = InputValidators.get(this.validator);
        if (_validator && !control.hasValidator(_validator)) {
            // if params: apply function (note here params is no longer optional)
            if (this.params) {
                control.addValidators(_validator(this.params));
            } else {
                control.addValidators(_validator);
            }
        }
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Then in our validators collection, wrap the function in another function to pass the arguments. Notice how I choose to name it with Fn suffix, to remind myself it's a function that needs params. (We might enhance by making the params optional and have a different check, but I am not well motivated to do that.)

// vaidtaors.ts

// example pastvalidation with date
export const pastValidatorFn = (params: {date: string}): ValidatorFn => {
  return (control: AbstractControl): ValidationErrors | null => {
    if (!control.value) return null;

    const _date = makeDate(params.date);
    if(!_date) return null;

    const value = new Date(control.value);
    if (!value || +value < +_date) {
      return null;
    }
    return {
      past: true
    };
  };
};

// add to collection, the second argument of map is "any"
// if you want to reach 50s without a heart attack
export const InputValidators = new Map<string, any >([
    // ...
  ['pastFn', pastValidatorFn],
]);
Enter fullscreen mode Exit fullscreen mode

So far so good. The date range should now be easy, all we need to do is update the validation functions to accept two parameters, minimum and maximum. If one is null, it’s ever.

// validator.ts
// A generic function for date ranges, fix it as you see fit
export const dateRangeValidatorFn = (params: {minDate?: string, maxDate?: string}): ValidatorFn => {
  return (control: AbstractControl): ValidationErrors | null => {
    if (!control.value) return null;

    // make two dates if one is null, the other takes over, if both null, return null.
    const _min = makeDate(params.minDate);
    const _max = makeDate(params.maxDate);
    if (!_min && !_max) return null;

    // if both exist, range
    // if only one exists, check against that
    const _minDate = _min ? +_min : null;
    const _maxDate = _max ? +_max : null;
    const value = +(new Date(control.value));

    const future = _maxDate ? value < _maxDate : true;
    const past = value > _minDate; // null is also zero, so this works
    if (future && past) {
      return null;
    }

    return {
      dateRange: true
    };
  };
};

// add to map
export const InputValidators = new Map<string, ValidatorFn>([
    // ...
  ['dateRangeFn', dateRangeValidatorFn],
]);
Enter fullscreen mode Exit fullscreen mode

Since we are passing params as an object, there is enough dynamic behavior to it, but I haven’t fully tested it.

Form component

<cr-input placeholder="Date range" error="Out of range">
    <input crinput type="date" id="daterange" class="w100" formControlName="daterange" [params]="params" validator="dateRangeFn"  />
    <ng-container helptext>Between 1 Jan 2024 - 1 Jan 2025</ng-container>
</cr-input>

minDate = new Date(2024, 0, 1);
maxDate = new Date(2025, 0, 1);
params = { minDate: this.minDate, maxDate: this.maxDate };
Enter fullscreen mode Exit fullscreen mode

A date range selector.

Oh no. Use easypick. By far the least obtrusive. I hope they continue the path of “keeping this dead simple”

Password

To do this properly in our new validators shared function, it needs to pass the other field value dynamically. So we start with a function and a param.

// in the new validators.ts
export const matchPasswordFn = (pwd: AbstractControl): ValidatorFn => {
  return (control: AbstractControl): ValidationErrors | null => {
    // get password and match, if equal return null
    if (control?.value === pwd?.value) {
      return null;
    }
    return {
      matchPassword: true
    };
  };
};
Enter fullscreen mode Exit fullscreen mode

The two fields in our component before using the validator property looks like this

// compontent
template: `
  <cr-input placeholder="Password">
     <!-- update validity on change -->
     <input crinput type="password" id="pwd" (change)="fg?.get('pwd2')?.updateValueAndValidity()"
     formControlName="pwd" crpattern="password" [required]="true" />
    <ng-container helptext>Alphanumeric and special characters, 8 characters minimum</ng-container>
  </cr-input>

  <cr-input placeholder="Confirm password" error="Does not match" >
    <input crinput type="password" id="pwd2" formControlName="pwd2" [required]="true" />
    <ng-container helptext>Should match password</ng-container>
  </cr-input>`

  // in class, before 
  ngOnInit() {
    this.fg = this.fb.group({
      pwd: [''],
      // this won't work
      // pwd2: ['', matchPasswordFn(this.fg.get('pwd')],
      pwd2: ['']
    });

    // this will (import from validators)
    this.fg.get('pwd2').setValidators(matchPasswordFn(this.fg.get('pwd')));
  }
Enter fullscreen mode Exit fullscreen mode

Few things to notice:

  • Setting the validator directly in the form builder won’t work, because we need to access the form itself, which is not yet available.
  • We could have done a cross-form validation!
  • But because we did not, we needed to update validity of pwd2, whenever pwd changed. This is just to be as flexible as one can be.

We can import the matchPasswordFn directly from our validators function, but we can also use the validator property as follows:

// component

<cr-input placeholder="Confirm password" error="Does not match" >
    <!-- add the validator function directly -->
    <input crinput formControlName="pwd2" ...
        validator="matchPassword"
        [params]="fg?.get('pwd')"
    />
</cr-input>
Enter fullscreen mode Exit fullscreen mode

And then the same validation function can be used.

Upload

We begin with a file input that is required. It does not look great, but I will not force it to look any different because every project will has its own unique look for this field. Some might also allow drag and drop, or paste. So we’ll focus on the basics.

Format and required validations are easy. We can create a general image pattern in our patterns list. Adding accept attribute is an additional friendly feature.

// patterns
export const InputPatterns = new Map<string, any>([
 //...
  ['image', '.+\\\\.{1}(jpg|png|gif|bmp)$'],
]);
Enter fullscreen mode Exit fullscreen mode

The component looks like this

// component
<cr-input placeholder="Upload document">
  <input crinput type="file" id="file" formControlName="doc" crpattern="image"
  [attr.accept]="someArray" />
  <ng-container helptext>JPG, PNG only. 1 MB max.</ng-container>
</cr-input>
Enter fullscreen mode Exit fullscreen mode

And it looks like this

Upload validation

The size is a bit harder to capture. But we can still use what we have. A new validation function that accepts two parameters, one for the maximum size, and one for the file size.

// validators
// validate file size to be what?
export const sizeValidatorFn = (params: {size: number, max: number}): ValidatorFn => {
  return (control: AbstractControl): ValidationErrors | null => {
    if (!control.value) return null;
    // convert max from KB to bytes
    const _max = params.max * 1024;
    if (params.size > _max) {
      return {
        size: true
      };
    }

    return null;
  };
};

export const InputValidators = new Map<string, any >([
 // ...
  ['sizeFn', sizeValidatorFn]
]);
Enter fullscreen mode Exit fullscreen mode

And to use it, we need something like this

// component
template: `<cr-input placeholder="Upload document" error="Invalid format or size">
  <input crinput type="file" id="file" class="w100" formControlName="doc" crpattern="image"
   #f validator="sizeFn" [params]="fparams" (change)="updateSize(f)" />
  <ng-container helptext>JPG, PNG only. 1 MB max.</ng-container>
</cr-input>`

fparams: { size: number, max: number };
this.fg = this.fb.group({
  doc: [],
});
updateSize(f: HTMLInputElement) {
    // update the params, then update validity
    // because files is not an immidiate property of formControl
    this.fparams.size = f.files[0]?.size;
    this.fg.get('doc').updateValueAndValidity();
}
Enter fullscreen mode Exit fullscreen mode

This works as expected, but I think we can do better. The file input field is usually not part of the form, because the trend is to upload the file first to the cloud, then update our server record with the returned file unique identifier. We'll leave that to one fine Tuesday.

Upload validation

At least one

Let's add the "at least one" for group of checkboxes in there too.

// validators.ts
export const atleastOne = (control: AbstractControl): ValidationErrors | null => {
  // if all controls are false, return error

  const values = Object.values(control.value);
  if (values.some(v => v === true)) {
    return null;
  }

  return { atleastOne: true };
};
Enter fullscreen mode Exit fullscreen mode

Then use directly in a group of checkboxes

// form component
<cr-input placeholder="Colors" error="At least one color" >
    <div formGroupName="colors" crinput validator="atleastOne">
      <label>
        <input type="checkbox" name="colors" id="color1" formControlName="red">
        Red
      </label> 
      <br>
      <label>
        <input type="checkbox" name="colors" id="color2" formControlName="black">
        Black
      </label> <br>
      <label>
        <input type="checkbox" name="colors" id="color3" formControlName="green">
        Green
      </label> <br>
    </div>
</cr-input>
Enter fullscreen mode Exit fullscreen mode

That's enough.

Styling

In order to fine tune styling without making assumptions about the project style, nor using too smart CSS selectors, let's go back to few elements and dig deeper. Last episode. 😴

Did you speak up today?

References

Top comments (0)