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>
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>
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],
]);
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);
}
}
// ...
}
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>
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);
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);
}
}
}
// ...
}
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],
]);
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],
]);
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 };
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
};
};
};
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')));
}
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>
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)$'],
]);
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>
And it looks like this
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]
]);
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();
}
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.
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 };
};
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>
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?
Top comments (0)