From b01ba534a0017ad8bf85ddecff7610a6de6a74e9 Mon Sep 17 00:00:00 2001 From: crupest Date: Sat, 9 Mar 2019 01:42:38 +0800 Subject: User named route in dialog. --- .../user/user-login-success/user-login-success.component.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) (limited to 'Timeline/ClientApp/src/app/user/user-login-success') diff --git a/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.ts b/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.ts index 99de5970..d141b3b6 100644 --- a/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.ts +++ b/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.ts @@ -1,5 +1,7 @@ import { Component, OnInit, Input } from '@angular/core'; -import { UserInfo } from '../user-info'; +import { UserInfo } from '../entities'; +import { UserService } from '../user-service/user.service'; +import { ActivatedRoute } from '@angular/router'; @Component({ selector: 'app-user-login-success', @@ -8,15 +10,14 @@ import { UserInfo } from '../user-info'; }) export class UserLoginSuccessComponent implements OnInit { - @Input() displayLoginSuccessMessage = false; - @Input() userInfo: UserInfo; - constructor() { } + constructor(private route: ActivatedRoute, private userService: UserService) { } ngOnInit() { + this.userInfo = this.userService.userInfo; + this.displayLoginSuccessMessage = this.route.snapshot.paramMap.get('reason') === 'login'; } - } -- cgit v1.2.3 From 76300141a6411c05d585994d5f19938bfe45838e Mon Sep 17 00:00:00 2001 From: crupest Date: Sat, 9 Mar 2019 21:36:55 +0800 Subject: Seperate internal and public user service. --- Timeline/ClientApp/src/app/app.component.ts | 9 +- .../src/app/user/internal-user-service/errors.ts | 25 +++++ .../user/internal-user-service/http-entities.ts | 17 +++ .../internal-user.service.spec.ts | 119 +++++++++++++++++++++ .../internal-user-service/internal-user.service.ts | 93 ++++++++++++++++ .../user/user-dialog/user-dialog.component.spec.ts | 22 ++-- .../app/user/user-dialog/user-dialog.component.ts | 10 +- .../user-login-success.component.ts | 9 +- .../app/user/user-login/user-login.component.ts | 7 +- .../src/app/user/user-service/user.service.spec.ts | 119 --------------------- .../src/app/user/user-service/user.service.ts | 118 -------------------- Timeline/ClientApp/src/app/user/user.service.ts | 33 ++++++ 12 files changed, 315 insertions(+), 266 deletions(-) create mode 100644 Timeline/ClientApp/src/app/user/internal-user-service/errors.ts create mode 100644 Timeline/ClientApp/src/app/user/internal-user-service/http-entities.ts create mode 100644 Timeline/ClientApp/src/app/user/internal-user-service/internal-user.service.spec.ts create mode 100644 Timeline/ClientApp/src/app/user/internal-user-service/internal-user.service.ts delete mode 100644 Timeline/ClientApp/src/app/user/user-service/user.service.spec.ts delete mode 100644 Timeline/ClientApp/src/app/user/user-service/user.service.ts create mode 100644 Timeline/ClientApp/src/app/user/user.service.ts (limited to 'Timeline/ClientApp/src/app/user/user-login-success') diff --git a/Timeline/ClientApp/src/app/app.component.ts b/Timeline/ClientApp/src/app/app.component.ts index 0e2a9799..ee02f833 100644 --- a/Timeline/ClientApp/src/app/app.component.ts +++ b/Timeline/ClientApp/src/app/app.component.ts @@ -1,6 +1,5 @@ import { Component } from '@angular/core'; -import { MatDialog } from '@angular/material'; -import { UserDialogComponent } from './user/user-dialog/user-dialog.component'; +import { UserService } from './user/user.service'; @Component({ selector: 'app-root', @@ -9,11 +8,9 @@ import { UserDialogComponent } from './user/user-dialog/user-dialog.component'; }) export class AppComponent { - constructor(private dialog: MatDialog) { } + constructor(private userService: UserService) { } openUserDialog() { - this.dialog.open(UserDialogComponent, { - width: '300px' - }); + this.userService.openUserDialog(); } } diff --git a/Timeline/ClientApp/src/app/user/internal-user-service/errors.ts b/Timeline/ClientApp/src/app/user/internal-user-service/errors.ts new file mode 100644 index 00000000..22e44dd6 --- /dev/null +++ b/Timeline/ClientApp/src/app/user/internal-user-service/errors.ts @@ -0,0 +1,25 @@ +export abstract class LoginError extends Error { } + +export class BadNetworkError extends LoginError { + constructor() { + super('Network is bad.'); + } +} + +export class AlreadyLoginError extends LoginError { + constructor() { + super('Internal logical error. There is already a token saved. Please call validateUserLoginState first.'); + } +} + +export class BadCredentialsError extends LoginError { + constructor() { + super('Username or password is wrong.'); + } +} + +export class UnknownError extends LoginError { + constructor(public internalError?: any) { + super('Sorry, unknown error occured!'); + } +} diff --git a/Timeline/ClientApp/src/app/user/internal-user-service/http-entities.ts b/Timeline/ClientApp/src/app/user/internal-user-service/http-entities.ts new file mode 100644 index 00000000..1335b407 --- /dev/null +++ b/Timeline/ClientApp/src/app/user/internal-user-service/http-entities.ts @@ -0,0 +1,17 @@ +import { UserCredentials, UserInfo } from '../entities'; + +export type CreateTokenRequest = UserCredentials; + +export interface CreateTokenResponse { + token: string; + userInfo: UserInfo; +} + +export interface ValidateTokenRequest { + token: string; +} + +export interface ValidateTokenResponse { + isValid: boolean; + userInfo?: UserInfo; +} diff --git a/Timeline/ClientApp/src/app/user/internal-user-service/internal-user.service.spec.ts b/Timeline/ClientApp/src/app/user/internal-user-service/internal-user.service.spec.ts new file mode 100644 index 00000000..4a2c78f8 --- /dev/null +++ b/Timeline/ClientApp/src/app/user/internal-user-service/internal-user.service.spec.ts @@ -0,0 +1,119 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpRequest } from '@angular/common/http'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + +import { UserInfo, UserCredentials } from '../entities'; +import { + InternalUserService, CreateTokenResult, + UserLoginState, TokenValidationRequest, TokenValidationResult +} from './internal-user.service'; + +describe('UserService', () => { + const tokenCreateUrl = '/api/User/CreateToken'; + + const mockUserCredentials: UserCredentials = { + username: 'user', + password: 'user' + }; + + beforeEach(() => TestBed.configureTestingModule({ + imports: [HttpClientTestingModule] + })); + + it('should be created', () => { + const service: InternalUserService = TestBed.get(InternalUserService); + expect(service).toBeTruthy(); + }); + + it('should be nologin at first', () => { + const service: InternalUserService = TestBed.get(InternalUserService); + service.refreshAndGetUserState().subscribe(result => { + expect(result.state).toBe('nologin'); + }); + }); + + it('login should work well', () => { + const service: InternalUserService = TestBed.get(InternalUserService); + + const mockUserInfo: UserInfo = { + username: 'user', + roles: ['user', 'other'] + }; + + service.tryLogin(mockUserCredentials).subscribe(result => { + expect(result).toEqual(mockUserInfo); + }); + + const httpController = TestBed.get(HttpTestingController) as HttpTestingController; + + httpController.expectOne((request: HttpRequest) => + request.url === tokenCreateUrl && + request.body.username === 'user' && + request.body.password === 'user').flush({ + token: 'test-token', + userInfo: mockUserInfo + }); + + httpController.verify(); + }); + + describe('validateUserLoginState', () => { + let service: InternalUserService; + let httpController: HttpTestingController; + + const mockUserInfo: UserInfo = { + username: 'user', + roles: ['user', 'other'] + }; + + const mockToken = 'mock-token'; + + const tokenValidateRequestMatcher = (req: HttpRequest) => { + return req.url === '/api/User/ValidateToken' && req.body.token === mockToken; + }; + + beforeEach(() => { + service = TestBed.get(InternalUserService); + httpController = TestBed.get(HttpTestingController); + + service.tryLogin(mockUserCredentials).subscribe(); // subscribe to activate login + + httpController.expectOne(tokenCreateUrl).flush({ + token: mockToken, + userInfo: mockUserInfo + }); + }); + + it('success should work well', () => { + service.refreshAndGetUserState().subscribe((result: UserLoginState) => { + expect(result).toEqual({ + state: 'success', + userInfo: mockUserInfo + }); + }); + + httpController.expectOne(tokenValidateRequestMatcher).flush({ + isValid: true, + userInfo: mockUserInfo + }); + + httpController.verify(); + }); + + it('invalid should work well', () => { + service.refreshAndGetUserState().subscribe((result: UserLoginState) => { + expect(result).toEqual({ + state: 'invalidlogin' + }); + }); + + httpController.expectOne(tokenValidateRequestMatcher).flush({ + isValid: false + }); + + httpController.verify(); + }); + }); + + // TODO: test on error situations. +}); diff --git a/Timeline/ClientApp/src/app/user/internal-user-service/internal-user.service.ts b/Timeline/ClientApp/src/app/user/internal-user-service/internal-user.service.ts new file mode 100644 index 00000000..f6987d7d --- /dev/null +++ b/Timeline/ClientApp/src/app/user/internal-user-service/internal-user.service.ts @@ -0,0 +1,93 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { Router } from '@angular/router'; + +import { Observable, of, throwError, BehaviorSubject } from 'rxjs'; +import { map, catchError, retry } from 'rxjs/operators'; + +import { AlreadyLoginError, BadCredentialsError, BadNetworkError, UnknownError } from './errors'; +import { CreateTokenRequest, CreateTokenResponse, ValidateTokenRequest, ValidateTokenResponse } from './http-entities'; +import { UserCredentials, UserInfo } from '../entities'; + + +export type UserLoginState = 'nologin' | 'invalidlogin' | 'success'; + +/** + * This service is only used internal in user module. + */ +@Injectable({ + providedIn: 'root' +}) +export class InternalUserService { + + private token: string; + private userInfoSubject = new BehaviorSubject(null); + + get currentUserInfo(): UserInfo | null { + return this.userInfoSubject.value; + } + + get userInfo$(): Observable { + return this.userInfoSubject; + } + + constructor(private httpClient: HttpClient, private router: Router) { } + + userRouteNavigate(commands: any[]) { + this.router.navigate([{ + outlets: { + user: commands + } + }]); + } + + refreshAndGetUserState(): Observable { + if (this.token === undefined || this.token === null) { + return of('nologin'); + } + + return this.httpClient.post('/api/User/ValidateToken', { token: this.token }).pipe( + retry(3), + catchError(error => { + console.error('Failed to validate token.'); + return throwError(error); + }), + map(result => { + if (result.isValid) { + this.userInfoSubject.next(result.userInfo); + return 'success'; + } else { + this.token = null; + this.userInfoSubject.next(null); + return 'invalidlogin'; + } + }) + ); + } + + tryLogin(credentials: UserCredentials): Observable { + if (this.token) { + return throwError(new AlreadyLoginError()); + } + + return this.httpClient.post('/api/User/CreateToken', credentials).pipe( + catchError((error: HttpErrorResponse) => { + if (error.error instanceof ErrorEvent) { + console.error('An error occurred when login: ' + error.error.message); + return throwError(new BadNetworkError()); + } else if (error.status === 400) { + console.error('An error occurred when login: wrong credentials.'); + return throwError(new BadCredentialsError()); + } else { + console.error('An unknown error occurred when login: ' + error); + return throwError(new UnknownError(error)); + } + }), + map(result => { + this.token = result.token; + this.userInfoSubject.next(result.userInfo); + return result.userInfo; + }) + ); + } +} diff --git a/Timeline/ClientApp/src/app/user/user-dialog/user-dialog.component.spec.ts b/Timeline/ClientApp/src/app/user/user-dialog/user-dialog.component.spec.ts index d24c0cd2..dd7af6ca 100644 --- a/Timeline/ClientApp/src/app/user/user-dialog/user-dialog.component.spec.ts +++ b/Timeline/ClientApp/src/app/user/user-dialog/user-dialog.component.spec.ts @@ -4,9 +4,9 @@ import { By } from '@angular/platform-browser'; import { of } from 'rxjs'; import { delay } from 'rxjs/operators'; -import { UserInfo } from '../user-info'; +import { UserInfo } from '../entities'; import { UserDialogComponent } from './user-dialog.component'; -import { UserService, UserLoginState } from '../user-service/user.service'; +import { InternalUserService, UserLoginState } from '../internal-user-service/internal-user.service'; import { LoginEvent } from '../user-login/user-login.component'; @Component({ @@ -38,7 +38,7 @@ class UserLoginSuccessStubComponent { } describe('UserDialogComponent', () => { let component: UserDialogComponent; let fixture: ComponentFixture; - let mockUserService: jasmine.SpyObj; + let mockUserService: jasmine.SpyObj; beforeEach(async(() => { mockUserService = jasmine.createSpyObj('UserService', ['validateUserLoginState', 'tryLogin']); @@ -46,7 +46,7 @@ describe('UserDialogComponent', () => { TestBed.configureTestingModule({ declarations: [UserDialogComponent, MatProgressSpinnerStubComponent, UserLoginStubComponent, UserLoginSuccessStubComponent], - providers: [{ provide: UserService, useValue: mockUserService }] + providers: [{ provide: InternalUserService, useValue: mockUserService }] }) .compileComponents(); })); @@ -57,7 +57,7 @@ describe('UserDialogComponent', () => { }); it('progress spinner should work well', fakeAsync(() => { - mockUserService.validateUserLoginState.and.returnValue(of({ state: 'nologin' }).pipe(delay(10))); + mockUserService.refreshAndGetUserState.and.returnValue(of({ state: 'nologin' }).pipe(delay(10))); fixture.detectChanges(); expect(fixture.debugElement.query(By.css('mat-progress-spinner'))).toBeTruthy(); tick(10); @@ -66,30 +66,30 @@ describe('UserDialogComponent', () => { })); it('nologin should work well', () => { - mockUserService.validateUserLoginState.and.returnValue(of({ state: 'nologin' })); + mockUserService.refreshAndGetUserState.and.returnValue(of({ state: 'nologin' })); fixture.detectChanges(); - expect(mockUserService.validateUserLoginState).toHaveBeenCalled(); + expect(mockUserService.refreshAndGetUserState).toHaveBeenCalled(); expect(fixture.debugElement.query(By.css('app-user-login'))).toBeTruthy(); expect(fixture.debugElement.query(By.css('app-user-login-success'))).toBeFalsy(); }); it('success should work well', () => { - mockUserService.validateUserLoginState.and.returnValue(of({ state: 'success', userInfo: {} })); + mockUserService.refreshAndGetUserState.and.returnValue(of({ state: 'success', userInfo: {} })); fixture.detectChanges(); - expect(mockUserService.validateUserLoginState).toHaveBeenCalled(); + expect(mockUserService.refreshAndGetUserState).toHaveBeenCalled(); expect(fixture.debugElement.query(By.css('app-user-login'))).toBeFalsy(); expect(fixture.debugElement.query(By.css('app-user-login-success'))).toBeTruthy(); }); it('login should work well', () => { - mockUserService.validateUserLoginState.and.returnValue(of({ state: 'nologin' })); + mockUserService.refreshAndGetUserState.and.returnValue(of({ state: 'nologin' })); fixture.detectChanges(); - expect(mockUserService.validateUserLoginState).toHaveBeenCalled(); + expect(mockUserService.refreshAndGetUserState).toHaveBeenCalled(); expect(fixture.debugElement.query(By.css('app-user-login'))).toBeTruthy(); expect(fixture.debugElement.query(By.css('app-user-login-success'))).toBeFalsy(); diff --git a/Timeline/ClientApp/src/app/user/user-dialog/user-dialog.component.ts b/Timeline/ClientApp/src/app/user/user-dialog/user-dialog.component.ts index 0edde924..498ffaa1 100644 --- a/Timeline/ClientApp/src/app/user/user-dialog/user-dialog.component.ts +++ b/Timeline/ClientApp/src/app/user/user-dialog/user-dialog.component.ts @@ -1,5 +1,5 @@ import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core'; -import { UserService } from '../user-service/user.service'; +import { InternalUserService } from '../internal-user-service/internal-user.service'; import { RouterOutlet, Router, ActivationStart } from '@angular/router'; @Component({ @@ -9,7 +9,7 @@ import { RouterOutlet, Router, ActivationStart } from '@angular/router'; }) export class UserDialogComponent implements OnInit, OnDestroy { - constructor(private userService: UserService, private router: Router) { } + constructor(private userService: InternalUserService, private router: Router) { } @ViewChild(RouterOutlet) outlet: RouterOutlet; @@ -24,12 +24,12 @@ export class UserDialogComponent implements OnInit, OnDestroy { }); - this.userService.validateUserLoginState().subscribe(result => { + this.userService.refreshAndGetUserState().subscribe(result => { this.isLoading = false; - if (result.state === 'success') { + if (result === 'success') { this.userService.userRouteNavigate(['success', { reason: 'already' }]); } else { - this.userService.userRouteNavigate(['login', { reason: result.state }]); + this.userService.userRouteNavigate(['login', { reason: result }]); } }); } diff --git a/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.ts b/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.ts index d141b3b6..48e331d6 100644 --- a/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.ts +++ b/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.ts @@ -1,8 +1,9 @@ import { Component, OnInit, Input } from '@angular/core'; -import { UserInfo } from '../entities'; -import { UserService } from '../user-service/user.service'; import { ActivatedRoute } from '@angular/router'; +import { UserInfo } from '../entities'; +import { InternalUserService } from '../internal-user-service/internal-user.service'; + @Component({ selector: 'app-user-login-success', templateUrl: './user-login-success.component.html', @@ -14,10 +15,10 @@ export class UserLoginSuccessComponent implements OnInit { userInfo: UserInfo; - constructor(private route: ActivatedRoute, private userService: UserService) { } + constructor(private route: ActivatedRoute, private userService: InternalUserService) { } ngOnInit() { - this.userInfo = this.userService.userInfo; + this.userInfo = this.userService.currentUserInfo; this.displayLoginSuccessMessage = this.route.snapshot.paramMap.get('reason') === 'login'; } } diff --git a/Timeline/ClientApp/src/app/user/user-login/user-login.component.ts b/Timeline/ClientApp/src/app/user/user-login/user-login.component.ts index 971d57ce..082f879c 100644 --- a/Timeline/ClientApp/src/app/user/user-login/user-login.component.ts +++ b/Timeline/ClientApp/src/app/user/user-login/user-login.component.ts @@ -1,8 +1,9 @@ import { Component, Output, OnInit, EventEmitter } from '@angular/core'; import { FormGroup, FormControl } from '@angular/forms'; -import { UserService } from '../user-service/user.service'; import { ActivatedRoute } from '@angular/router'; +import { InternalUserService } from '../internal-user-service/internal-user.service'; + export type LoginMessage = 'nologin' | 'invalidlogin' | string; export class LoginEvent { @@ -17,9 +18,9 @@ export class LoginEvent { }) export class UserLoginComponent implements OnInit { - constructor(private route: ActivatedRoute, private userService: UserService) { } + constructor(private route: ActivatedRoute, private userService: InternalUserService) { } - message: string; + message: LoginMessage; form = new FormGroup({ username: new FormControl(''), diff --git a/Timeline/ClientApp/src/app/user/user-service/user.service.spec.ts b/Timeline/ClientApp/src/app/user/user-service/user.service.spec.ts deleted file mode 100644 index 9effe000..00000000 --- a/Timeline/ClientApp/src/app/user/user-service/user.service.spec.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { HttpRequest } from '@angular/common/http'; -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; - -import { UserInfo, UserCredentials } from '../entities'; -import { - UserService, CreateTokenResult, - UserLoginState, TokenValidationRequest, TokenValidationResult -} from './user.service'; - -describe('UserService', () => { - const tokenCreateUrl = '/api/User/CreateToken'; - - const mockUserCredentials: UserCredentials = { - username: 'user', - password: 'user' - }; - - beforeEach(() => TestBed.configureTestingModule({ - imports: [HttpClientTestingModule] - })); - - it('should be created', () => { - const service: UserService = TestBed.get(UserService); - expect(service).toBeTruthy(); - }); - - it('should be nologin at first', () => { - const service: UserService = TestBed.get(UserService); - service.validateUserLoginState().subscribe(result => { - expect(result.state).toBe('nologin'); - }); - }); - - it('login should work well', () => { - const service: UserService = TestBed.get(UserService); - - const mockUserInfo: UserInfo = { - username: 'user', - roles: ['user', 'other'] - }; - - service.tryLogin(mockUserCredentials).subscribe(result => { - expect(result).toEqual(mockUserInfo); - }); - - const httpController = TestBed.get(HttpTestingController) as HttpTestingController; - - httpController.expectOne((request: HttpRequest) => - request.url === tokenCreateUrl && - request.body.username === 'user' && - request.body.password === 'user').flush({ - token: 'test-token', - userInfo: mockUserInfo - }); - - httpController.verify(); - }); - - describe('validateUserLoginState', () => { - let service: UserService; - let httpController: HttpTestingController; - - const mockUserInfo: UserInfo = { - username: 'user', - roles: ['user', 'other'] - }; - - const mockToken = 'mock-token'; - - const tokenValidateRequestMatcher = (req: HttpRequest) => { - return req.url === '/api/User/ValidateToken' && req.body.token === mockToken; - }; - - beforeEach(() => { - service = TestBed.get(UserService); - httpController = TestBed.get(HttpTestingController); - - service.tryLogin(mockUserCredentials).subscribe(); // subscribe to activate login - - httpController.expectOne(tokenCreateUrl).flush({ - token: mockToken, - userInfo: mockUserInfo - }); - }); - - it('success should work well', () => { - service.validateUserLoginState().subscribe((result: UserLoginState) => { - expect(result).toEqual({ - state: 'success', - userInfo: mockUserInfo - }); - }); - - httpController.expectOne(tokenValidateRequestMatcher).flush({ - isValid: true, - userInfo: mockUserInfo - }); - - httpController.verify(); - }); - - it('invalid should work well', () => { - service.validateUserLoginState().subscribe((result: UserLoginState) => { - expect(result).toEqual({ - state: 'invalidlogin' - }); - }); - - httpController.expectOne(tokenValidateRequestMatcher).flush({ - isValid: false - }); - - httpController.verify(); - }); - }); - - // TODO: test on error situations. -}); diff --git a/Timeline/ClientApp/src/app/user/user-service/user.service.ts b/Timeline/ClientApp/src/app/user/user-service/user.service.ts deleted file mode 100644 index e535537d..00000000 --- a/Timeline/ClientApp/src/app/user/user-service/user.service.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { Injectable } from '@angular/core'; -import { HttpClient, HttpErrorResponse } from '@angular/common/http'; -import { Observable, of, throwError } from 'rxjs'; -import { map, catchError, retry } from 'rxjs/operators'; - -import { UserCredentials, UserInfo } from '../entities'; -import { Router } from '@angular/router'; - -export interface CreateTokenResult { - token: string; - userInfo: UserInfo; -} - -export interface TokenValidationRequest { - token: string; -} - -export interface TokenValidationResult { - isValid: boolean; - userInfo?: UserInfo; -} - -export interface UserLoginState { - state: 'nologin' | 'invalidlogin' | 'success'; - userInfo?: UserInfo; -} - -export class BadNetworkException extends Error { - constructor() { - super('Network is bad.'); - } -} - -export class AlreadyLoginException extends Error { - constructor() { - super('There is already a token saved. Please call validateUserLoginState first.'); - } -} - -export class BadCredentialsException extends Error { - constructor() { - super(`Username or password is wrong.`); - } -} - -@Injectable({ - providedIn: 'root' -}) -export class UserService { - - private token: string; - userInfo: UserInfo; - - constructor(private httpClient: HttpClient, private router: Router) { } - - userRouteNavigate(commands: any[]) { - this.router.navigate([{ - outlets: { - user: commands - } - }]); - } - - validateUserLoginState(): Observable { - if (this.token === undefined || this.token === null) { - return of({ state: 'nologin' }); - } - - return this.httpClient.post('/api/User/ValidateToken', { token: this.token }).pipe( - retry(3), - catchError(error => { - console.error('Failed to validate token.'); - return throwError(error); - }), - map(result => { - if (result.isValid) { - this.userInfo = result.userInfo; - return { - state: 'success', - userInfo: result.userInfo - }; - } else { - this.token = null; - this.userInfo = null; - return { - state: 'invalidlogin' - }; - } - }) - ); - } - - tryLogin(credentials: UserCredentials): Observable { - if (this.token) { - return throwError(new AlreadyLoginException()); - } - - return this.httpClient.post('/api/User/CreateToken', credentials).pipe( - catchError((error: HttpErrorResponse) => { - if (error.error instanceof ErrorEvent) { - console.error('An error occurred when login: ' + error.error.message); - return throwError(new BadNetworkException()); - } else if (error.status === 400) { - console.error('An error occurred when login: wrong credentials.'); - return throwError(new BadCredentialsException()); - } else { - console.error('An unknown error occurred when login: ' + error); - return throwError(error); - } - }), - map(result => { - this.token = result.token; - this.userInfo = result.userInfo; - return result.userInfo; - }) - ); - } -} diff --git a/Timeline/ClientApp/src/app/user/user.service.ts b/Timeline/ClientApp/src/app/user/user.service.ts new file mode 100644 index 00000000..e876706c --- /dev/null +++ b/Timeline/ClientApp/src/app/user/user.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@angular/core'; +import { MatDialog } from '@angular/material'; + +import { Observable } from 'rxjs'; + +import { UserInfo } from './entities'; +import { InternalUserService } from './internal-user-service/internal-user.service'; +import { UserDialogComponent } from './user-dialog/user-dialog.component'; + + +/** + * This service provides public api of user module. + */ +@Injectable({ + providedIn: 'root' +}) +export class UserService { + constructor(private dialog: MatDialog, private internalService: InternalUserService) { } + + get currentUserInfo(): UserInfo | null { + return this.internalService.currentUserInfo; + } + + get userInfo$(): Observable { + return this.internalService.userInfo$; + } + + openUserDialog() { + this.dialog.open(UserDialogComponent, { + width: '300px' + }); + } +} -- cgit v1.2.3 From 31199282e1ccf72bb464117ae68668aed91e2530 Mon Sep 17 00:00:00 2001 From: crupest Date: Mon, 11 Mar 2019 00:07:59 +0800 Subject: Write unit tests. --- .../src/app/test-utilities/activated-route.mock.ts | 66 +++++++++++ .../internal-user.service.mock.ts | 5 + .../mock-internal-user-service.ts | 5 - .../ClientApp/src/app/user/mock-activated-route.ts | 43 ------- .../user/user-dialog/user-dialog.component.spec.ts | 2 +- .../user-login-success.component.spec.ts | 40 ++++++- .../user/user-login/user-login.component.spec.ts | 74 +++++++++--- .../app/user/user-login/user-login.component.ts | 6 +- Timeline/ClientApp/src/app/user/user.module.ts | 2 +- .../app/utilities/debounce-click.directive.spec.ts | 124 +++++++++++++++++++++ .../src/app/utilities/debounce-click.directive.ts | 39 +++++++ .../ClientApp/src/app/utilities/utility.module.ts | 11 ++ .../app/utility/debounce-click.directive.spec.ts | 124 --------------------- .../src/app/utility/debounce-click.directive.ts | 39 ------- .../ClientApp/src/app/utility/utility.module.ts | 11 -- 15 files changed, 344 insertions(+), 247 deletions(-) create mode 100644 Timeline/ClientApp/src/app/test-utilities/activated-route.mock.ts create mode 100644 Timeline/ClientApp/src/app/user/internal-user-service/internal-user.service.mock.ts delete mode 100644 Timeline/ClientApp/src/app/user/internal-user-service/mock-internal-user-service.ts delete mode 100644 Timeline/ClientApp/src/app/user/mock-activated-route.ts create mode 100644 Timeline/ClientApp/src/app/utilities/debounce-click.directive.spec.ts create mode 100644 Timeline/ClientApp/src/app/utilities/debounce-click.directive.ts create mode 100644 Timeline/ClientApp/src/app/utilities/utility.module.ts delete mode 100644 Timeline/ClientApp/src/app/utility/debounce-click.directive.spec.ts delete mode 100644 Timeline/ClientApp/src/app/utility/debounce-click.directive.ts delete mode 100644 Timeline/ClientApp/src/app/utility/utility.module.ts (limited to 'Timeline/ClientApp/src/app/user/user-login-success') diff --git a/Timeline/ClientApp/src/app/test-utilities/activated-route.mock.ts b/Timeline/ClientApp/src/app/test-utilities/activated-route.mock.ts new file mode 100644 index 00000000..1743e615 --- /dev/null +++ b/Timeline/ClientApp/src/app/test-utilities/activated-route.mock.ts @@ -0,0 +1,66 @@ +import { ParamMap } from '@angular/router'; + +import { Observable, BehaviorSubject } from 'rxjs'; +import { map } from 'rxjs/operators'; + +export interface ParamMapCreator { [name: string]: string | string[]; } + +export class MockActivatedRouteSnapshot { + + private paramMapInternal: ParamMap; + + constructor({ mockParamMap }: { mockParamMap: ParamMapCreator } = { mockParamMap: {} }) { + this.paramMapInternal = { + keys: Object.keys(mockParamMap), + get(name: string): string | null { + const param = mockParamMap[name]; + if (typeof param === 'string') { + return param; + } else if (param instanceof Array) { + if (param.length === 0) { + return null; + } + return param[0]; + } + return null; + }, + getAll(name: string): string[] { + const param = mockParamMap[name]; + if (typeof param === 'string') { + return [param]; + } else if (param instanceof Array) { + return param; + } + return []; + }, + has(name: string): boolean { + return mockParamMap.hasOwnProperty(name); + } + }; + } + + get paramMap(): ParamMap { + return this.paramMapInternal; + } +} + +export class MockActivatedRoute { + + snapshot$ = new BehaviorSubject(new MockActivatedRouteSnapshot()); + + get paramMap(): Observable { + return this.snapshot$.pipe(map(snapshot => snapshot.paramMap)); + } + + get snapshot(): MockActivatedRouteSnapshot { + return this.snapshot$.value; + } + + pushSnapshot(snapshot: MockActivatedRouteSnapshot) { + this.snapshot$.next(snapshot); + } + + pushSnapshotWithParamMap(mockParamMap: ParamMapCreator) { + this.pushSnapshot(new MockActivatedRouteSnapshot({mockParamMap})); + } +} diff --git a/Timeline/ClientApp/src/app/user/internal-user-service/internal-user.service.mock.ts b/Timeline/ClientApp/src/app/user/internal-user-service/internal-user.service.mock.ts new file mode 100644 index 00000000..f4a85262 --- /dev/null +++ b/Timeline/ClientApp/src/app/user/internal-user-service/internal-user.service.mock.ts @@ -0,0 +1,5 @@ +import { InternalUserService } from './internal-user.service'; + +export function createMockInternalUserService(): jasmine.SpyObj { + return jasmine.createSpyObj('InternalUserService', ['userRouteNavigate', 'refreshAndGetUserState', 'tryLogin']); +} diff --git a/Timeline/ClientApp/src/app/user/internal-user-service/mock-internal-user-service.ts b/Timeline/ClientApp/src/app/user/internal-user-service/mock-internal-user-service.ts deleted file mode 100644 index f4a85262..00000000 --- a/Timeline/ClientApp/src/app/user/internal-user-service/mock-internal-user-service.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { InternalUserService } from './internal-user.service'; - -export function createMockInternalUserService(): jasmine.SpyObj { - return jasmine.createSpyObj('InternalUserService', ['userRouteNavigate', 'refreshAndGetUserState', 'tryLogin']); -} diff --git a/Timeline/ClientApp/src/app/user/mock-activated-route.ts b/Timeline/ClientApp/src/app/user/mock-activated-route.ts deleted file mode 100644 index 9e516e83..00000000 --- a/Timeline/ClientApp/src/app/user/mock-activated-route.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { ParamMap } from '@angular/router'; - -interface MockActivatedRoute { - snapshot: MockActivatedRouteSnapshot; -} - -interface MockActivatedRouteSnapshot { - paramMap: ParamMap; -} - -export function createMockActivatedRoute(mockParamMap: { [name: string]: string | string[] }): MockActivatedRoute { - return { - snapshot: { - paramMap: { - keys: Object.keys(mockParamMap), - get(name: string): string | null { - const param = mockParamMap[name]; - if (typeof param === 'string') { - return param; - } else if (param instanceof Array) { - if (param.length === 0) { - return null; - } - return param[0]; - } - return null; - }, - getAll(name: string): string[] { - const param = mockParamMap[name]; - if (typeof param === 'string') { - return [param]; - } else if (param instanceof Array) { - return param; - } - return []; - }, - has(name: string): boolean { - return mockParamMap.hasOwnProperty(name); - } - } - } - } -} diff --git a/Timeline/ClientApp/src/app/user/user-dialog/user-dialog.component.spec.ts b/Timeline/ClientApp/src/app/user/user-dialog/user-dialog.component.spec.ts index ca7c024d..c56e1ed1 100644 --- a/Timeline/ClientApp/src/app/user/user-dialog/user-dialog.component.spec.ts +++ b/Timeline/ClientApp/src/app/user/user-dialog/user-dialog.component.spec.ts @@ -6,7 +6,7 @@ import { of, Observable } from 'rxjs'; import { delay } from 'rxjs/operators'; import { UserDialogComponent } from './user-dialog.component'; -import { createMockInternalUserService } from '../internal-user-service/mock-internal-user-service'; +import { createMockInternalUserService } from '../internal-user-service/internal-user.service.mock'; import { InternalUserService, UserLoginState } from '../internal-user-service/internal-user.service'; @Component({ diff --git a/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.spec.ts b/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.spec.ts index ba015ae6..1efbb5c7 100644 --- a/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.spec.ts +++ b/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.spec.ts @@ -1,20 +1,39 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { ActivatedRoute } from '@angular/router'; + +import { MockActivatedRoute } from 'src/app/test-utilities/activated-route.mock'; +import { createMockInternalUserService } from '../internal-user-service/internal-user.service.mock'; import { UserLoginSuccessComponent } from './user-login-success.component'; -import { By } from '@angular/platform-browser'; +import { InternalUserService } from '../internal-user-service/internal-user.service'; + describe('UserLoginSuccessComponent', () => { let component: UserLoginSuccessComponent; let fixture: ComponentFixture; + let mockInternalUserService: jasmine.SpyObj; + let mockActivatedRoute: MockActivatedRoute; + const mockUserInfo = { username: 'crupest', roles: ['superman', 'coder'] }; beforeEach(async(() => { + mockInternalUserService = createMockInternalUserService(); + mockActivatedRoute = new MockActivatedRoute(); + + // mock currentUserInfo property. because it only has a getter so cast it to any first. + (mockInternalUserService).currentUserInfo = mockUserInfo; + TestBed.configureTestingModule({ - declarations: [UserLoginSuccessComponent] + declarations: [UserLoginSuccessComponent], + providers: [ + { provide: InternalUserService, useValue: mockInternalUserService }, + { provide: ActivatedRoute, useValue: mockActivatedRoute } + ] }) .compileComponents(); })); @@ -22,18 +41,29 @@ describe('UserLoginSuccessComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(UserLoginSuccessComponent); component = fixture.componentInstance; - component.userInfo = mockUserInfo; - fixture.detectChanges(); }); it('should create', () => { + fixture.detectChanges(); expect(component).toBeTruthy(); }); - it('should work well', () => { + it('user info should work well', () => { + fixture.detectChanges(); + + expect((fixture.debugElement.query(By.css('p.login-success-message')))).toBeFalsy(); + expect((fixture.debugElement.query(By.css('span.username')).nativeElement as HTMLSpanElement).textContent) .toBe(mockUserInfo.username); expect((fixture.debugElement.query(By.css('span.roles')).nativeElement as HTMLSpanElement).textContent) .toBe(mockUserInfo.roles.join(', ')); }); + + it('login success message should display well', () => { + mockActivatedRoute.pushSnapshotWithParamMap({ reason: 'login' }); + + fixture.detectChanges(); + + expect((fixture.debugElement.query(By.css('p.login-success-message')))).toBeTruthy(); + }); }); diff --git a/Timeline/ClientApp/src/app/user/user-login/user-login.component.spec.ts b/Timeline/ClientApp/src/app/user/user-login/user-login.component.spec.ts index 3d431ce7..9c9ee1dc 100644 --- a/Timeline/ClientApp/src/app/user/user-login/user-login.component.spec.ts +++ b/Timeline/ClientApp/src/app/user/user-login/user-login.component.spec.ts @@ -1,28 +1,33 @@ import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { async, ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ReactiveFormsModule } from '@angular/forms'; import { By } from '@angular/platform-browser'; import { ActivatedRoute } from '@angular/router'; -import { createMockInternalUserService } from '../internal-user-service/mock-internal-user-service'; -import { createMockActivatedRoute } from '../mock-activated-route'; -import { UserLoginComponent, LoginEvent } from './user-login.component'; +import { of, throwError } from 'rxjs'; + +import { createMockInternalUserService } from '../internal-user-service/internal-user.service.mock'; +import { MockActivatedRoute } from '../../test-utilities/activated-route.mock'; +import { UserLoginComponent } from './user-login.component'; import { InternalUserService } from '../internal-user-service/internal-user.service'; +import { UserInfo } from '../entities'; describe('UserLoginComponent', () => { let component: UserLoginComponent; let fixture: ComponentFixture; let mockInternalUserService: jasmine.SpyObj; + let mockActivatedRoute: MockActivatedRoute; beforeEach(async(() => { mockInternalUserService = createMockInternalUserService(); + mockActivatedRoute = new MockActivatedRoute(); TestBed.configureTestingModule({ declarations: [UserLoginComponent], providers: [ - {provide: InternalUserService, useValue: mockInternalUserService}, - {provide: ActivatedRoute, useValue:} // TODO: custom route snapshot param later. - ] + { provide: InternalUserService, useValue: mockInternalUserService }, + { provide: ActivatedRoute, useValue: mockActivatedRoute } + ], imports: [ReactiveFormsModule], schemas: [NO_ERRORS_SCHEMA] }) @@ -32,14 +37,16 @@ describe('UserLoginComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(UserLoginComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { + fixture.detectChanges(); expect(component).toBeTruthy(); }); it('reactive form should work well', () => { + fixture.detectChanges(); + const usernameInput = fixture.debugElement.query(By.css('input[type=text]')).nativeElement as HTMLInputElement; const passwordInput = fixture.debugElement.query(By.css('input[type=password]')).nativeElement as HTMLInputElement; @@ -56,16 +63,57 @@ describe('UserLoginComponent', () => { }); }); - it('login event should work well', fakeAsync(() => { - let userCredential: LoginEvent; - component.login.subscribe((e: LoginEvent) => { userCredential = e; }); + it('login should work well', () => { fixture.detectChanges(); + const mockValue = { username: 'user', password: 'user' }; + + mockInternalUserService.tryLogin.withArgs(mockValue).and.returnValue(of({ username: 'user', roles: ['user'] })); + component.form.setValue(mockValue); component.onLoginButtonClick(); - expect(userCredential).toEqual(mockValue); - })); + + expect(mockInternalUserService.tryLogin).toHaveBeenCalledWith(mockValue); + expect(mockInternalUserService.userRouteNavigate).toHaveBeenCalledWith(['success', { reason: 'login' }]); + }); + + describe('message display', () => { + it('nologin reason should display', () => { + mockActivatedRoute.pushSnapshotWithParamMap({ reason: 'nologin' }); + fixture.detectChanges(); + expect(component.message).toBe('nologin'); + expect((fixture.debugElement.query(By.css('p.mat-body')).nativeElement as + HTMLParagraphElement).textContent).toBe('You haven\'t login.'); + }); + + it('invalid login reason should display', () => { + mockActivatedRoute.pushSnapshotWithParamMap({ reason: 'invalidlogin' }); + fixture.detectChanges(); + expect(component.message).toBe('invalidlogin'); + expect((fixture.debugElement.query(By.css('p.mat-body')).nativeElement as + HTMLParagraphElement).textContent).toBe('Your login is no longer valid.'); + }); + + it('custom error message should display', () => { + const customMessage = 'custom message'; + + fixture.detectChanges(); + + const mockValue = { + username: 'user', + password: 'user' + }; + mockInternalUserService.tryLogin.withArgs(mockValue).and.returnValue(throwError(new Error(customMessage))); + component.form.setValue(mockValue); + component.onLoginButtonClick(); + + fixture.detectChanges(); + expect(component.message).toBe(customMessage); + expect((fixture.debugElement.query(By.css('p.mat-body')).nativeElement as + HTMLParagraphElement).textContent).toBe(customMessage); + }); + }); }); diff --git a/Timeline/ClientApp/src/app/user/user-login/user-login.component.ts b/Timeline/ClientApp/src/app/user/user-login/user-login.component.ts index 082f879c..79a788de 100644 --- a/Timeline/ClientApp/src/app/user/user-login/user-login.component.ts +++ b/Timeline/ClientApp/src/app/user/user-login/user-login.component.ts @@ -1,4 +1,4 @@ -import { Component, Output, OnInit, EventEmitter } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { FormGroup, FormControl } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; @@ -6,10 +6,6 @@ import { InternalUserService } from '../internal-user-service/internal-user.serv export type LoginMessage = 'nologin' | 'invalidlogin' | string; -export class LoginEvent { - username: string; - password: string; -} @Component({ selector: 'app-user-login', diff --git a/Timeline/ClientApp/src/app/user/user.module.ts b/Timeline/ClientApp/src/app/user/user.module.ts index 1e70d33d..c399c9e0 100644 --- a/Timeline/ClientApp/src/app/user/user.module.ts +++ b/Timeline/ClientApp/src/app/user/user.module.ts @@ -10,7 +10,7 @@ import { import { UserDialogComponent } from './user-dialog/user-dialog.component'; import { UserLoginComponent } from './user-login/user-login.component'; import { UserLoginSuccessComponent } from './user-login-success/user-login-success.component'; -import { UtilityModule } from '../utility/utility.module'; +import { UtilityModule } from '../utilities/utility.module'; import { RouterModule } from '@angular/router'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; diff --git a/Timeline/ClientApp/src/app/utilities/debounce-click.directive.spec.ts b/Timeline/ClientApp/src/app/utilities/debounce-click.directive.spec.ts new file mode 100644 index 00000000..75710d0c --- /dev/null +++ b/Timeline/ClientApp/src/app/utilities/debounce-click.directive.spec.ts @@ -0,0 +1,124 @@ +import { Component, ViewChild } from '@angular/core'; +import { async, TestBed, ComponentFixture, fakeAsync, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { DebounceClickDirective } from './debounce-click.directive'; + +interface TestComponent { + clickHandler: () => void; +} + +@Component({ + selector: 'app-default-test', + template: '' +}) +class DefaultDebounceTimeTestComponent { + @ViewChild(DebounceClickDirective) + directive: DebounceClickDirective; + + clickHandler: () => void = () => { }; +} + +@Component({ + selector: 'app-default-test', + template: '' +}) +class CustomDebounceTimeTestComponent { + debounceTime: number; + + @ViewChild(DebounceClickDirective) + directive: DebounceClickDirective; + + clickHandler: () => void = () => { }; +} + + +describe('DebounceClickDirective', () => { + let counter: number; + + function initComponent(component: TestComponent) { + component.clickHandler = () => counter++; + } + + beforeEach(() => { + counter = 0; + }); + + describe('default debounce time', () => { + let component: DefaultDebounceTimeTestComponent; + let componentFixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [DebounceClickDirective, DefaultDebounceTimeTestComponent] + }).compileComponents(); + })); + + beforeEach(() => { + componentFixture = TestBed.createComponent(DefaultDebounceTimeTestComponent); + component = componentFixture.componentInstance; + initComponent(component); + }); + + it('should create an instance', () => { + componentFixture.detectChanges(); + expect(component.directive).toBeTruthy(); + }); + + it('should work well', fakeAsync(() => { + function click() { + (componentFixture.debugElement.query(By.css('button')).nativeElement).dispatchEvent(new MouseEvent('click')); + } + componentFixture.detectChanges(); + expect(counter).toBe(0); + click(); + tick(300); + expect(counter).toBe(0); + click(); + tick(); + expect(counter).toBe(0); + tick(500); + expect(counter).toBe(1); + })); + }); + + + describe('custom debounce time', () => { + let component: CustomDebounceTimeTestComponent; + let componentFixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [DebounceClickDirective, CustomDebounceTimeTestComponent] + }).compileComponents(); + })); + + beforeEach(() => { + componentFixture = TestBed.createComponent(CustomDebounceTimeTestComponent); + component = componentFixture.componentInstance; + initComponent(component); + component.debounceTime = 600; + }); + + it('should create an instance', () => { + componentFixture.detectChanges(); + expect(component.directive).toBeTruthy(); + }); + + it('should work well', fakeAsync(() => { + function click() { + (componentFixture.debugElement.query(By.css('button')).nativeElement).dispatchEvent(new MouseEvent('click')); + } + componentFixture.detectChanges(); + expect(counter).toBe(0); + click(); + tick(300); + expect(counter).toBe(0); + click(); + tick(); + expect(counter).toBe(0); + tick(600); + expect(counter).toBe(1); + })); + }); +}); diff --git a/Timeline/ClientApp/src/app/utilities/debounce-click.directive.ts b/Timeline/ClientApp/src/app/utilities/debounce-click.directive.ts new file mode 100644 index 00000000..feb0404e --- /dev/null +++ b/Timeline/ClientApp/src/app/utilities/debounce-click.directive.ts @@ -0,0 +1,39 @@ +import { Directive, Output, Input, EventEmitter, ElementRef, OnInit, OnDestroy } from '@angular/core'; +import { fromEvent, Subscription } from 'rxjs'; +import { debounceTime } from 'rxjs/operators'; + +@Directive({ + selector: '[appDebounceClick]' +}) +export class DebounceClickDirective implements OnInit, OnDestroy { + + private subscription: Subscription; + + @Output('appDebounceClick') clickEvent = new EventEmitter(); + + // tslint:disable-next-line:no-input-rename + @Input('appDebounceClickTime') + set debounceTime(value: number) { + if (this.subscription) { + this.subscription.unsubscribe(); + } + this.subscription = fromEvent(this.element.nativeElement, 'click').pipe( + debounceTime(value) + ).subscribe(o => this.clickEvent.emit(o)); + } + + constructor(private element: ElementRef) { + } + + ngOnInit() { + if (!this.subscription) { + this.subscription = fromEvent(this.element.nativeElement, 'click').pipe( + debounceTime(500) + ).subscribe(o => this.clickEvent.emit(o)); + } + } + + ngOnDestroy() { + this.subscription.unsubscribe(); + } +} diff --git a/Timeline/ClientApp/src/app/utilities/utility.module.ts b/Timeline/ClientApp/src/app/utilities/utility.module.ts new file mode 100644 index 00000000..dd686bf7 --- /dev/null +++ b/Timeline/ClientApp/src/app/utilities/utility.module.ts @@ -0,0 +1,11 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { DebounceClickDirective } from './debounce-click.directive'; + +@NgModule({ + declarations: [DebounceClickDirective], + imports: [CommonModule], + exports: [DebounceClickDirective] +}) +export class UtilityModule { } diff --git a/Timeline/ClientApp/src/app/utility/debounce-click.directive.spec.ts b/Timeline/ClientApp/src/app/utility/debounce-click.directive.spec.ts deleted file mode 100644 index 75710d0c..00000000 --- a/Timeline/ClientApp/src/app/utility/debounce-click.directive.spec.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { Component, ViewChild } from '@angular/core'; -import { async, TestBed, ComponentFixture, fakeAsync, tick } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; - -import { DebounceClickDirective } from './debounce-click.directive'; - -interface TestComponent { - clickHandler: () => void; -} - -@Component({ - selector: 'app-default-test', - template: '' -}) -class DefaultDebounceTimeTestComponent { - @ViewChild(DebounceClickDirective) - directive: DebounceClickDirective; - - clickHandler: () => void = () => { }; -} - -@Component({ - selector: 'app-default-test', - template: '' -}) -class CustomDebounceTimeTestComponent { - debounceTime: number; - - @ViewChild(DebounceClickDirective) - directive: DebounceClickDirective; - - clickHandler: () => void = () => { }; -} - - -describe('DebounceClickDirective', () => { - let counter: number; - - function initComponent(component: TestComponent) { - component.clickHandler = () => counter++; - } - - beforeEach(() => { - counter = 0; - }); - - describe('default debounce time', () => { - let component: DefaultDebounceTimeTestComponent; - let componentFixture: ComponentFixture; - - beforeEach(async(() => { - TestBed.configureTestingModule({ - declarations: [DebounceClickDirective, DefaultDebounceTimeTestComponent] - }).compileComponents(); - })); - - beforeEach(() => { - componentFixture = TestBed.createComponent(DefaultDebounceTimeTestComponent); - component = componentFixture.componentInstance; - initComponent(component); - }); - - it('should create an instance', () => { - componentFixture.detectChanges(); - expect(component.directive).toBeTruthy(); - }); - - it('should work well', fakeAsync(() => { - function click() { - (componentFixture.debugElement.query(By.css('button')).nativeElement).dispatchEvent(new MouseEvent('click')); - } - componentFixture.detectChanges(); - expect(counter).toBe(0); - click(); - tick(300); - expect(counter).toBe(0); - click(); - tick(); - expect(counter).toBe(0); - tick(500); - expect(counter).toBe(1); - })); - }); - - - describe('custom debounce time', () => { - let component: CustomDebounceTimeTestComponent; - let componentFixture: ComponentFixture; - - beforeEach(async(() => { - TestBed.configureTestingModule({ - declarations: [DebounceClickDirective, CustomDebounceTimeTestComponent] - }).compileComponents(); - })); - - beforeEach(() => { - componentFixture = TestBed.createComponent(CustomDebounceTimeTestComponent); - component = componentFixture.componentInstance; - initComponent(component); - component.debounceTime = 600; - }); - - it('should create an instance', () => { - componentFixture.detectChanges(); - expect(component.directive).toBeTruthy(); - }); - - it('should work well', fakeAsync(() => { - function click() { - (componentFixture.debugElement.query(By.css('button')).nativeElement).dispatchEvent(new MouseEvent('click')); - } - componentFixture.detectChanges(); - expect(counter).toBe(0); - click(); - tick(300); - expect(counter).toBe(0); - click(); - tick(); - expect(counter).toBe(0); - tick(600); - expect(counter).toBe(1); - })); - }); -}); diff --git a/Timeline/ClientApp/src/app/utility/debounce-click.directive.ts b/Timeline/ClientApp/src/app/utility/debounce-click.directive.ts deleted file mode 100644 index feb0404e..00000000 --- a/Timeline/ClientApp/src/app/utility/debounce-click.directive.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Directive, Output, Input, EventEmitter, ElementRef, OnInit, OnDestroy } from '@angular/core'; -import { fromEvent, Subscription } from 'rxjs'; -import { debounceTime } from 'rxjs/operators'; - -@Directive({ - selector: '[appDebounceClick]' -}) -export class DebounceClickDirective implements OnInit, OnDestroy { - - private subscription: Subscription; - - @Output('appDebounceClick') clickEvent = new EventEmitter(); - - // tslint:disable-next-line:no-input-rename - @Input('appDebounceClickTime') - set debounceTime(value: number) { - if (this.subscription) { - this.subscription.unsubscribe(); - } - this.subscription = fromEvent(this.element.nativeElement, 'click').pipe( - debounceTime(value) - ).subscribe(o => this.clickEvent.emit(o)); - } - - constructor(private element: ElementRef) { - } - - ngOnInit() { - if (!this.subscription) { - this.subscription = fromEvent(this.element.nativeElement, 'click').pipe( - debounceTime(500) - ).subscribe(o => this.clickEvent.emit(o)); - } - } - - ngOnDestroy() { - this.subscription.unsubscribe(); - } -} diff --git a/Timeline/ClientApp/src/app/utility/utility.module.ts b/Timeline/ClientApp/src/app/utility/utility.module.ts deleted file mode 100644 index dd686bf7..00000000 --- a/Timeline/ClientApp/src/app/utility/utility.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; - -import { DebounceClickDirective } from './debounce-click.directive'; - -@NgModule({ - declarations: [DebounceClickDirective], - imports: [CommonModule], - exports: [DebounceClickDirective] -}) -export class UtilityModule { } -- cgit v1.2.3