diff --git a/angular.json b/angular.json index f9a82963128d48ae85b6f328e552f85630325107..0348f3534bccf87ab13ea65efaea6cde6b3cf21d 100644 --- a/angular.json +++ b/angular.json @@ -420,6 +420,34 @@ "styleext": "scss" } } + }, + "utils-form": { + "projectType": "library", + "root": "libs/utils/form", + "sourceRoot": "libs/utils/form/src", + "prefix": "ut", + "architect": { + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": ["libs/utils/form/tsconfig.lib.json", "libs/utils/form/tsconfig.spec.json"], + "exclude": ["**/node_modules/**", "!libs/utils/form/**"] + } + }, + "test": { + "builder": "@nrwl/jest:jest", + "options": { + "jestConfig": "libs/utils/form/jest.config.js", + "tsConfig": "libs/utils/form/tsconfig.spec.json", + "setupFile": "libs/utils/form/src/test-setup.ts" + } + } + }, + "schematics": { + "@nrwl/angular:component": { + "styleext": "scss" + } + } } }, "cli": { diff --git a/libs/utils/form/README.md b/libs/utils/form/README.md new file mode 100644 index 0000000000000000000000000000000000000000..3317e8430d01ba629d37e959db3dc73bdda6bc40 --- /dev/null +++ b/libs/utils/form/README.md @@ -0,0 +1,4 @@ +# @psu/utils/form + +Provides useful functions for working with @angular/forms, +and a directive to let you easily disable a form control. diff --git a/libs/utils/form/jest.config.js b/libs/utils/form/jest.config.js new file mode 100644 index 0000000000000000000000000000000000000000..0be2df24878549c688793173f3e4eb31f1edd1f5 --- /dev/null +++ b/libs/utils/form/jest.config.js @@ -0,0 +1,9 @@ +module.exports = { + name: 'utils-form', + preset: '../../../jest.config.js', + coverageDirectory: '../../../coverage/libs/utils/form', + snapshotSerializers: [ + 'jest-preset-angular/AngularSnapshotSerializer.js', + 'jest-preset-angular/HTMLCommentSerializer.js' + ] +}; diff --git a/libs/utils/form/ng-package.json b/libs/utils/form/ng-package.json new file mode 100644 index 0000000000000000000000000000000000000000..c781f0df4675ef304c7d745c98df9f59daa25dee --- /dev/null +++ b/libs/utils/form/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/utils/form/src/index.ts b/libs/utils/form/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d78e11b093f4de991688a97a1528fa185f0a798 --- /dev/null +++ b/libs/utils/form/src/index.ts @@ -0,0 +1,4 @@ +export * from './lib/disable-control/disable-control.directive'; +export * from './lib/disable-control/disable-control.module'; +export * from './lib/utils/form.utils'; +export { PsuValidators } from './lib/validators/psu.validators'; diff --git a/libs/utils/form/src/lib/disable-control/disable-control.directive.spec.ts b/libs/utils/form/src/lib/disable-control/disable-control.directive.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..64e04be6491c759a531f19e86a18f1c261692ef6 --- /dev/null +++ b/libs/utils/form/src/lib/disable-control/disable-control.directive.spec.ts @@ -0,0 +1,57 @@ +import { Component, Input } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { DisableControlDirective } from './disable-control.directive'; + +@Component({ + template: ` + + ` +}) +class TestComponent { + @Input() + public disabled: boolean; + public control: FormControl; + + constructor() { + this.control = new FormControl(); + } +} + +describe('DisableControlDirective', () => { + let fixture: ComponentFixture; + let component: TestComponent; + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ReactiveFormsModule], + declarations: [TestComponent, DisableControlDirective] + }); + + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeDefined(); + }); + + function getInput(): HTMLInputElement { + return fixture.debugElement.query(By.css('input')).nativeElement; + } + + it('should disable input when disabled is true', () => { + component.disabled = true; + fixture.detectChanges(); + expect(component.control.disabled).toBe(true); + expect(getInput().disabled).toBe(true); + }); + + it('should enable input when disabled is false', () => { + component.disabled = false; + fixture.detectChanges(); + expect(component.control.disabled).toBe(false); + expect(getInput().disabled).toBe(false); + }); +}); diff --git a/libs/utils/form/src/lib/disable-control/disable-control.directive.ts b/libs/utils/form/src/lib/disable-control/disable-control.directive.ts new file mode 100644 index 0000000000000000000000000000000000000000..2bb4eafaeb7112057b80b6fb991f1194852ad5e9 --- /dev/null +++ b/libs/utils/form/src/lib/disable-control/disable-control.directive.ts @@ -0,0 +1,15 @@ +import { Directive, Input } from '@angular/core'; +import { NgControl } from '@angular/forms'; + +@Directive({ + selector: '[utDisableControl]' +}) +export class DisableControlDirective { + constructor(private ngControl: NgControl) {} + + @Input('utDisableControl') + public set disableControl(condition: boolean) { + const action = !!condition ? 'disable' : 'enable'; + this.ngControl.control[action](); + } +} diff --git a/libs/utils/form/src/lib/disable-control/disable-control.module.ts b/libs/utils/form/src/lib/disable-control/disable-control.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..c43b914d74423931bd8618aaf8f2dbfbb7603fb2 --- /dev/null +++ b/libs/utils/form/src/lib/disable-control/disable-control.module.ts @@ -0,0 +1,11 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { DisableControlDirective } from './disable-control.directive'; + +@NgModule({ + imports: [CommonModule, FormsModule, ReactiveFormsModule], + declarations: [DisableControlDirective], + exports: [DisableControlDirective] +}) +export class DisableControlModule {} diff --git a/libs/utils/form/src/lib/utils/form.utils.spec.ts b/libs/utils/form/src/lib/utils/form.utils.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..4137157125da262c2fab3f36a9b65a895b7f2040 --- /dev/null +++ b/libs/utils/form/src/lib/utils/form.utils.spec.ts @@ -0,0 +1,17 @@ +import { FormUtils } from './form.utils'; + +describe('FormUtils', () => { + describe('safeTrim', () => { + it('should return original value when value is falsy', () => { + expect(FormUtils.safeTrim(undefined)).toBeUndefined(); + }); + + it('should return original value when value has no whitespace', () => { + expect(FormUtils.safeTrim('me')).toBe('me'); + }); + + it('should trim leading and trailing whitespace', () => { + expect(FormUtils.safeTrim(' some ')).toBe('some'); + }); + }); +}); diff --git a/libs/utils/form/src/lib/utils/form.utils.ts b/libs/utils/form/src/lib/utils/form.utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..548043dba791c9b8863ddc1beebfdd74c6e03ecd --- /dev/null +++ b/libs/utils/form/src/lib/utils/form.utils.ts @@ -0,0 +1,23 @@ +import { FormGroup, AbstractControl, FormArray } from '@angular/forms'; + +export class FormUtils { + public static touchAllFields(group: FormGroup): void { + Object.keys(group.controls).forEach(controlName => { + const control: AbstractControl = group.get(controlName); + if (control instanceof FormArray) { + const arr = control as FormArray; + for (let i = 0; i < arr.length; ++i) { + FormUtils.touchAllFields(arr.at(i) as FormGroup); + } + } else if (control instanceof FormGroup) { + FormUtils.touchAllFields(control); + } else { + control.markAsTouched({ onlySelf: true }); + } + }); + } + + public static safeTrim(value: string): string { + return value ? value.trim() : value; + } +} diff --git a/libs/utils/form/src/lib/validators/email.validator.spec.ts b/libs/utils/form/src/lib/validators/email.validator.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..3d7f3b5e9df7bc25abc497b291a42a2ff744ded5 --- /dev/null +++ b/libs/utils/form/src/lib/validators/email.validator.spec.ts @@ -0,0 +1,36 @@ +import { emailValidator } from './email.validator'; +import { FormControl } from '@angular/forms'; + +function getControl(value: string): FormControl { + const c = new FormControl(); + c.patchValue(value); + return c; +} + +describe('emailValidator', () => { + it('empty string should be valid', () => { + expect(emailValidator(getControl(''))).toBeNull(); + }); + + it('valid email should be valid', () => { + expect(emailValidator(getControl('irock2@gmail.com'))).toBeNull(); + }); + + it('invalid email with special characters ending should be invalid', () => { + expect(emailValidator(getControl('irock2@gmail.co!'))).toEqual({ + validateEmail: { + valid: false + }, + email: true + }); + }); + + it('invalid email should have error', () => { + expect(emailValidator(getControl('invalid'))).toEqual({ + validateEmail: { + valid: false + }, + email: true + }); + }); +}); diff --git a/libs/utils/form/src/lib/validators/email.validator.ts b/libs/utils/form/src/lib/validators/email.validator.ts new file mode 100644 index 0000000000000000000000000000000000000000..36a1d5ddbd617e3cae14c4e90e13dd219321cc4c --- /dev/null +++ b/libs/utils/form/src/lib/validators/email.validator.ts @@ -0,0 +1,26 @@ +import { ValidationErrors, AbstractControl } from '@angular/forms'; + +// tslint:disable-next-line:max-line-length +const RE: RegExp = new RegExp( + "^[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$" +); + +/** + * Validates an email address using the same regular expression the server uses. + * + * @param email the form control to validate + */ +export function emailValidator(email: AbstractControl): ValidationErrors { + const value = email.value; + if (!value) { + return null; + } + return RE.test(email.value) + ? null + : { + email: true, + validateEmail: { + valid: false + } + }; +} diff --git a/libs/utils/form/src/lib/validators/length.validators.spec.ts b/libs/utils/form/src/lib/validators/length.validators.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f110c41e2ace1a54a52e2689a1868600ffb0be1 --- /dev/null +++ b/libs/utils/form/src/lib/validators/length.validators.spec.ts @@ -0,0 +1,70 @@ +import { FormControl } from '@angular/forms'; +import { minLengthValidator, maxLengthValidator } from './length.validators'; + +describe('length validators', () => { + function getControl(value: any): FormControl { + const c = new FormControl(); + c.patchValue(value); + return c; + } + + describe('minLength', () => { + it('should be valid when empty', () => { + expect(minLengthValidator(3)(getControl(''))).toBeNull(); + }); + + it('should be valid when at length', () => { + expect(minLengthValidator(3)(getControl('abc'))).toBeNull(); + }); + + it('should be invalid when less than length', () => { + expect(minLengthValidator(2)(getControl('a'))).toEqual({ + minLength: { + requiredLength: 2, + actualLength: 1 + } + }); + }); + + it('should be invalid when less than length when trimmed', () => { + expect(minLengthValidator(3)(getControl(' a '))).toEqual({ + minLength: { + requiredLength: 3, + actualLength: 1 + } + }); + }); + }); + + describe('maxLength', () => { + it('should be valid when empty', () => { + expect(maxLengthValidator(3)(getControl(''))).toBeNull(); + }); + + it('should be valid when at length', () => { + expect(maxLengthValidator(3)(getControl('abc'))).toBeNull(); + }); + + it('should be invalid when greater than length', () => { + expect(maxLengthValidator(2)(getControl('abc'))).toEqual({ + maxLength: { + requiredLength: 2, + actualLength: 3 + } + }); + }); + + it('should be invalid when greater than length when trimmed', () => { + expect(maxLengthValidator(3)(getControl(' abcd '))).toEqual({ + maxLength: { + requiredLength: 3, + actualLength: 4 + } + }); + }); + + it('should be valid when at length when trimmed', () => { + expect(maxLengthValidator(3)(getControl(' abc '))).toBeNull(); + }); + }); +}); diff --git a/libs/utils/form/src/lib/validators/length.validators.ts b/libs/utils/form/src/lib/validators/length.validators.ts new file mode 100644 index 0000000000000000000000000000000000000000..36eccc87b7e2ffd27207f465ee974219899290d6 --- /dev/null +++ b/libs/utils/form/src/lib/validators/length.validators.ts @@ -0,0 +1,57 @@ +import { ValidatorFn, AbstractControl, ValidationErrors } from '@angular/forms'; + +function isEmptyValue(value: any): boolean { + return value === undefined || value === null || (typeof value === 'string' && value.trim().length === 0); +} + +export function minLengthValidator(minLength: number): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + if (isEmptyValue(control.value)) { + return null; + } + let length; + if (control.value) { + if (typeof control.value === 'string') { + length = control.value.trim().length; + } else { + length = control.value.length; + } + } else { + length = 0; + } + return length < minLength + ? { + minLength: { + requiredLength: minLength, + actualLength: length + } + } + : null; + }; +} + +export function maxLengthValidator(maxLength: number): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + if (isEmptyValue(control.value)) { + return null; + } + let length; + if (control.value) { + if (typeof control.value === 'string') { + length = control.value.trim().length; + } else { + length = control.value.length; + } + } else { + length = 0; + } + return length > maxLength + ? { + maxLength: { + requiredLength: maxLength, + actualLength: length + } + } + : null; + }; +} diff --git a/libs/utils/form/src/lib/validators/non-psu-email.validator.spec.ts b/libs/utils/form/src/lib/validators/non-psu-email.validator.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..66b237acfd502969b93e2589cec29c46611d8f6c --- /dev/null +++ b/libs/utils/form/src/lib/validators/non-psu-email.validator.spec.ts @@ -0,0 +1,28 @@ +import { FormControl } from '@angular/forms'; +import { nonPsuEmailValidator } from './non-psu-email.validator'; + +describe('nonPsuEmailValidator', () => { + function getValue(val: string): FormControl { + const ret = new FormControl(); + ret.patchValue(val); + return ret; + } + + it('should fail email address @psu.edu', () => { + expect(nonPsuEmailValidator(getValue('someone@psu.edu'))).toEqual({ + psuEmail: true + }); + }); + + it('should pass email address from a subdomain under psu.edu', () => { + expect(nonPsuEmailValidator(getValue('something@somewhere.psu.edu'))).toBeNull(); + }); + + it('should pass email from another tld', () => { + expect(nonPsuEmailValidator(getValue('someone@google.com'))).toBeNull(); + }); + + it('should pass email from another tricky tld', () => { + expect(nonPsuEmailValidator(getValue('someone@psu.edu.com'))).toBeNull(); + }); +}); diff --git a/libs/utils/form/src/lib/validators/non-psu-email.validator.ts b/libs/utils/form/src/lib/validators/non-psu-email.validator.ts new file mode 100644 index 0000000000000000000000000000000000000000..851f589150e748986362fa45ac61c1d9a8449cea --- /dev/null +++ b/libs/utils/form/src/lib/validators/non-psu-email.validator.ts @@ -0,0 +1,6 @@ +import { AbstractControl, ValidationErrors } from '@angular/forms'; + +export function nonPsuEmailValidator(email: AbstractControl): ValidationErrors { + const regexp = /^.+@psu.edu$/i; + return regexp.test(email.value) ? { psuEmail: true } : null; +} diff --git a/libs/utils/form/src/lib/validators/not-email.validator.spec.ts b/libs/utils/form/src/lib/validators/not-email.validator.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..18c690abd1d82090b841b96b9720ec7bb644a604 --- /dev/null +++ b/libs/utils/form/src/lib/validators/not-email.validator.spec.ts @@ -0,0 +1,36 @@ +import { notAnEmailValidator } from './not-email.validator'; +import { FormControl } from '@angular/forms'; + +describe('notAnEmailValidator', () => { + function getValue(val: string): FormControl { + const ret = new FormControl(); + ret.patchValue(val); + return ret; + } + + it('when value contains an at sign only', () => { + expect(notAnEmailValidator(getValue('@m'))).toEqual({ notAnEmail: true }); + }); + + it('when value contains an at sign at the beginning', () => { + expect(notAnEmailValidator(getValue('@me'))).toEqual({ notAnEmail: true }); + }); + + it('when value contains an at sign at the end', () => { + expect(notAnEmailValidator(getValue('me@'))).toEqual({ notAnEmail: true }); + }); + + it('when value contains an at sign in the middle', () => { + expect(notAnEmailValidator(getValue('you@me'))).toEqual({ + notAnEmail: true + }); + }); + + it('when value does not contain an at sign', () => { + expect(notAnEmailValidator(getValue('rpd123'))).toBeNull(); + }); + + it('when value is empty', () => { + expect(notAnEmailValidator(getValue(''))).toBeNull(); + }); +}); diff --git a/libs/utils/form/src/lib/validators/not-email.validator.ts b/libs/utils/form/src/lib/validators/not-email.validator.ts new file mode 100644 index 0000000000000000000000000000000000000000..5448a95fc087a014d932a4902e5d32b83903cb32 --- /dev/null +++ b/libs/utils/form/src/lib/validators/not-email.validator.ts @@ -0,0 +1,9 @@ +import { ValidationErrors, AbstractControl } from '@angular/forms'; + +export function notAnEmailValidator(control: AbstractControl): ValidationErrors { + return /@/.test(control.value) + ? { + notAnEmail: true + } + : null; +} diff --git a/libs/utils/form/src/lib/validators/office365-email.validator.spec.ts b/libs/utils/form/src/lib/validators/office365-email.validator.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..4f31d7184584955cce0a7b83b6574d44c5c9cb15 --- /dev/null +++ b/libs/utils/form/src/lib/validators/office365-email.validator.spec.ts @@ -0,0 +1,30 @@ +import { FormControl } from '@angular/forms'; +import { office365EmailValidator } from './office365-email.validator'; + +describe('psuEmailValidator', () => { + function getControl(value: string): FormControl { + const c = new FormControl(); + c.patchValue(value); + return c; + } + + it('empty string should be valid', () => { + expect(office365EmailValidator(getControl(''))).toBeNull(); + }); + + it('non-pennstateoffice365.onmicrosoft.com email should be valid', () => { + expect(office365EmailValidator(getControl('irock@gmail.com'))).toBeNull(); + }); + + it('@pennstateoffice365.onmicrosoft.com email should be invalid', () => { + expect(office365EmailValidator(getControl('me@pennstateoffice365.onmicrosoft.com'))).toEqual({ + psuEmail: true + }); + }); + + it('@pennstateoffice365.onmicrosoft.com email with uppercase should be invalid', () => { + expect(office365EmailValidator(getControl('me@pennstateOFFice365.onmicROsoft.com'))).toEqual({ + psuEmail: true + }); + }); +}); diff --git a/libs/utils/form/src/lib/validators/office365-email.validator.ts b/libs/utils/form/src/lib/validators/office365-email.validator.ts new file mode 100644 index 0000000000000000000000000000000000000000..f350a46ad46bdedfa143c74a9722eec25666ef9b --- /dev/null +++ b/libs/utils/form/src/lib/validators/office365-email.validator.ts @@ -0,0 +1,13 @@ +import { ValidationErrors, AbstractControl } from '@angular/forms'; + +/* + Validates that the supplied email is not an office 365 extension. +*/ + +export function office365EmailValidator(email: AbstractControl): ValidationErrors { + return /^.+@pennstateoffice365.onmicrosoft.com$/i.test(email.value) + ? { + psuEmail: true + } + : null; +} diff --git a/libs/utils/form/src/lib/validators/psu-email.validator.spec.ts b/libs/utils/form/src/lib/validators/psu-email.validator.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..225c9edf5de0525956aedc460633d5b47385d078 --- /dev/null +++ b/libs/utils/form/src/lib/validators/psu-email.validator.spec.ts @@ -0,0 +1,112 @@ +import { FormControl } from '@angular/forms'; +import { psuEmailValidator, psuStrictEmailValidator } from './psu-email.validator'; + +function getControl(value: string): FormControl { + const c = new FormControl(); + c.patchValue(value); + return c; +} + +describe('psuEmailValidator', () => { + it('empty string should be valid', () => { + expect(psuEmailValidator(getControl(''))).toBeNull(); + }); + + it('non-psu email should be valid', () => { + expect(psuEmailValidator(getControl('irock@gmail.com'))).toBeNull(); + }); + + it('@psu.edu email should be invalid', () => { + expect(psuEmailValidator(getControl('me@psu.edu'))).toEqual({ + psuEmail: true + }); + }); + + it('@psu.edu email with uppercase should be invalid', () => { + expect(psuEmailValidator(getControl('me@PSU.EDU'))).toEqual({ + psuEmail: true + }); + }); + + it('@engr.psu.edu email should be invalid', () => { + expect(psuEmailValidator(getControl('me@engr.psu.edu'))).toEqual({ + psuEmail: true + }); + }); + + it('@engr.psu.edu email with uppercase should be invalid', () => { + expect(psuEmailValidator(getControl('me@ENGR.pSu.eDu'))).toEqual({ + psuEmail: true + }); + }); + + it('@garbage.psu.edu.something.else.domain email should be valid', () => { + expect(psuEmailValidator(getControl('me@garbage.psu.edu.something.else.domain'))).toBeNull(); + }); + + it('@garbage.psu.edu.something.else.domain email when upper should be valid', () => { + expect(psuEmailValidator(getControl('me@garbage.PSU.edu.something.ELSE.domain'))).toBeNull(); + }); + + it('@alumni.psu.edu email should be valid', () => { + expect(psuEmailValidator(getControl('me@alumni.psu.edu'))).toBeNull(); + }); + + it('@alumni.psu.edu email when uppercase should be valid', () => { + expect(psuEmailValidator(getControl('ME@ALUMNI.PSU.EDU'))).toBeNull(); + }); + + it('@arl.psu.edu email should be valid', () => { + expect(psuEmailValidator(getControl('me@arl.psu.edu'))).toBeNull(); + }); + + it('@arl.psu.edu email when uppercase should be valid', () => { + expect(psuEmailValidator(getControl('me@ARL.PSU.EDU'))).toBeNull(); + }); + + it('@pennstatehealth.psu.edu email should be valid', () => { + expect(psuEmailValidator(getControl('me@pennstatehealth.psu.edu'))).toBeNull(); + }); + + it('@pennstatehealth.psu.edu email when uppercase should be valid', () => { + expect(psuEmailValidator(getControl('me@pennstaTehealth.psU.edu'))).toBeNull(); + }); + + it('@phs.psu.edu email should be valid', () => { + expect(psuEmailValidator(getControl('me@phs.psu.edu'))).toBeNull(); + }); + + it('@phs.psu.edu email when uppercase should be valid', () => { + expect(psuEmailValidator(getControl('me@Phs.Psu.edu'))).toBeNull(); + }); +}); + +describe('psuStrictEmailValidator', () => { + it('empty string should be valid', () => { + expect(psuStrictEmailValidator(getControl(''))).toBeNull(); + }); + + it('non-psu email should be valid', () => { + expect(psuStrictEmailValidator(getControl('irock@gmail.com'))).toBeNull(); + }); + + it('@psu.edu email should be invalid', () => { + expect(psuStrictEmailValidator(getControl('me@psu.edu'))).toEqual({ + psuEmail: true + }); + }); + + it('@psu.edu email when uppercase should be invalid', () => { + expect(psuStrictEmailValidator(getControl('ME@PSU.EDU'))).toEqual({ + psuEmail: true + }); + }); + + it('@engr.psu.edu email should be valid', () => { + expect(psuStrictEmailValidator(getControl('me@engr.psu.edu'))).toBeNull(); + }); + + it('@engr.psu.edu email when uppercase should be valid', () => { + expect(psuStrictEmailValidator(getControl('me@engr.PSU.edu'))).toBeNull(); + }); +}); diff --git a/libs/utils/form/src/lib/validators/psu-email.validator.ts b/libs/utils/form/src/lib/validators/psu-email.validator.ts new file mode 100644 index 0000000000000000000000000000000000000000..5f404d5f476b223e141e0c2a568d2d664d9a299e --- /dev/null +++ b/libs/utils/form/src/lib/validators/psu-email.validator.ts @@ -0,0 +1,25 @@ +import { ValidationErrors, AbstractControl } from '@angular/forms'; + +/* + Two regex patterns in the email validator below (ands are ?=) + - check from the beginning of the email until after the @ (and some amount of characters) to see if it ends in .psu.edu + - and from that subgroup (?:) ignore (?!) emails that end in arl.psu.edu, alumni.psu.edu, etc. + - return true if it matches (meaning the email shouldn't be allowed) + - return null if it does not match (meaning the email should be allowed). +*/ + +export function psuEmailValidator(email: AbstractControl): ValidationErrors { + return _validatePsuEmail(email, /(?=(^.+@.*psu.edu$))(?=^(?:(?!(arl|pennstatehealth|alumni|phs)(.psu.edu)).)*$).*$/i); +} + +export function psuStrictEmailValidator(email: AbstractControl): ValidationErrors { + return _validatePsuEmail(email, /^.+@psu.edu$/i); +} + +function _validatePsuEmail(email: AbstractControl, exp: RegExp): ValidationErrors { + return exp.test(email.value) + ? { + psuEmail: true + } + : null; +} diff --git a/libs/utils/form/src/lib/validators/psu.validators.spec.ts b/libs/utils/form/src/lib/validators/psu.validators.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..52a7ad55d7f985c8666186cc6bab3a3100542c1a --- /dev/null +++ b/libs/utils/form/src/lib/validators/psu.validators.spec.ts @@ -0,0 +1,67 @@ +import { PsuValidators } from './psu.validators'; +import { FormControl } from '@angular/forms'; + +describe('PsuValidators', () => { + function getControl(value: any): FormControl { + const c = new FormControl(); + c.patchValue(value); + return c; + } + + it('required', () => { + expect(PsuValidators.required(getControl(''))).toEqual({ required: true }); + }); + + it('minLength', () => { + expect(PsuValidators.minLength(3)(getControl('a'))).toEqual({ + minLength: { + requiredLength: 3, + actualLength: 1 + } + }); + }); + + it('maxLength', () => { + expect(PsuValidators.maxLength(3)(getControl('abcd'))).toEqual({ + maxLength: { + requiredLength: 3, + actualLength: 4 + } + }); + }); + + it('email', () => { + expect(PsuValidators.email(getControl('test'))).toEqual({ + validateEmail: { valid: false }, + email: true + }); + }); + + it('psuEmail', () => { + expect(PsuValidators.psuEmail(getControl('me@psu.edu'))).toEqual({ + psuEmail: true + }); + }); + + it('psuEmailStrict', () => { + expect(PsuValidators.psuEmailStrict(getControl('me@psu.edu'))).toEqual({ + psuEmail: true + }); + }); + + it('office365Email', () => { + expect(PsuValidators.office365Email(getControl('me@pennstateoffice365.onmicrosoft.com'))).toEqual({ + psuEmail: true + }); + }); + + it('ssn', () => { + expect(PsuValidators.ssn(getControl('me'))).toEqual({ ssnCheck: true }); + }); + + it('not an email', () => { + expect(PsuValidators.notAnEmail(getControl('me@psu.edu'))).toEqual({ + notAnEmail: true + }); + }); +}); diff --git a/libs/utils/form/src/lib/validators/psu.validators.ts b/libs/utils/form/src/lib/validators/psu.validators.ts new file mode 100644 index 0000000000000000000000000000000000000000..b8d995551d15aca85b03e30eaa02b24c43eb1581 --- /dev/null +++ b/libs/utils/form/src/lib/validators/psu.validators.ts @@ -0,0 +1,56 @@ +import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; +import { emailValidator } from './email.validator'; +import { maxLengthValidator, minLengthValidator } from './length.validators'; +import { nonPsuEmailValidator } from './non-psu-email.validator'; +import { notAnEmailValidator } from './not-email.validator'; +import { office365EmailValidator } from './office365-email.validator'; +import { psuEmailValidator, psuStrictEmailValidator } from './psu-email.validator'; +import { requiredValidator } from './required.validator'; +import { ssnPatternValidator } from './ssn-pattern.validator'; +import { ssnValidator } from './ssn.validator'; + +export class PsuValidators { + public static required(control: AbstractControl): ValidationErrors | null { + return requiredValidator(control); + } + + public static minLength(minLength: number): ValidatorFn { + return minLengthValidator(minLength); + } + + public static maxLength(maxLength: number): ValidatorFn { + return maxLengthValidator(maxLength); + } + + public static ssn(control: AbstractControl): ValidationErrors | null { + return ssnValidator(control); + } + + public static ssnPattern(control: AbstractControl): ValidationErrors | null { + return ssnPatternValidator(control); + } + + public static office365Email(control: AbstractControl): ValidationErrors | null { + return office365EmailValidator(control); + } + + public static email(control: AbstractControl): ValidationErrors | null { + return emailValidator(control); + } + + public static psuEmail(control: AbstractControl): ValidationErrors | null { + return psuEmailValidator(control); + } + + public static psuEmailStrict(control: AbstractControl): ValidationErrors | null { + return psuStrictEmailValidator(control); + } + + public static notAnEmail(control: AbstractControl): ValidationErrors | null { + return notAnEmailValidator(control); + } + + public static nonPsuEmail(control: AbstractControl): ValidationErrors | null { + return nonPsuEmailValidator(control); + } +} diff --git a/libs/utils/form/src/lib/validators/required.validator.spec.ts b/libs/utils/form/src/lib/validators/required.validator.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..5556e91a42dbd3772383376adc8c21260f0fd1a4 --- /dev/null +++ b/libs/utils/form/src/lib/validators/required.validator.spec.ts @@ -0,0 +1,58 @@ +import { FormControl } from '@angular/forms'; +import { requiredValidator } from './required.validator'; + +describe('required validator', () => { + function getControl(value: any): FormControl { + const c = new FormControl(); + c.patchValue(value); + return c; + } + + it('undefined string should be invalid', () => { + expect(requiredValidator(getControl(undefined))).toEqual({ + required: true + }); + }); + + it('undefined string should be invalid', () => { + expect(requiredValidator(getControl(null))).toEqual({ + required: true + }); + }); + + it('empty string should be invalid', () => { + expect(requiredValidator(getControl(''))).toEqual({ + required: true + }); + }); + + it('string with only spaces should be invalid', () => { + expect(requiredValidator(getControl(' '))).toEqual({ + required: true + }); + }); + + it('string with a single character should be valid', () => { + expect(requiredValidator(getControl('a'))).toBeNull(); + }); + + it('string with a single character plus whitespace should be valid', () => { + expect(requiredValidator(getControl(' a '))).toBeNull(); + }); + + it('string with many characters should be valid', () => { + expect(requiredValidator(getControl(' a test of a valid value '))).toBeNull(); + }); + + it('0 should be valid', () => { + expect(requiredValidator(getControl(0))).toBeNull(); + }); + + it('empty array should be invalid', () => { + expect(requiredValidator(getControl([]))).toEqual({ required: true }); + }); + + it('array with item should be valid', () => { + expect(requiredValidator(getControl(['a']))).toBeNull(); + }); +}); diff --git a/libs/utils/form/src/lib/validators/required.validator.ts b/libs/utils/form/src/lib/validators/required.validator.ts new file mode 100644 index 0000000000000000000000000000000000000000..0927587cba5f44323de859a5d812f7e7f5c3033f --- /dev/null +++ b/libs/utils/form/src/lib/validators/required.validator.ts @@ -0,0 +1,9 @@ +import { ValidationErrors, AbstractControl } from '@angular/forms'; + +export function requiredValidator(control: AbstractControl): ValidationErrors | null { + let val = control.value; + if (val && typeof val === 'string') { + val = val.trim(); + } + return val === null || val === undefined || val.length === 0 ? { required: true } : null; +} diff --git a/libs/utils/form/src/lib/validators/ssn-pattern.validator.spec.ts b/libs/utils/form/src/lib/validators/ssn-pattern.validator.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..bcf63ef29baf0246b9902bbb3621c4e99570439c --- /dev/null +++ b/libs/utils/form/src/lib/validators/ssn-pattern.validator.spec.ts @@ -0,0 +1,30 @@ +import { FormControl } from '@angular/forms'; +import { ssnPatternValidator } from './ssn-pattern.validator'; + +describe('ssnPatternValidator', () => { + function getControl(value: string): FormControl { + const c = new FormControl(); + c.patchValue(value); + return c; + } + + it('undefined string should be valid', () => { + expect(ssnPatternValidator(getControl(undefined))).toBeNull(); + }); + + it('empty string should be valid', () => { + expect(ssnPatternValidator(getControl(''))).toBeNull(); + }); + + it('valid ssn pattern should return validation error', () => { + expect(ssnPatternValidator(getControl('555-55-5555'))).toEqual({ + ssnPattern: true + }); + }); + + it('valid ssn pattern of last 4 only should return validation error', () => { + expect(ssnPatternValidator(getControl('xxx-xx-5555'))).toEqual({ + ssnPattern: true + }); + }); +}); diff --git a/libs/utils/form/src/lib/validators/ssn-pattern.validator.ts b/libs/utils/form/src/lib/validators/ssn-pattern.validator.ts new file mode 100644 index 0000000000000000000000000000000000000000..3cdf4a85d6b0d6ec85f8947506efb52317df012d --- /dev/null +++ b/libs/utils/form/src/lib/validators/ssn-pattern.validator.ts @@ -0,0 +1,11 @@ +import { AbstractControl, ValidationErrors } from '@angular/forms'; + +export function ssnPatternValidator(freeformTextField: AbstractControl): ValidationErrors { + const hasValue = !!freeformTextField.value; + const matchesSsnPattern = /(\d{3}-?\d{2}-?\d{4})|(.{3}-?.{2}-?\d{4})/.test(freeformTextField.value); + return hasValue && matchesSsnPattern + ? { + ssnPattern: true + } + : null; +} diff --git a/libs/utils/form/src/lib/validators/ssn.validator.spec.ts b/libs/utils/form/src/lib/validators/ssn.validator.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..43b242e74b7f77b40f0209b5866b0bcdc052e3da --- /dev/null +++ b/libs/utils/form/src/lib/validators/ssn.validator.spec.ts @@ -0,0 +1,58 @@ +import { FormControl } from '@angular/forms'; +import { ssnValidator } from './ssn.validator'; + +describe('ssnValidator', () => { + function getControl(value: string): FormControl { + const c = new FormControl(); + c.patchValue(value); + return c; + } + + it('undefined string should be valid', () => { + expect(ssnValidator(getControl(undefined))).toBeNull(); + }); + + it('empty string should be valid', () => { + expect(ssnValidator(getControl(''))).toBeNull(); + }); + + it('valid ssn', () => { + expect(ssnValidator(getControl('555-55-5555'))).toBeNull(); + }); + + it('ssn must be formatted with hyphens', () => { + expect(ssnValidator(getControl('555555555'))).toEqual({ + ssnCheck: true + }); + }); + + it('ssn must not start with 666', () => { + expect(ssnValidator(getControl('666-55-5555'))).toEqual({ + ssnCheck: true + }); + }); + + it('ssn must not start with 9', () => { + expect(ssnValidator(getControl('900-55-5555'))).toEqual({ + ssnCheck: true + }); + }); + + it('ssn must not start with 000', () => { + expect(ssnValidator(getControl('000-55-5555'))).toEqual({ + ssnCheck: true + }); + }); + + it('ssn group cannot be 00', () => { + expect(ssnValidator(getControl('555-00-5555'))).toEqual({ + ssnCheck: true + }); + }); + + it('ssn personal cannot be 0000', () => { + expect(ssnValidator(getControl('555-55-0000'))).toEqual({ + ssnCheck: true + }); + }); +}); diff --git a/libs/utils/form/src/lib/validators/ssn.validator.ts b/libs/utils/form/src/lib/validators/ssn.validator.ts new file mode 100644 index 0000000000000000000000000000000000000000..7e645e017de458e1028b7ea3d923f2523d18a426 --- /dev/null +++ b/libs/utils/form/src/lib/validators/ssn.validator.ts @@ -0,0 +1,16 @@ +import { ValidationErrors, AbstractControl } from '@angular/forms'; + +/* + SSN cannot have any group of all 0's + SSN cannot start with 666 or 900 - 999 + SSN is not required +*/ +export function ssnValidator(ssn: AbstractControl): ValidationErrors { + const noEntry = !!!ssn.value; + const matchesValidSsn = /^(?!(000|666|9))\d{3}-(?!00)\d{2}-(?!0000)\d{4}$/.test(ssn.value); + return !matchesValidSsn && !noEntry + ? { + ssnCheck: true + } + : null; +} diff --git a/libs/utils/form/src/test-setup.ts b/libs/utils/form/src/test-setup.ts new file mode 100644 index 0000000000000000000000000000000000000000..ea6fbefa5107bf0742b20f740fe208d15657248b --- /dev/null +++ b/libs/utils/form/src/test-setup.ts @@ -0,0 +1,2 @@ +import 'jest-preset-angular'; +import '../../../../jestGlobalMocks'; diff --git a/libs/utils/form/tsconfig.json b/libs/utils/form/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..08c7db8c96729fc7e5d208344835a678c973f96c --- /dev/null +++ b/libs/utils/form/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "types": ["node", "jest"] + }, + "include": ["**/*.ts"] +} diff --git a/libs/utils/form/tsconfig.lib.json b/libs/utils/form/tsconfig.lib.json new file mode 100644 index 0000000000000000000000000000000000000000..1c600457d394acbbc693cc87f480a170a4b277cd --- /dev/null +++ b/libs/utils/form/tsconfig.lib.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "target": "es2015", + "declaration": true, + "inlineSources": true, + "types": [], + "lib": ["dom", "es2018"] + }, + "angularCompilerOptions": { + "annotateForClosureCompiler": true, + "skipTemplateCodegen": true, + "strictMetadataEmit": true, + "fullTemplateTypeCheck": true, + "strictInjectionParameters": true, + "enableResourceInlining": true + }, + "exclude": ["src/test-setup.ts", "**/*.spec.ts"] +} diff --git a/libs/utils/form/tsconfig.spec.json b/libs/utils/form/tsconfig.spec.json new file mode 100644 index 0000000000000000000000000000000000000000..fd405a65ef42fc2a9dece7054ce3338c0195210b --- /dev/null +++ b/libs/utils/form/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": ["**/*.spec.ts", "**/*.d.ts"] +} diff --git a/libs/utils/form/tslint.json b/libs/utils/form/tslint.json new file mode 100644 index 0000000000000000000000000000000000000000..5c1c5f0ecc3b54c086b81dbe1161948f212a4b49 --- /dev/null +++ b/libs/utils/form/tslint.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tslint.json", + "rules": { + "directive-selector": [true, "attribute", "ut", "camelCase"], + "component-selector": [true, "element", "ut", "kebab-case"] + } +} diff --git a/nx.json b/nx.json index 5da35188642b4b2c478d04548b36e289022571fd..21c89845f907de45bddf5fcda9e3b845683d0b9c 100644 --- a/nx.json +++ b/nx.json @@ -49,6 +49,9 @@ }, "utils-cdn": { "tags": [] + }, + "utils-form": { + "tags": [] } } } diff --git a/tsconfig.json b/tsconfig.json index 4be4cb82053f3ae71a44754630e7550d9892ce3b..6d485d6dec8c61fb81863590278b95e230b872bd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,7 +27,8 @@ "@psu/utils/logger": ["libs/utils/logger/src/index.ts"], "@psu/utils/loading-events": ["libs/utils/loading-events/src/index.ts"], "@psu/utils/ngrx": ["libs/utils/ngrx/src/index.ts"], - "@psu/utils/cdn": ["libs/utils/cdn/src/index.ts"] + "@psu/utils/cdn": ["libs/utils/cdn/src/index.ts"], + "@psu/utils/form": ["libs/utils/form/src/index.ts"] } }, "exclude": ["node_modules", "tmp"]