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"]