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..93dcc54607a552678efd98c261f7961962d35a58 100644 --- a/libs/utils/ngrx/src/index.ts +++ b/libs/utils/ngrx/src/index.ts @@ -1,2 +1,3 @@ export * from './lib/ngrx.module'; +export * from './lib/progress'; export * from './lib/window'; 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/index.ts b/libs/utils/ngrx/src/lib/progress/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..fb1d565f6a6212d8c7d551187d828c89ac439d87 --- /dev/null +++ b/libs/utils/ngrx/src/lib/progress/index.ts @@ -0,0 +1,9 @@ +export * from './progress.actions'; +export * from './progress.interceptor'; +export { + ProgressPartialState, + progressSliceName, + ProgressState, + selectProgressPending, + selectProgressState +} from './progress.reducer'; 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); +}