diff --git a/angular.json b/angular.json index f98025372c6f9aa49facb85b18a30435fd44cf46..d86e40d3dd4757152f8f514e5e16820d815ef55a 100644 --- a/angular.json +++ b/angular.json @@ -262,7 +262,7 @@ "projectType": "library", "root": "libs/utils/theming", "sourceRoot": "libs/utils/theming/src", - "prefix": "psu", + "prefix": "ut", "architect": { "lint": { "builder": "@angular-devkit/build-angular:tslint", @@ -392,6 +392,90 @@ "styleext": "scss" } } + }, + "utils-cdn": { + "projectType": "library", + "root": "libs/utils/cdn", + "sourceRoot": "libs/utils/cdn/src", + "prefix": "ut", + "architect": { + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": ["libs/utils/cdn/tsconfig.lib.json", "libs/utils/cdn/tsconfig.spec.json"], + "exclude": ["**/node_modules/**", "!libs/utils/cdn/**"] + } + }, + "test": { + "builder": "@nrwl/jest:jest", + "options": { + "jestConfig": "libs/utils/cdn/jest.config.js", + "tsConfig": "libs/utils/cdn/tsconfig.spec.json", + "setupFile": "libs/utils/cdn/src/test-setup.ts" + } + } + }, + "schematics": { + "@nrwl/angular:component": { + "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" + } + } + }, + "utils-analytics": { + "projectType": "library", + "root": "libs/utils/analytics", + "sourceRoot": "libs/utils/analytics/src", + "prefix": "ut", + "architect": { + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": ["libs/utils/analytics/tsconfig.lib.json", "libs/utils/analytics/tsconfig.spec.json"], + "exclude": ["**/node_modules/**", "!libs/utils/analytics/**"] + } + }, + "test": { + "builder": "@nrwl/jest:jest", + "options": { + "jestConfig": "libs/utils/analytics/jest.config.js", + "tsConfig": "libs/utils/analytics/tsconfig.spec.json", + "setupFile": "libs/utils/analytics/src/test-setup.ts" + } + } + }, + "schematics": { + "@nrwl/angular:component": { + "styleext": "scss" + } + } } }, "cli": { diff --git a/libs/utils/analytics/README.md b/libs/utils/analytics/README.md new file mode 100644 index 0000000000000000000000000000000000000000..2193b54f65b471eb43c282d73ddbd783d48933ec --- /dev/null +++ b/libs/utils/analytics/README.md @@ -0,0 +1,20 @@ +# @psu/utils/analytics + +Provides integration with Google Analytics + +## Setup + +1. Setup Google Tag Manager and generate a container ID for your app (usually non-prod + prod) +1. Provide `ANALYTICS_CONFIG` injection token and initialize it in your app initializer +1. Inject `GoogleTagManagerService` into your `AppComponent` + +## ErrorInterceptor + +There is an optional `ErrorInterceptor` HTTP interceptor that can log any type of network errors to Google Analytics. +There is a custom report/dashboard that you can then view to see a summary of errors. +This has been helpful to debug specific customer problems - CORS errors, anti-virus software +that disabled PUT requests, etc. + +## Custom Events + +`AnalyticsService` can record page views and custom analytics events. diff --git a/libs/utils/analytics/jest.config.js b/libs/utils/analytics/jest.config.js new file mode 100644 index 0000000000000000000000000000000000000000..8db9150d4d9d62f3022325fe2ac5647a3bf07d63 --- /dev/null +++ b/libs/utils/analytics/jest.config.js @@ -0,0 +1,9 @@ +module.exports = { + name: 'utils-analytics', + preset: '../../../jest.config.js', + coverageDirectory: '../../../coverage/libs/utils/analytics', + snapshotSerializers: [ + 'jest-preset-angular/AngularSnapshotSerializer.js', + 'jest-preset-angular/HTMLCommentSerializer.js' + ] +}; diff --git a/libs/utils/analytics/ng-package.json b/libs/utils/analytics/ng-package.json new file mode 100644 index 0000000000000000000000000000000000000000..c781f0df4675ef304c7d745c98df9f59daa25dee --- /dev/null +++ b/libs/utils/analytics/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/utils/analytics/src/index.ts b/libs/utils/analytics/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..18c1e7b02669581d9bc9c37a6c1b501cac11bfa6 --- /dev/null +++ b/libs/utils/analytics/src/index.ts @@ -0,0 +1,5 @@ +export * from './lib/analytics.model'; +export { AnalyticsService } from './lib/analytics.service'; +export { GoogleAnalyticsService } from './lib/google-analytics.service'; +export * from './lib/google-tag-manager/google-tag-manager.service'; +export { ErrorStatusInterceptor } from './lib/interceptor/error.interceptor'; diff --git a/libs/utils/analytics/src/lib/analytics.model.ts b/libs/utils/analytics/src/lib/analytics.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..48f48b4035dc3b65ff964a56d51cf84faf515acd --- /dev/null +++ b/libs/utils/analytics/src/lib/analytics.model.ts @@ -0,0 +1,13 @@ +import { InjectionToken } from '@angular/core'; + +export interface AnalyticsSource { + pageView(url: string): void; + recordEvent(category: string, label: string, action: string, value: any): void; +} + +export interface AnalyticsConfig { + trackingId?: string; + tagManagerContainerId?: string; +} + +export const ANALYTICS_CONFIG = new InjectionToken('psu.utils.analytics.config'); diff --git a/libs/utils/analytics/src/lib/analytics.service.ts b/libs/utils/analytics/src/lib/analytics.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..e188e7cc0966a898e952bf9e3e21e3222bd8c8ae --- /dev/null +++ b/libs/utils/analytics/src/lib/analytics.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core'; +import { NavigationEnd, Router } from '@angular/router'; +import { filter, tap } from 'rxjs/operators'; +import { GoogleAnalyticsService } from './google-analytics.service'; + +@Injectable({ providedIn: 'root' }) +export class AnalyticsService { + constructor(private router: Router, private google: GoogleAnalyticsService) { + this.router.events + .pipe( + filter(event => event instanceof NavigationEnd), + tap(event => this.google.pageView((event as NavigationEnd).urlAfterRedirects)) + ) + .subscribe(event => {}); + } + + public recordEvent(action: string, category?: string, label?: string): void { + this.google.recordEvent(action, category, label); + } + + public recordError(exceptionMsg: string): void { + this.google.recordException(exceptionMsg); + } +} diff --git a/libs/utils/analytics/src/lib/google-analytics.service.ts b/libs/utils/analytics/src/lib/google-analytics.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..946c1455060ab817057f2f865f80078b8d3155e7 --- /dev/null +++ b/libs/utils/analytics/src/lib/google-analytics.service.ts @@ -0,0 +1,85 @@ +import { isPlatformBrowser } from '@angular/common'; +import { Inject, Injectable, PLATFORM_ID } from '@angular/core'; +import { AnalyticsConfig, AnalyticsSource, ANALYTICS_CONFIG } from './analytics.model'; + +declare global { + interface Window { + dataLayer: unknown[]; + gtag: Function; + } +} + +@Injectable({ providedIn: 'root' }) +export class GoogleAnalyticsService implements AnalyticsSource { + private gtag: Function; + private scriptLoaded = false; + + constructor( + @Inject(PLATFORM_ID) private platform: Object, + @Inject(ANALYTICS_CONFIG) private config: AnalyticsConfig + ) { + this.gtag = this.loadGoogleAnalytics(); + this.gtag('config', this.config.trackingId, { + send_page_view: false + }); + } + + private getReady(): void { + if (!this.scriptLoaded) { + this.scriptLoaded = true; + + if (typeof window !== 'undefined') { + window.dataLayer = window.dataLayer || []; + window.gtag = function(): void { + window.dataLayer.push(arguments); + }; + window.gtag('js', new Date()); + } + + const doc = document.body; + const script = document.createElement('script'); + script.innerHTML = ''; + script.src = `https://www.googletagmanager.com/gtag/js?id=${this.config.trackingId}`; + script.async = true; + script.defer = true; + doc.appendChild(script); + } + } + + public pageView(url: string): void { + this.gtag('config', this.config.trackingId, { + page_path: url + }); + } + + public recordEvent(action: string, category?: string, label?: string): void { + this.gtag('event', action, { + event_category: category, + event_label: label + }); + } + + public recordException(exceptionMsg: string): void { + this.gtag('event', 'exception', { + method: 'Angular', + event_category: 'exception', + event_label: 'Network Error in From User', + description: exceptionMsg + }); + } + + private loadGoogleAnalytics(): Function { + let ret: Function; + if (isPlatformBrowser(this.platform)) { + this.getReady(); + ret = (window).gtag; + if (!ret) { + console.warn('Unable to find Google Analytics function (window.gtag). Returning empty function.'); + ret = () => {}; + } + } else { + ret = () => {}; + } + return ret; + } +} diff --git a/libs/utils/analytics/src/lib/google-tag-manager/google-tag-manager.service.ts b/libs/utils/analytics/src/lib/google-tag-manager/google-tag-manager.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..c5d873134342c95c29efdda8ee74f11702b3e256 --- /dev/null +++ b/libs/utils/analytics/src/lib/google-tag-manager/google-tag-manager.service.ts @@ -0,0 +1,51 @@ +import { isPlatformBrowser } from '@angular/common'; +import { Inject, Injectable, PLATFORM_ID } from '@angular/core'; +import { Logger } from '@psu/utils/logger'; +import { AnalyticsConfig, ANALYTICS_CONFIG } from '../analytics.model'; + +@Injectable({ providedIn: 'root' }) +export class GoogleTagManagerService { + private scriptLoaded = false; + + constructor( + @Inject(PLATFORM_ID) private platform: Object, + @Inject(ANALYTICS_CONFIG) private config: AnalyticsConfig, + private logger: Logger + ) { + this.loadGoogleTagManager(); + } + + private loadGoogleTagManager(): void { + if (isPlatformBrowser(this.platform)) { + if (!this.scriptLoaded) { + if (this.config.tagManagerContainerId) { + this.scriptLoaded = true; + + try { + const head = document.head; + const script = document.createElement('script'); + script.innerHTML = `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': + new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0], + j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= + 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f); + })(window,document,'script','dataLayer','${this.config.tagManagerContainerId}');`; + head.appendChild(script); + + const doc = document.body; + const noscript = document.createElement('noscript'); + noscript.innerHTML = ``; + doc.appendChild(script); + } catch (err) { + this.scriptLoaded = false; + this.logger.error(`Error loading GoogleTagManager scripts: ${err}`); + } + } else { + this.logger.error( + `You have injected the GoogleTagManagerService but it did not find tagManagerContainerId in + the ANALYTICS_CONFIG InjectionToken. Possible race condition?` + ); + } + } + } + } +} diff --git a/libs/utils/analytics/src/lib/interceptor/error.interceptor.spec.ts b/libs/utils/analytics/src/lib/interceptor/error.interceptor.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..a72350b4e0fc21464a7e96f67e7b9efc1801b8a7 --- /dev/null +++ b/libs/utils/analytics/src/lib/interceptor/error.interceptor.spec.ts @@ -0,0 +1,123 @@ +import { HttpClient, HttpHeaders, HTTP_INTERCEPTORS } from '@angular/common/http'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { NavigationEnd, Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { Mock } from 'ts-mocks'; +import { AnalyticsConfig, ANALYTICS_CONFIG } from '../..'; +import { AnalyticsService } from '../analytics.service'; +import { ErrorStatusInterceptor } from './error.interceptor'; + +describe('ErrorStatusInterceptor', () => { + let gtag: Mock; + let mockRouter: Mock; + const config: AnalyticsConfig = {}; + const end: NavigationEnd = { + urlAfterRedirects: '', + id: 1, + url: '' + }; + let backend: HttpTestingController; + let http: HttpClient; + + beforeEach(() => { + gtag = new Mock({ + recordError: () => {} + }); + mockRouter = new Mock({ + events: of(end) + }); + + TestBed.configureTestingModule({ + imports: [RouterTestingModule, HttpClientTestingModule], + providers: [ + { provide: Router, useValue: mockRouter.Object }, + { provide: AnalyticsService, useValue: gtag.Object }, + { provide: ANALYTICS_CONFIG, useFactory: () => config }, + { + provide: HTTP_INTERCEPTORS, + useClass: ErrorStatusInterceptor, + multi: true + } + ] + }); + + backend = TestBed.get(HttpTestingController); + http = TestBed.get(HttpClient); + }); + + afterEach(() => { + backend.verify(); + }); + + it('should create', () => { + expect(TestBed.get(HTTP_INTERCEPTORS)[0]).toBeDefined(); + }); + + describe('intercepting http calls', () => { + function makeRequest(method = 'GET', url = '/data', headers?: HttpHeaders) { + http + .request(method, url, { headers }) + .pipe(catchError(err => of(null))) + .subscribe(); + } + + describe('when request has no headers', () => { + beforeEach(() => { + makeRequest(); + }); + describe('when http response is error', () => { + beforeEach(() => { + backend.expectOne('/data').flush({ errorMessage: 'Uh oh!' }, { status: 500, statusText: 'Server Error' }); + }); + + it('should call recordError with no request ID', () => { + expect(gtag.Object.recordError).toHaveBeenCalledWith(jasmine.stringMatching(/^((?!xRequestId).)*$/)); + expect(gtag.Object.recordError).toHaveBeenCalledWith(jasmine.stringMatching(/^HTTP 500: Server Error/)); + expect(gtag.Object.recordError).toHaveBeenCalledWith(jasmine.stringMatching(/requestMethod: GET/)); + expect(gtag.Object.recordError).toHaveBeenCalledWith(jasmine.stringMatching(/fullRequestUrl: \/data/)); + expect(gtag.Object.recordError).toHaveBeenCalledWith(jasmine.stringMatching(/Uh oh!/)); + }); + }); + + describe('when http error response has request ID header', () => { + beforeEach(() => { + backend + .expectOne('/data') + .flush({}, { status: 500, statusText: 'No', headers: { 'X-Request-Id': 'server-request' } }); + }); + + it('should record event with request id from server', () => { + expect(gtag.Object.recordError).toHaveBeenCalledWith(jasmine.stringMatching(/xRequestId: server-request/)); + }); + }); + }); + + describe('when request has requestid header', () => { + beforeEach(() => { + makeRequest('PUT', '/other/data?id=1', new HttpHeaders().append('X-Request-Id', 'client-request')); + }); + + it('should record event with request id from client', () => { + backend + .expectOne('/other/data?id=1') + .flush({ errorMessage: 'Uh oh!' }, { status: 404, statusText: 'Not Found' }); + expect(gtag.Object.recordError).toHaveBeenCalledWith(jasmine.stringMatching(/xRequestId: client-request/)); + expect(gtag.Object.recordError).toHaveBeenCalledWith(jasmine.stringMatching(/requestMethod: PUT/)); + expect(gtag.Object.recordError).toHaveBeenCalledWith( + jasmine.stringMatching(/fullRequestUrl: \/other\/data\?id=1/) + ); + }); + + it('should log client errors differently', () => { + backend.expectOne('/other/data?id=1').error(new ErrorEvent('CORS error', { message: 'not allowed' })); + expect(gtag.Object.recordError).toHaveBeenCalledWith(jasmine.stringMatching(/xRequestId: client-request/)); + expect(gtag.Object.recordError).toHaveBeenCalledWith( + jasmine.stringMatching(/Client\/Network Error: CORS error -> not allowed/) + ); + }); + }); + }); +}); diff --git a/libs/utils/analytics/src/lib/interceptor/error.interceptor.ts b/libs/utils/analytics/src/lib/interceptor/error.interceptor.ts new file mode 100644 index 0000000000000000000000000000000000000000..6e4a97f59fb9c4d5d250f96515f24ffbc7e24730 --- /dev/null +++ b/libs/utils/analytics/src/lib/interceptor/error.interceptor.ts @@ -0,0 +1,49 @@ +import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable, throwError } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { AnalyticsService } from '../analytics.service'; + +const X_REQUEST_ID = 'x-request-id'; + +@Injectable({ providedIn: 'root' }) +export class ErrorStatusInterceptor implements HttpInterceptor { + constructor(private aService: AnalyticsService) {} + + public intercept(request: HttpRequest, next: HttpHandler): Observable> { + return next.handle(request).pipe( + catchError((error: HttpErrorResponse) => { + let errorMsg = 'not provided'; + + // capture the request ID either from the error response or the original request + let xRequestId = ''; + if (error.headers.has(X_REQUEST_ID)) { + xRequestId = error.headers.get(X_REQUEST_ID); + } else if (request.headers.has(X_REQUEST_ID)) { + xRequestId = request.headers.get(X_REQUEST_ID); + } + + if (error.error && error.error.errorMessage) { + errorMsg = error.error.errorMessage; + } + + // there are 2 possible types of errors: client side and server side HTTP errors + // CORS errors fall into client side errors, they will have ErrorCode 0 in chrome and you will not see the real response code. + if (error.error instanceof ErrorEvent) { + // client error + errorMsg = `Client/Network Error: ${error.error.type} -> ${error.error.message}`; + } else { + // server error, pull out HTTP response status and message + errorMsg = `HTTP ${error.status}: ${error.statusText}, message: [${errorMsg}]`; + } + + this.aService.recordError( + `${errorMsg} time: [${new Date().toISOString()}] from [${error.url}] requestMethod: ${ + request.method + } fullRequestUrl: ${request.urlWithParams} ${!!xRequestId ? `xRequestId: ${xRequestId}` : ''}` + ); + return throwError(error); + }) + ); + } +} diff --git a/libs/utils/analytics/src/test-setup.ts b/libs/utils/analytics/src/test-setup.ts new file mode 100644 index 0000000000000000000000000000000000000000..ea6fbefa5107bf0742b20f740fe208d15657248b --- /dev/null +++ b/libs/utils/analytics/src/test-setup.ts @@ -0,0 +1,2 @@ +import 'jest-preset-angular'; +import '../../../../jestGlobalMocks'; diff --git a/libs/utils/analytics/tsconfig.json b/libs/utils/analytics/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..08c7db8c96729fc7e5d208344835a678c973f96c --- /dev/null +++ b/libs/utils/analytics/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "types": ["node", "jest"] + }, + "include": ["**/*.ts"] +} diff --git a/libs/utils/analytics/tsconfig.lib.json b/libs/utils/analytics/tsconfig.lib.json new file mode 100644 index 0000000000000000000000000000000000000000..1c600457d394acbbc693cc87f480a170a4b277cd --- /dev/null +++ b/libs/utils/analytics/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/analytics/tsconfig.spec.json b/libs/utils/analytics/tsconfig.spec.json new file mode 100644 index 0000000000000000000000000000000000000000..fd405a65ef42fc2a9dece7054ce3338c0195210b --- /dev/null +++ b/libs/utils/analytics/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/analytics/tslint.json b/libs/utils/analytics/tslint.json new file mode 100644 index 0000000000000000000000000000000000000000..5c1c5f0ecc3b54c086b81dbe1161948f212a4b49 --- /dev/null +++ b/libs/utils/analytics/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/libs/utils/browser/src/lib/tracing/request-tracing.config.ts b/libs/utils/browser/src/lib/tracing/request-tracing.config.ts index b59a7f8dbbdc386ef4c3c27fe083fe9ca3f2b9dd..e0f4fb73b2aef297743471040bdc800e13fada55 100644 --- a/libs/utils/browser/src/lib/tracing/request-tracing.config.ts +++ b/libs/utils/browser/src/lib/tracing/request-tracing.config.ts @@ -4,4 +4,4 @@ export interface RequestTracingConfig { applicationId: string; } -export const REQUEST_TRACING_CONFIG = new InjectionToken('request.tracing'); +export const REQUEST_TRACING_CONFIG = new InjectionToken('psu.utils.request.tracing'); diff --git a/libs/utils/cdn/README.md b/libs/utils/cdn/README.md new file mode 100644 index 0000000000000000000000000000000000000000..891a3e54f4fdeabd56ac1d5be114747d0ff830de --- /dev/null +++ b/libs/utils/cdn/README.md @@ -0,0 +1,104 @@ +# @psu/utils/cdn + +Utitlity for easily resolving URIs for resources stored on the CDN (images, etc). + +## Configuration + +There is an application-level injection token for providing the base URL to the CDN. Set this up +in your AppModule/AppInitializer. + +```typescript +import { CDN_CONFIG } from '@psu/utils/cdn'; + +... +providers: [ + ... + { provide: CDN_CONFIG, useValue: { + baseUrl: 'http://my-api' // simple case + }} +] +``` + +If you're loading the CDN base URL dynamically from the app properties (likely), provide an empty object above, +then in the factory function override: + +```typescript +export function initProperties( + propertiesService: PropertiesService, + cdnConfig: CdnConfig, + ... +): () => Promise { + return () => propertiesService.load('my-app').then(p => { + Object.assign(cdnConfig, p.cdn) + }); +} +``` + +## CdnService + +The main CdnService is `providedIn: 'root'`. You simply inject it to a component or service, and you +can pass relative paths to resources to the `uri()` method. + +## CdnPipe + +The `| psuCdn` pipe allows you to avoid injecting CdnService into your components. It supports both a +relative URI or a resource key, see below. + +In its current form, the CDN pipe does not support responsive resources. PRs welcome. + +### Providing Resources + +There are some optional advanced features of the CdnService. Depending on the project, the remote CDN +may have both a high-res desktop image, and a corresponding lower-res mobile image (for faster download speed). + +Managing those resources in code can become cumbersome and repetitive. We've added some injection tokens +that you can leverage to separate the relative path to the resource from the component that is going to +end up displaying the resource. These are optional. + +Both of these injection tokens are designed to be used with Angular's multi providers functionality, +so you can provide these injection tokens many times throughout libraries or apps. +**You should use prefixes to make sure the keys used in these maps are unique.** + +`CDN_RESOURCE_CONFIG` allows you to provide a map of `key: relativePath`. +`CDN_RESPONSIVE_RESOURCE_CONFIG` allows you to provide a map of `key: {mobile: 'path', desktop: 'path'}`. + +Examples: + +```typescript +@NgModule({ + imports: [ + CdnModule // for the CdnPipe + ], + providers: [ + ...{ + provide: CDN_RESOURCE_CONFIG, + multi: true, // <-- this is important + useValue: { + resources: { + // the 'AccountCreationLogo' key is later passed to CdnService.uri() + AccountCreationLogo: 'img/PSU_HOR_REV_RGB_2C.svg' + } + } + }, + { + provide: CDN_RESPONSIVE_RESOURCE_CONFIG, + multi: true, // <-- this is important + useValue: { + responsiveResources: { + // the 'AccountCreationBg' key is later passed to CdnService.responsive() + AccountCreationBg: { + desktop: 'img/bg/aerial/d_UPAerial.jpg', + mobile: 'img/bg/aerial/m_UPAerial.jpg' + } + } + } + } + ] +}) +@Component({ + template: ` + ('psu.utils.cdn.config'); +export const CDN_RESOURCE_CONFIG = new InjectionToken('psu.utils.cdn.resource.config'); +export const CDN_RESPONSIVE_RESOURCE_CONFIG = new InjectionToken( + 'psu.utils.cdn.responsive.resource.config' +); diff --git a/libs/utils/cdn/src/lib/cdn.model.spec.ts b/libs/utils/cdn/src/lib/cdn.model.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..f1d0da46878c8a3b9043624c86a296dc34dd4287 --- /dev/null +++ b/libs/utils/cdn/src/lib/cdn.model.spec.ts @@ -0,0 +1,26 @@ +import { CdnUri } from './cdn.model'; + +describe('cdn model', () => { + it('instantiation', () => { + const cdn: CdnUri = { + mobile: 'string', + desktop: 'string' + }; + expect(cdn.mobile).toBe('string'); + expect(cdn.desktop).toBe('string'); + }); + + it('extends', () => { + class CdnExt extends CdnUri { + public tablet: string; + } + + const cdnExt: CdnExt = { + mobile: 'string', + desktop: 'string', + tablet: 'string' + }; + expect(cdnExt.mobile).toBe('string'); + expect(cdnExt.desktop).toBe('string'); + }); +}); diff --git a/libs/utils/cdn/src/lib/cdn.model.ts b/libs/utils/cdn/src/lib/cdn.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..377f8cbb1dc2490b8dc9c8acd67e791cd9a022ae --- /dev/null +++ b/libs/utils/cdn/src/lib/cdn.model.ts @@ -0,0 +1,4 @@ +export class CdnUri { + public mobile: string; + public desktop: string; +} diff --git a/libs/utils/cdn/src/lib/cdn.module.ts b/libs/utils/cdn/src/lib/cdn.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..0de8f660fab80c4303418ef741759d074436cf69 --- /dev/null +++ b/libs/utils/cdn/src/lib/cdn.module.ts @@ -0,0 +1,10 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { CdnPipe } from './cdn.pipe'; + +@NgModule({ + imports: [CommonModule], + declarations: [CdnPipe], + exports: [CdnPipe] +}) +export class CdnModule {} diff --git a/libs/utils/cdn/src/lib/cdn.pipe.spec.ts b/libs/utils/cdn/src/lib/cdn.pipe.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..bf141e938ceb1d597d510fbaf20ae4260746da0c --- /dev/null +++ b/libs/utils/cdn/src/lib/cdn.pipe.spec.ts @@ -0,0 +1,31 @@ +import { Mock } from 'ts-mocks'; +import { CdnPipe } from './cdn.pipe'; +import { CdnService } from './cdn.service'; + +describe('CdnPipe', () => { + let pipe: CdnPipe; + let service: Mock; + + beforeEach(() => { + service = new Mock({ + uri: path => `http://my-cdn/${path === 'myKey' ? 'anotherResource/another.jpg' : path}` + }); + pipe = new CdnPipe(service.Object); + }); + + it('should create', () => { + expect(pipe).toBeDefined(); + }); + + it('transform with undefined input should return empty string', () => { + expect(pipe.transform(undefined)).toBe(''); + }); + + it('transform with path should call cdn', () => { + expect(pipe.transform('myResource/something.png')).toEqual('http://my-cdn/myResource/something.png'); + }); + + it('transform with key should call cdn', () => { + expect(pipe.transform('myKey')).toEqual('http://my-cdn/anotherResource/another.jpg'); + }); +}); diff --git a/libs/utils/cdn/src/lib/cdn.pipe.ts b/libs/utils/cdn/src/lib/cdn.pipe.ts new file mode 100644 index 0000000000000000000000000000000000000000..7990ee45e8014e05393a8451d5de29674edc94a3 --- /dev/null +++ b/libs/utils/cdn/src/lib/cdn.pipe.ts @@ -0,0 +1,13 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { CdnService } from './cdn.service'; + +@Pipe({ + name: 'psuCdn' +}) +export class CdnPipe implements PipeTransform { + constructor(private cdnService: CdnService) {} + + public transform(value: string): string { + return !!value ? this.cdnService.uri(value) : ''; + } +} diff --git a/libs/utils/cdn/src/lib/cdn.service.spec.ts b/libs/utils/cdn/src/lib/cdn.service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..5493bbd53f74fe2c6a297f6c57d1d3a3148daa4f --- /dev/null +++ b/libs/utils/cdn/src/lib/cdn.service.spec.ts @@ -0,0 +1,271 @@ +import { TestBed } from '@angular/core/testing'; +import { + CdnConfig, + CdnResourceConfig, + CdnResponsiveResourceConfig, + CDN_CONFIG, + CDN_RESOURCE_CONFIG, + CDN_RESPONSIVE_RESOURCE_CONFIG +} from './cdn.config'; +import { CdnService } from './cdn.service'; + +describe('CdnService', () => { + let service: CdnService; + + let config: CdnConfig; + function configFactory(): CdnConfig { + return config; + } + + let resources1: CdnResourceConfig; + function resourceFactory1(): CdnResourceConfig { + return resources1; + } + + let resources2: CdnResourceConfig; + function resourceFactory2(): CdnResourceConfig { + return resources2; + } + + let responsiveResources1: CdnResponsiveResourceConfig; + function responsiveResourceFactory1(): CdnResponsiveResourceConfig { + return responsiveResources1; + } + + let responsiveResources2: CdnResponsiveResourceConfig; + function responsiveResourceFactory2(): CdnResponsiveResourceConfig { + return responsiveResources2; + } + + beforeEach(() => { + config = { + baseUrl: undefined + }; + resources1 = { + resources: undefined + }; + resources2 = { + resources: undefined + }; + responsiveResources1 = { + responsiveResources: undefined + }; + responsiveResources2 = { + responsiveResources: undefined + }; + TestBed.configureTestingModule({ + providers: [ + CdnService, + { + provide: CDN_CONFIG, + useFactory: configFactory + }, + { + provide: CDN_RESOURCE_CONFIG, + useFactory: resourceFactory1, + multi: true + }, + { + provide: CDN_RESPONSIVE_RESOURCE_CONFIG, + useFactory: responsiveResourceFactory1, + multi: true + }, + { + provide: CDN_RESOURCE_CONFIG, + useFactory: resourceFactory2, + multi: true + }, + { + provide: CDN_RESPONSIVE_RESOURCE_CONFIG, + useFactory: responsiveResourceFactory2, + multi: true + } + ] + }); + + service = TestBed.get(CdnService); + }); + + it('should create', () => { + expect(service).toBeDefined(); + }); + + describe('when config.baseUrl is missing', () => { + it('uri should use default baseUrl', () => { + expect(service.uri('test')).toBe('https://static.apps.psu.edu/test'); + }); + }); + + describe('when config.baseUrl is set with no trailing slash', () => { + beforeEach(() => { + config.baseUrl = '/cdn'; + }); + + it('uri should use baseUrl from config', () => { + expect(service.uri('test')).toBe('/cdn/test'); + }); + }); + + describe('when config.baseUrl is set', () => { + beforeEach(() => { + config.baseUrl = '/cdn/'; + resources1.resources = { + Another: 'another.jpg' + }; + resources2.resources = { + '2fa': '2fa.png' + }; + }); + + it('unknown uri should use baseUrl', () => { + expect(service.uri('test')).toBe('/cdn/test'); + }); + + it('known uri from group 1 should use baseUrl', () => { + expect(service.uri('Another')).toBe('/cdn/another.jpg'); + }); + + it('known uri from group 2 should use baseUrl', () => { + expect(service.uri('2fa')).toBe('/cdn/2fa.png'); + }); + + describe('responsiveResources', () => { + describe('when no config exists', () => { + describe('desktop', () => { + it('should use plain uri', () => { + expect(service.desktop('missing')).toBe('/cdn/missing'); + }); + }); + + describe('mobile', () => { + it('should use plain uri', () => { + expect(service.mobile('missing')).toBe('/cdn/missing'); + }); + }); + + describe('responsive', () => { + it('should return undefined', () => { + expect(service.responsive('missing')).toBeUndefined(); + }); + }); + }); + + describe('when config exists', () => { + beforeEach(() => { + responsiveResources1.responsiveResources = { + a: { + mobile: 'am.png', + desktop: 'ad.png' + }, + b: { + mobile: 'bm.png', + desktop: 'bd.png' + } + }; + responsiveResources2.responsiveResources = { + c: { + mobile: 'cm.png', + desktop: 'dd.png' + }, + d: { + mobile: 'dm.png', + desktop: 'dd.png' + } + }; + }); + + describe('responsive', () => { + describe('when key exists in config 1', () => { + it('should return model with cdn baseUrl prepended', () => { + expect(service.responsive('a')).toEqual({ + mobile: '/cdn/am.png', + desktop: '/cdn/ad.png' + }); + expect(responsiveResources1.responsiveResources.a).toEqual({ + mobile: 'am.png', + desktop: 'ad.png' + }); + }); + }); + + describe('when key exists in config 2', () => { + it('should return model with cdn baseUrl prepended', () => { + expect(service.responsive('d')).toEqual({ + mobile: '/cdn/dm.png', + desktop: '/cdn/dd.png' + }); + expect(responsiveResources2.responsiveResources.d).toEqual({ + mobile: 'dm.png', + desktop: 'dd.png' + }); + }); + }); + + describe('when key does not exist in config', () => { + it('should return undefined', () => { + expect(service.responsive('missing')).toBeUndefined(); + }); + }); + }); + + describe('mobile', () => { + describe('when key exists in config 1', () => { + it('should use value from config', () => { + expect(service.mobile('b')).toBe('/cdn/bm.png'); + }); + }); + + describe('when key exists in config 2', () => { + it('should use value from config', () => { + expect(service.mobile('c')).toBe('/cdn/cm.png'); + }); + }); + + describe('when key does not exist in config', () => { + it('should default to regular resource', () => { + expect(service.mobile('e')).toBe('/cdn/e'); + }); + }); + }); + + describe('desktop', () => { + describe('when key exists in config 1', () => { + it('should use value from config', () => { + expect(service.desktop('a')).toBe('/cdn/ad.png'); + }); + }); + + describe('when key exists in config 2', () => { + it('should use value from config', () => { + expect(service.desktop('d')).toBe('/cdn/dd.png'); + }); + }); + + describe('when key does not exist in config', () => { + it('should default to regular resource', () => { + expect(service.desktop('f')).toBe('/cdn/f'); + }); + }); + }); + }); + }); + }); +}); + +describe('CdnService with no CDN_CONFIG', () => { + let service: CdnService; + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [CdnService] + }); + service = TestBed.get(CdnService); + }); + + it('should create', () => { + expect(service).toBeDefined(); + }); + + it('should use default baseUrl', () => { + expect(service.uri('test')).toBe('https://static.apps.psu.edu/test'); + }); +}); diff --git a/libs/utils/cdn/src/lib/cdn.service.ts b/libs/utils/cdn/src/lib/cdn.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..36c5098781ba589084f2873a44acffd15c264679 --- /dev/null +++ b/libs/utils/cdn/src/lib/cdn.service.ts @@ -0,0 +1,115 @@ +import { Inject, Injectable, Optional } from '@angular/core'; +import { + CdnConfig, + CdnResourceConfig, + CdnResponsiveResourceConfig, + CDN_CONFIG, + CDN_RESOURCE_CONFIG, + CDN_RESPONSIVE_RESOURCE_CONFIG +} from './cdn.config'; +import { CdnUri } from './cdn.model'; + +/** + * CdnService provides an API to resolve the full URI for resources stored on our remote CDN. + */ +@Injectable({ providedIn: 'root' }) +export class CdnService { + constructor( + @Optional() + @Inject(CDN_CONFIG) + private config: CdnConfig, + @Optional() + @Inject(CDN_RESOURCE_CONFIG) + private resourceConfig: CdnResourceConfig[], + @Optional() + @Inject(CDN_RESPONSIVE_RESOURCE_CONFIG) + private responsiveResourceConfig: CdnResponsiveResourceConfig[] + ) {} + + private get cdnUrl(): string { + let url = 'https://static.apps.psu.edu'; + if (this.config && this.config.baseUrl) { + url = this.config.baseUrl; + } + if (url && url.lastIndexOf('/') === url.length - 1) { + url = url.substr(0, url.length - 1); + } + return url; + } + + /** + * Resolve the absolute URI for the mobile asset for the given resource key. + * + * @param key the name of the resource registered using CDN_RESPONSIVE_RESOURCE_CONFIG injection token + */ + public mobile(key: string): string { + return this.responsiveResource(key, 'mobile'); + } + + /** + * Resolve the absolute URI for the desktop asset for the given resource key. + * + * @param key the name of the resource registered using CDN_RESPONSIVE_RESOURCE_CONFIG injection token + */ + public desktop(key: string): string { + return this.responsiveResource(key, 'desktop'); + } + + /** + * Resolve the CDN resources for a given resource key. This will return an object that contains + * the absolute URI to the given resource. + * + * @param key the name of the resource registered using CDN_RESPONSIVE_RESOURCE_CONFIG injection token + */ + public responsive(key: string): CdnUri { + let ret: CdnUri; + if (this.allResponsiveResources[key]) { + ret = { ...this.allResponsiveResources[key] }; + ret.desktop = this.resource(ret.desktop); + ret.mobile = this.resource(ret.mobile); + } + return ret; + } + + private responsiveResource(key: string, mode): string { + let path = key; + if (this.allResponsiveResources[key]) { + path = this.allResponsiveResources[key][mode]; + } + return this.resource(path); + } + + /** + * Resolve the absolute URI to a given resource. + * + * @param path the name of the resource registered using CDN_RESOURCE_CONFIG, or the relative path to the resource + */ + public uri(path: string): string { + if (this.allResources[path]) { + path = this.allResources[path]; + } + return this.resource(path); + } + + private resource(path: string): string { + return `${this.cdnUrl}/${path}`; + } + + private get allResources(): { [key: string]: string } { + return (this.resourceConfig || []) + .filter(c => !!c) + .map(c => c.resources) + .reduce((acc, cur) => { + return { ...acc, ...cur }; + }, {}); + } + + private get allResponsiveResources(): { [key: string]: CdnUri } { + return (this.responsiveResourceConfig || []) + .filter(c => !!c) + .map(c => c.responsiveResources) + .reduce((acc, cur) => { + return { ...acc, ...cur }; + }, {}); + } +} diff --git a/libs/utils/cdn/src/test-setup.ts b/libs/utils/cdn/src/test-setup.ts new file mode 100644 index 0000000000000000000000000000000000000000..ea6fbefa5107bf0742b20f740fe208d15657248b --- /dev/null +++ b/libs/utils/cdn/src/test-setup.ts @@ -0,0 +1,2 @@ +import 'jest-preset-angular'; +import '../../../../jestGlobalMocks'; diff --git a/libs/utils/cdn/tsconfig.json b/libs/utils/cdn/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..08c7db8c96729fc7e5d208344835a678c973f96c --- /dev/null +++ b/libs/utils/cdn/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "types": ["node", "jest"] + }, + "include": ["**/*.ts"] +} diff --git a/libs/utils/cdn/tsconfig.lib.json b/libs/utils/cdn/tsconfig.lib.json new file mode 100644 index 0000000000000000000000000000000000000000..1c600457d394acbbc693cc87f480a170a4b277cd --- /dev/null +++ b/libs/utils/cdn/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/cdn/tsconfig.spec.json b/libs/utils/cdn/tsconfig.spec.json new file mode 100644 index 0000000000000000000000000000000000000000..fd405a65ef42fc2a9dece7054ce3338c0195210b --- /dev/null +++ b/libs/utils/cdn/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/cdn/tslint.json b/libs/utils/cdn/tslint.json new file mode 100644 index 0000000000000000000000000000000000000000..5c1c5f0ecc3b54c086b81dbe1161948f212a4b49 --- /dev/null +++ b/libs/utils/cdn/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/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/libs/utils/ngrx/README.md b/libs/utils/ngrx/README.md index 9d813693172fe9f070b2c457b0b82303f91dfcf6..07d8c3d75889e1e63ee1398cb0756461e0bf4dda 100644 --- a/libs/utils/ngrx/README.md +++ b/libs/utils/ngrx/README.md @@ -1,7 +1,11 @@ -# utils-ngrx +# @psu/utils/ngrx -This library was generated with [Nx](https://nx.dev). +NgRx extensions for common utilities -## Running unit tests +## Window -Run `nx test utils-ngrx` to execute the unit tests. +Actions and effects for opening links (usually in a new window/tab) + +## Progress + +Actions and an HTTP Interceptor for handling progress. diff --git a/libs/utils/ngrx/src/index.ts b/libs/utils/ngrx/src/index.ts index 07361c34cf339397c5f30bf3ae32392d41eb126c..45f81be266d0b49d6a4b56627da8fd48b1c469bf 100644 --- a/libs/utils/ngrx/src/index.ts +++ b/libs/utils/ngrx/src/index.ts @@ -1,2 +1,11 @@ export * from './lib/ngrx.module'; -export * from './lib/window'; +export * from './lib/progress/progress.actions'; +export * from './lib/progress/progress.interceptor'; +export { + ProgressPartialState, + progressSliceName, + ProgressState, + selectProgressPending, + selectProgressState +} from './lib/progress/progress.reducer'; +export * from './lib/window/window.actions'; diff --git a/libs/utils/ngrx/src/lib/ngrx.module.ts b/libs/utils/ngrx/src/lib/ngrx.module.ts index 5c6e3beb367ad63a9b664fab05bd09cd608d1837..59a4c8dbe50b1afc63b301ee8fa626af8f7e05ea 100644 --- a/libs/utils/ngrx/src/lib/ngrx.module.ts +++ b/libs/utils/ngrx/src/lib/ngrx.module.ts @@ -1,9 +1,15 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { EffectsModule } from '@ngrx/effects'; +import { StoreModule } from '@ngrx/store'; +import { progressReducer, progressSliceName } from './progress/progress.reducer'; import { WindowEffects } from './window/window.effects'; @NgModule({ - imports: [CommonModule, EffectsModule.forFeature([WindowEffects])] + imports: [ + CommonModule, + StoreModule.forFeature(progressSliceName, progressReducer), + EffectsModule.forFeature([WindowEffects]) + ] }) export class NgrxModule {} diff --git a/libs/utils/ngrx/src/lib/progress/progress.actions.ts b/libs/utils/ngrx/src/lib/progress/progress.actions.ts new file mode 100644 index 0000000000000000000000000000000000000000..88a3501e90d1dacaccb1e7a6467f210a3723c498 --- /dev/null +++ b/libs/utils/ngrx/src/lib/progress/progress.actions.ts @@ -0,0 +1,6 @@ +import { createAction } from '@ngrx/store'; + +const PREFIX = '[@psu/utils Progress]'; + +export const progressStart = createAction(`${PREFIX} Start`); +export const progressEnd = createAction(`${PREFIX} End`); diff --git a/libs/utils/ngrx/src/lib/progress/progress.interceptor.spec.ts b/libs/utils/ngrx/src/lib/progress/progress.interceptor.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..c3cf717bd964e0131f5f4b2f4b32fdd35ef00d0e --- /dev/null +++ b/libs/utils/ngrx/src/lib/progress/progress.interceptor.spec.ts @@ -0,0 +1,89 @@ +import { HttpClient, HTTP_INTERCEPTORS } from '@angular/common/http'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { inject, TestBed } from '@angular/core/testing'; +import { Store, StoreModule } from '@ngrx/store'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { progressEnd, progressStart } from './progress.actions'; +import { ProgressInterceptor } from './progress.interceptor'; +import { progressReducer, progressSliceName } from './progress.reducer'; + +describe('ProgressInterceptor', () => { + let store: Store; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forFeature(progressSliceName, progressReducer), + StoreModule.forRoot({}), + HttpClientTestingModule + ], + providers: [ + { + provide: HTTP_INTERCEPTORS, + useClass: ProgressInterceptor, + multi: true + } + ] + }); + + store = TestBed.get(Store); + spyOn(store, 'dispatch').and.callThrough(); + }); + + it('should create', () => { + expect(TestBed.get(HTTP_INTERCEPTORS)[0]).toBeDefined(); + }); + + describe('intercepting http calls', () => { + beforeEach(inject([HttpClient, HttpTestingController], (http: HttpClient, httpMock: HttpTestingController) => { + http + .get('/data') + .pipe(catchError(err => of(null))) + .subscribe(); + })); + + it('should dispatch progress start action', () => { + expect(store.dispatch).toHaveBeenCalledWith(progressStart()); + }); + + describe('when http response is successful', () => { + beforeEach(inject([HttpClient, HttpTestingController], (http: HttpClient, httpMock: HttpTestingController) => { + const req = httpMock.expectOne('/data'); + req.flush({}); + httpMock.verify(); + })); + + it('should dispatch progress end action', () => { + expect(store.dispatch).toHaveBeenCalledWith(progressEnd()); + expect(store.dispatch).toHaveBeenCalledTimes(2); + }); + }); + + describe('when http response is error', () => { + beforeEach(inject([HttpClient, HttpTestingController], (http: HttpClient, httpMock: HttpTestingController) => { + const req = httpMock.expectOne('/data'); + req.flush({ errorMessage: 'Uh oh!' }, { status: 500, statusText: 'Server Error' }); + httpMock.verify(); + })); + + it('should dispatch progress end action', () => { + expect(store.dispatch).toHaveBeenCalledWith(progressEnd()); + expect(store.dispatch).toHaveBeenCalledTimes(2); + }); + }); + + describe('when http event errors or is cancelled', () => { + beforeEach(inject([HttpClient, HttpTestingController], (http: HttpClient, httpMock: HttpTestingController) => { + const req = httpMock.expectOne('/data'); + req.error({} as any); + httpMock.verify(); + })); + + it('should dispatch progress end action', () => { + expect(store.dispatch).toHaveBeenCalledWith(progressEnd()); + expect(store.dispatch).toHaveBeenCalledTimes(2); + }); + }); + }); +}); diff --git a/libs/utils/ngrx/src/lib/progress/progress.interceptor.ts b/libs/utils/ngrx/src/lib/progress/progress.interceptor.ts new file mode 100644 index 0000000000000000000000000000000000000000..35e4aadf89e7181ebf585f5c5f41f01fe0e19b66 --- /dev/null +++ b/libs/utils/ngrx/src/lib/progress/progress.interceptor.ts @@ -0,0 +1,20 @@ +import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { finalize } from 'rxjs/operators'; +import { progressEnd, progressStart } from './progress.actions'; + +@Injectable({ providedIn: 'root' }) +export class ProgressInterceptor implements HttpInterceptor { + constructor(private store: Store) {} + + public intercept(req: HttpRequest, next: HttpHandler): Observable> { + this.store.dispatch(progressStart()); + return next.handle(req).pipe( + finalize(() => { + this.store.dispatch(progressEnd()); + }) + ); + } +} diff --git a/libs/utils/ngrx/src/lib/progress/progress.reducer.spec.ts b/libs/utils/ngrx/src/lib/progress/progress.reducer.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..def455356f5caa669a6b4817b7fd5839dd93c272 --- /dev/null +++ b/libs/utils/ngrx/src/lib/progress/progress.reducer.spec.ts @@ -0,0 +1,114 @@ +import { progressEnd, progressStart } from './progress.actions'; +import { progressReducer, ProgressState } from './progress.reducer'; + +describe('ProgressReducer', () => { + let state: ProgressState; + let initialState: ProgressState; + + beforeEach(() => { + initialState = { + pending: false, + counter: 0 + }; + }); + + describe('ProgressStart action', () => { + beforeEach(() => { + state = progressReducer(undefined, progressStart()); + }); + + it('should set pending to true', () => { + expect(state.pending).toBe(true); + }); + + it('should increment counter', () => { + expect(state.counter).toBe(1); + }); + + describe('after another start action', () => { + beforeEach(() => { + state = progressReducer(state, progressStart()); + }); + + it('should set pending to true', () => { + expect(state.pending).toBe(true); + }); + + it('should increment counter', () => { + expect(state.counter).toBe(2); + }); + }); + }); + + describe('ProgressEnd action', () => { + describe('when counter is 1', () => { + beforeEach(() => { + state = progressReducer( + { + pending: true, + counter: 1 + }, + progressEnd() + ); + }); + + it('should set pending to false', () => { + expect(state.pending).toBe(false); + }); + + it('should set counter to 0', () => { + expect(state.counter).toBe(0); + }); + }); + + describe('when counter is 2', () => { + beforeEach(() => { + state = progressReducer( + { + pending: true, + counter: 2 + }, + progressEnd() + ); + }); + + it('should set pending to true', () => { + expect(state.pending).toBe(true); + }); + + it('should set counter to 1', () => { + expect(state.counter).toBe(1); + }); + }); + + describe('when counter is 0', () => { + beforeEach(() => { + state = progressReducer( + { + pending: false, + counter: 0 + }, + progressEnd() + ); + }); + + it('should set pending to false', () => { + expect(state.pending).toBe(false); + }); + + it('should set counter to 0', () => { + expect(state.counter).toBe(0); + }); + }); + }); + + describe('unknown action', () => { + beforeEach(() => { + state = progressReducer(undefined, { type: 'GARBAGE' }); + }); + + it('should not modify state', () => { + expect(state).toEqual(initialState); + }); + }); +}); diff --git a/libs/utils/ngrx/src/lib/progress/progress.reducer.ts b/libs/utils/ngrx/src/lib/progress/progress.reducer.ts new file mode 100644 index 0000000000000000000000000000000000000000..e898244be708f6999a4ea4283bf64e4a94f22206 --- /dev/null +++ b/libs/utils/ngrx/src/lib/progress/progress.reducer.ts @@ -0,0 +1,41 @@ +import { Action, createFeatureSelector, createReducer, createSelector, on } from '@ngrx/store'; +import { progressEnd, progressStart } from './progress.actions'; + +export const progressSliceName = 'utils-progress'; + +export interface ProgressState { + counter: number; + pending: boolean; +} + +export const initialProgressState: ProgressState = { + pending: false, + counter: 0 +}; + +export interface ProgressPartialState { + [progressSliceName]: ProgressState; +} + +export const selectProgressState = createFeatureSelector(progressSliceName); +export const selectProgressPending = createSelector( + selectProgressState, + s => s.pending +); + +const reducer = createReducer( + initialProgressState, + on(progressStart, state => ({ ...state, pending: true, counter: state.counter + 1 })), + on(progressEnd, state => { + const counter = Math.max(0, state.counter - 1); + return { + ...state, + counter, + pending: counter > 0 + }; + }) +); + +export function progressReducer(state: ProgressState | undefined, action: Action) { + return reducer(state, action); +} diff --git a/libs/utils/ngrx/src/lib/window/index.ts b/libs/utils/ngrx/src/lib/window/index.ts deleted file mode 100644 index 8bf7364d0e6c1dffc4ca7ba74816de1a8ca39538..0000000000000000000000000000000000000000 --- a/libs/utils/ngrx/src/lib/window/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './window.actions'; diff --git a/libs/utils/package.json b/libs/utils/package.json index aa7e951ad6bcdf0263032370434475aaba46ad44..13ebe39de577fd6ff2503cbfad1ee2181b89ac89 100644 --- a/libs/utils/package.json +++ b/libs/utils/package.json @@ -7,6 +7,7 @@ "@angular/cdk": "^8.2.3", "@ngrx/store": "^8.0.0", "@ngrx/effects": "^8.0.0", + "backoff-rxjs": ">=6.3.3", "ramda": "^0.27.0", "short-uuid": "^3.1.1" }, diff --git a/libs/utils/properties/src/lib/properties.config.ts b/libs/utils/properties/src/lib/properties.config.ts index 543ec1afde6282e0306d1f35a04fac58a82b4c4f..5284cbaad2e8c60d53f2ae7162d824e047ed3c9b 100644 --- a/libs/utils/properties/src/lib/properties.config.ts +++ b/libs/utils/properties/src/lib/properties.config.ts @@ -4,4 +4,4 @@ export interface PropertiesConfig { propertiesBase: string; } -export const PROPERTIES_CONFIG = new InjectionToken('properties.config'); +export const PROPERTIES_CONFIG = new InjectionToken('psu.utils.properties.config'); diff --git a/libs/utils/rx/src/index.ts b/libs/utils/rx/src/index.ts index c80fdc6653e4462ed2d2770d93873645d6c3eec6..bd6ec3f301e00b02972ce07a991841d372b431a1 100644 --- a/libs/utils/rx/src/index.ts +++ b/libs/utils/rx/src/index.ts @@ -1,2 +1 @@ export { httpRetryBackoff, HttpRetryBackoffConfig } from './lib/http-backoff'; -export { createReducer } from './lib/reducer-utils'; diff --git a/libs/utils/rx/src/lib/reducer-utils.spec.ts b/libs/utils/rx/src/lib/reducer-utils.spec.ts deleted file mode 100644 index 91ca184923a1bdc7c2c6ec16a0f0471054b0f533..0000000000000000000000000000000000000000 --- a/libs/utils/rx/src/lib/reducer-utils.spec.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { createReducer } from '..'; - -describe('reducer utils', () => { - describe('createReducer', () => { - it('should create reducer correctly', () => { - const result = createReducer({}, {}); - expect(result instanceof Function).toBe(true); - expect(result({ s: 's' }, {})).toEqual({ s: 's' }); - }); - }); -}); diff --git a/libs/utils/rx/src/lib/reducer-utils.ts b/libs/utils/rx/src/lib/reducer-utils.ts deleted file mode 100644 index 5976b79a7f22816977d5441c157d049a6a215766..0000000000000000000000000000000000000000 --- a/libs/utils/rx/src/lib/reducer-utils.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * @deprecated use ngrx reducer creators instead - * - */ -export function createReducer(iState, handlers): (state, action) => any { - return (state = iState, action) => { - return handlers.hasOwnProperty(action.type) ? handlers[action.type](state, action) : state; - }; -} diff --git a/libs/utils/rx/tslint.json b/libs/utils/rx/tslint.json index 44c64460c8ae0db3ceb7074ff89c770907ebade7..5c1c5f0ecc3b54c086b81dbe1161948f212a4b49 100644 --- a/libs/utils/rx/tslint.json +++ b/libs/utils/rx/tslint.json @@ -1,7 +1,7 @@ { "extends": "../../../tslint.json", "rules": { - "directive-selector": [true, "attribute", "psu", "camelCase"], - "component-selector": [true, "element", "psu", "kebab-case"] + "directive-selector": [true, "attribute", "ut", "camelCase"], + "component-selector": [true, "element", "ut", "kebab-case"] } } diff --git a/libs/utils/security/tslint.json b/libs/utils/security/tslint.json index 44c64460c8ae0db3ceb7074ff89c770907ebade7..5c1c5f0ecc3b54c086b81dbe1161948f212a4b49 100644 --- a/libs/utils/security/tslint.json +++ b/libs/utils/security/tslint.json @@ -1,7 +1,7 @@ { "extends": "../../../tslint.json", "rules": { - "directive-selector": [true, "attribute", "psu", "camelCase"], - "component-selector": [true, "element", "psu", "kebab-case"] + "directive-selector": [true, "attribute", "ut", "camelCase"], + "component-selector": [true, "element", "ut", "kebab-case"] } } diff --git a/libs/utils/theming/src/lib/style-manager.ts b/libs/utils/theming/src/lib/style-manager.ts index 13429eeac63876be38ab75ae7c4091e360ab3ec8..39e4b9bc9d1bfecc0ddb4c32ff01b9074550a723 100644 --- a/libs/utils/theming/src/lib/style-manager.ts +++ b/libs/utils/theming/src/lib/style-manager.ts @@ -7,7 +7,7 @@ export interface Theme { isDark?: boolean; } -export const STYLE_MANAGER_CONFIG = new InjectionToken('style.manager.config'); +export const STYLE_MANAGER_CONFIG = new InjectionToken('psu.utils.style.manager.config'); /** * Class for managing stylesheets. Stylesheets are loaded into named slots so that they can be diff --git a/nx.json b/nx.json index c4df772b71097bea59be3d551e31b2863c8036c1..49647bbf697df3c1e23b91ceae1d058655968c2f 100644 --- a/nx.json +++ b/nx.json @@ -46,6 +46,15 @@ }, "utils-ngrx": { "tags": [] + }, + "utils-cdn": { + "tags": [] + }, + "utils-form": { + "tags": [] + }, + "utils-analytics": { + "tags": [] } } } diff --git a/package.json b/package.json index a4fd71ec7f7e67e176e526af87ffdc238e4bf94a..243570320ddc046fc25ee88f7562d09702b2bb25 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "ng": "ng", "start": "ng serve", "build": "ng build", - "test": "ng test", + "test": "ng test --passWithNoTests", "lint": "ng lint", "e2e": "ng e2e", "affected:apps": "nx affected:apps", diff --git a/tsconfig.json b/tsconfig.json index 87677620cc8d2c10fa958c457a58d5bd081a9b94..dcd31763e0313b3e072d9abb3c7dcf167e0e86e2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,7 +26,10 @@ "@psu/utils/properties": ["libs/utils/properties/src/index.ts"], "@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/ngrx": ["libs/utils/ngrx/src/index.ts"], + "@psu/utils/cdn": ["libs/utils/cdn/src/index.ts"], + "@psu/utils/form": ["libs/utils/form/src/index.ts"], + "@psu/utils/analytics": ["libs/utils/analytics/src/index.ts"] } }, "exclude": ["node_modules", "tmp"]