diff options
author | 杨宇千 <crupest@outlook.com> | 2019-03-06 23:29:12 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2019-03-06 23:29:12 +0800 |
commit | 4b37c96de2c7d3fe046a6f342d2da8ef03d3c807 (patch) | |
tree | e5618cddfa6f637d7033ae5b52e6da825eb53c63 | |
parent | aca753fba19a221f1aec65030ba4aec4bc34f576 (diff) | |
parent | b5e01c4571061cbaf5915aa4c0f1b7126ef1ed18 (diff) | |
download | timeline-4b37c96de2c7d3fe046a6f342d2da8ef03d3c807.tar.gz timeline-4b37c96de2c7d3fe046a6f342d2da8ef03d3c807.tar.bz2 timeline-4b37c96de2c7d3fe046a6f342d2da8ef03d3c807.zip |
Merge pull request #3 from crupest/user
Develop user dialog.
35 files changed, 850 insertions, 54 deletions
diff --git a/Timeline/ClientApp/.vscode/launch.json b/Timeline/ClientApp/.vscode/launch.json index 96a3c552..73e17a72 100644 --- a/Timeline/ClientApp/.vscode/launch.json +++ b/Timeline/ClientApp/.vscode/launch.json @@ -1,22 +1,15 @@ { - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "type": "chrome", - "request": "launch", - "name": "Launch app", - "url": "https://localhost:5001", - "webRoot": "${workspaceFolder}" - }, - { - "type": "chrome", - "request": "launch", - "name": "Launch test", - "url": "http://localhost:9876", - "webRoot": "${workspaceFolder}" - } - ] + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "chrome", + "request": "launch", + "name": "Launch app", + "url": "https://localhost:5001", + "webRoot": "${workspaceFolder}" + } + ] } diff --git a/Timeline/ClientApp/package.json b/Timeline/ClientApp/package.json index 9d948004..f725a0f5 100644 --- a/Timeline/ClientApp/package.json +++ b/Timeline/ClientApp/package.json @@ -4,6 +4,7 @@ "scripts": { "ng": "ng", "start": "ng serve", + "start-dotnet": "dotnet run --project ..", "build": "ng build", "build:ssr": "ng run Timeline:server:dev", "test": "ng test", diff --git a/Timeline/ClientApp/src/app/app.component.html b/Timeline/ClientApp/src/app/app.component.html index 9d20bc91..a5df80ac 100644 --- a/Timeline/ClientApp/src/app/app.component.html +++ b/Timeline/ClientApp/src/app/app.component.html @@ -4,7 +4,7 @@ <img width="30" height="30" src="assets/icon.svg"> Timeline</a> <a mat-button routerLink="/todo">TodoList</a> <span class="fill-remaining-space"></span> - <button mat-icon-button> + <button mat-icon-button (click)="openUserDialog()"> <mat-icon>account_circle</mat-icon> </button> </mat-toolbar> diff --git a/Timeline/ClientApp/src/app/app.component.ts b/Timeline/ClientApp/src/app/app.component.ts index bba1f59d..0e2a9799 100644 --- a/Timeline/ClientApp/src/app/app.component.ts +++ b/Timeline/ClientApp/src/app/app.component.ts @@ -1,4 +1,6 @@ import { Component } from '@angular/core'; +import { MatDialog } from '@angular/material'; +import { UserDialogComponent } from './user/user-dialog/user-dialog.component'; @Component({ selector: 'app-root', @@ -6,7 +8,12 @@ import { Component } from '@angular/core'; styleUrls: ['./app.component.css'] }) export class AppComponent { - title = 'app'; - public isCollapse = false; + constructor(private dialog: MatDialog) { } + + openUserDialog() { + this.dialog.open(UserDialogComponent, { + width: '300px' + }); + } } diff --git a/Timeline/ClientApp/src/app/app.module.ts b/Timeline/ClientApp/src/app/app.module.ts index 86511be8..d0b6a5c6 100644 --- a/Timeline/ClientApp/src/app/app.module.ts +++ b/Timeline/ClientApp/src/app/app.module.ts @@ -1,37 +1,55 @@ import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; -import { FormsModule } from '@angular/forms'; +import { ReactiveFormsModule } from '@angular/forms'; import { HttpClientModule } from '@angular/common/http'; import { RouterModule } from '@angular/router'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { MatMenuModule, MatIconModule, MatButtonModule, MatToolbarModule, MatListModule, - MatProgressBarModule, MatCardModule + MatProgressBarModule, MatCardModule, MatDialogModule, MatInputModule, MatFormFieldModule, MatProgressSpinnerModule } from '@angular/material'; import { AppComponent } from './app.component'; import { HomeComponent } from './home/home.component'; -import { TodoListPageComponent } from './todo-list-page/todo-list-page.component'; -import { TodoItemComponent } from './todo-item/todo-item.component'; + +import { DebounceClickDirective } from './debounce-click.directive'; + +import { TodoListPageComponent } from './todo/todo-list-page/todo-list-page.component'; +import { TodoItemComponent } from './todo/todo-item/todo-item.component'; + +import { UserDialogComponent } from './user/user-dialog/user-dialog.component'; +import { UserLoginComponent } from './user/user-login/user-login.component'; +import { UserLoginSuccessComponent } from './user/user-login-success/user-login-success.component'; + +const importedMatModules = [ + MatMenuModule, MatIconModule, MatButtonModule, MatToolbarModule, + MatListModule, MatProgressBarModule, MatCardModule, MatDialogModule, + MatInputModule, MatFormFieldModule, MatProgressSpinnerModule +]; @NgModule({ declarations: [ AppComponent, HomeComponent, TodoListPageComponent, - TodoItemComponent + TodoItemComponent, + UserDialogComponent, + DebounceClickDirective, + UserLoginComponent, + UserLoginSuccessComponent ], imports: [ BrowserModule.withServerTransition({ appId: 'ng-cli-universal' }), HttpClientModule, - FormsModule, + ReactiveFormsModule, BrowserAnimationsModule, - MatMenuModule, MatIconModule, MatButtonModule, MatToolbarModule, MatListModule, MatProgressBarModule, MatCardModule, + ...importedMatModules, RouterModule.forRoot([ { path: '', component: HomeComponent, pathMatch: 'full' }, { path: 'todo', component: TodoListPageComponent } ]) ], + entryComponents: [UserDialogComponent], providers: [], bootstrap: [AppComponent] }) diff --git a/Timeline/ClientApp/src/app/debounce-click.directive.spec.ts b/Timeline/ClientApp/src/app/debounce-click.directive.spec.ts new file mode 100644 index 00000000..75710d0c --- /dev/null +++ b/Timeline/ClientApp/src/app/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: '<button (appDebounceClick)="clickHandler()"></button>' +}) +class DefaultDebounceTimeTestComponent { + @ViewChild(DebounceClickDirective) + directive: DebounceClickDirective; + + clickHandler: () => void = () => { }; +} + +@Component({ + selector: 'app-default-test', + template: '<button (appDebounceClick)="clickHandler()" [appDebounceClickTime]="debounceTime"></button>' +}) +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<DefaultDebounceTimeTestComponent>; + + 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() { + (<HTMLButtonElement>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<CustomDebounceTimeTestComponent>; + + 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() { + (<HTMLButtonElement>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/debounce-click.directive.ts b/Timeline/ClientApp/src/app/debounce-click.directive.ts new file mode 100644 index 00000000..feb0404e --- /dev/null +++ b/Timeline/ClientApp/src/app/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<any>(); + + // tslint:disable-next-line:no-input-rename + @Input('appDebounceClickTime') + set debounceTime(value: number) { + if (this.subscription) { + this.subscription.unsubscribe(); + } + this.subscription = fromEvent(<HTMLElement>this.element.nativeElement, 'click').pipe( + debounceTime(value) + ).subscribe(o => this.clickEvent.emit(o)); + } + + constructor(private element: ElementRef) { + } + + ngOnInit() { + if (!this.subscription) { + this.subscription = fromEvent(<HTMLElement>this.element.nativeElement, 'click').pipe( + debounceTime(500) + ).subscribe(o => this.clickEvent.emit(o)); + } + } + + ngOnDestroy() { + this.subscription.unsubscribe(); + } +} diff --git a/Timeline/ClientApp/src/app/todo/todo-item.ts b/Timeline/ClientApp/src/app/todo/todo-item.ts new file mode 100644 index 00000000..b19d8335 --- /dev/null +++ b/Timeline/ClientApp/src/app/todo/todo-item.ts @@ -0,0 +1,6 @@ +export interface TodoItem { + number: number; + title: string; + isClosed: boolean; + detailUrl: string; +} diff --git a/Timeline/ClientApp/src/app/todo-item/todo-item.component.css b/Timeline/ClientApp/src/app/todo/todo-item/todo-item.component.css index dcf25fd8..dcf25fd8 100644 --- a/Timeline/ClientApp/src/app/todo-item/todo-item.component.css +++ b/Timeline/ClientApp/src/app/todo/todo-item/todo-item.component.css diff --git a/Timeline/ClientApp/src/app/todo-item/todo-item.component.html b/Timeline/ClientApp/src/app/todo/todo-item/todo-item.component.html index 6f76e73b..6f76e73b 100644 --- a/Timeline/ClientApp/src/app/todo-item/todo-item.component.html +++ b/Timeline/ClientApp/src/app/todo/todo-item/todo-item.component.html diff --git a/Timeline/ClientApp/src/app/todo-item/todo-item.component.spec.ts b/Timeline/ClientApp/src/app/todo/todo-item/todo-item.component.spec.ts index 520b6136..239ffc42 100644 --- a/Timeline/ClientApp/src/app/todo-item/todo-item.component.spec.ts +++ b/Timeline/ClientApp/src/app/todo/todo-item/todo-item.component.spec.ts @@ -1,9 +1,9 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; - -import { TodoItemComponent } from './todo-item.component'; -import { TodoItem } from '../todo-list-page/todo-list.service'; import { By } from '@angular/platform-browser'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; + +import { TodoItem } from '../todo-item'; +import { TodoItemComponent } from '../todo-item/todo-item.component'; describe('TodoItemComponent', () => { let component: TodoItemComponent; diff --git a/Timeline/ClientApp/src/app/todo-item/todo-item.component.ts b/Timeline/ClientApp/src/app/todo/todo-item/todo-item.component.ts index 325812f1..2ea6997a 100644 --- a/Timeline/ClientApp/src/app/todo-item/todo-item.component.ts +++ b/Timeline/ClientApp/src/app/todo/todo-item/todo-item.component.ts @@ -1,10 +1,10 @@ import { Component, Input } from '@angular/core'; -import { TodoItem } from '../todo-list-page/todo-list.service'; +import { TodoItem } from '../todo-item'; @Component({ selector: 'app-todo-item', templateUrl: './todo-item.component.html', - styleUrls: ['./todo-item.component.css', '../todo-list-page/todo-list-color-block.css'] + styleUrls: ['./todo-item.component.css', '../todo-list-color-block.css'] }) export class TodoItemComponent { diff --git a/Timeline/ClientApp/src/app/todo-list-page/todo-list-color-block.css b/Timeline/ClientApp/src/app/todo/todo-list-color-block.css index 5e0d4ba9..5e0d4ba9 100644 --- a/Timeline/ClientApp/src/app/todo-list-page/todo-list-color-block.css +++ b/Timeline/ClientApp/src/app/todo/todo-list-color-block.css diff --git a/Timeline/ClientApp/src/app/todo-list-page/todo-list-page.component.css b/Timeline/ClientApp/src/app/todo/todo-list-page/todo-list-page.component.css index 754b786e..754b786e 100644 --- a/Timeline/ClientApp/src/app/todo-list-page/todo-list-page.component.css +++ b/Timeline/ClientApp/src/app/todo/todo-list-page/todo-list-page.component.css diff --git a/Timeline/ClientApp/src/app/todo-list-page/todo-list-page.component.html b/Timeline/ClientApp/src/app/todo/todo-list-page/todo-list-page.component.html index 50180fe8..50180fe8 100644 --- a/Timeline/ClientApp/src/app/todo-list-page/todo-list-page.component.html +++ b/Timeline/ClientApp/src/app/todo/todo-list-page/todo-list-page.component.html diff --git a/Timeline/ClientApp/src/app/todo-list-page/todo-list-page.component.spec.ts b/Timeline/ClientApp/src/app/todo/todo-list-page/todo-list-page.component.spec.ts index 5706bf51..0af113dc 100644 --- a/Timeline/ClientApp/src/app/todo-list-page/todo-list-page.component.spec.ts +++ b/Timeline/ClientApp/src/app/todo/todo-list-page/todo-list-page.component.spec.ts @@ -1,20 +1,22 @@ import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; import { async, ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { Observable, from } from 'rxjs'; +import { delay } from 'rxjs/operators'; +import { TodoItem } from '../todo-item'; import { TodoListPageComponent } from './todo-list-page.component'; -import { TodoListService, TodoItem } from './todo-list.service'; -import { By } from '@angular/platform-browser'; -import { delay } from 'rxjs/operators'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TodoListService } from '../todo-service/todo-list.service'; + @Component({ /* tslint:disable-next-line:component-selector*/ selector: 'mat-progress-bar', template: '' }) -class MatProgressBarStubComponent {} +class MatProgressBarStubComponent { } function asyncArray<T>(data: T[]): Observable<T> { return from(data).pipe(delay(0)); diff --git a/Timeline/ClientApp/src/app/todo-list-page/todo-list-page.component.ts b/Timeline/ClientApp/src/app/todo/todo-list-page/todo-list-page.component.ts index c62dd808..a69c6856 100644 --- a/Timeline/ClientApp/src/app/todo-list-page/todo-list-page.component.ts +++ b/Timeline/ClientApp/src/app/todo/todo-list-page/todo-list-page.component.ts @@ -1,11 +1,12 @@ import { Component, OnInit } from '@angular/core'; -import { TodoListService, TodoItem } from './todo-list.service'; +import { TodoItem } from '../todo-item'; +import { TodoListService } from '../todo-service/todo-list.service'; import { trigger, transition, style, animate } from '@angular/animations'; @Component({ selector: 'app-todo-list-page', templateUrl: './todo-list-page.component.html', - styleUrls: ['./todo-list-page.component.css', './todo-list-color-block.css'], + styleUrls: ['./todo-list-page.component.css', '../todo-list-color-block.css'], animations: [ trigger('itemEnter', [ transition(':enter', [ diff --git a/Timeline/ClientApp/src/app/todo-list-page/todo-list.service.spec.ts b/Timeline/ClientApp/src/app/todo/todo-service/todo-list.service.spec.ts index a2ad0cbd..d8283b54 100644 --- a/Timeline/ClientApp/src/app/todo-list-page/todo-list.service.spec.ts +++ b/Timeline/ClientApp/src/app/todo/todo-service/todo-list.service.spec.ts @@ -1,11 +1,10 @@ import { TestBed } from '@angular/core/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; - -import { - TodoListService, IssueResponse, IssueResponseItem, TodoItem -} from './todo-list.service'; import { toArray } from 'rxjs/operators'; +import { TodoItem } from '../todo-item'; +import { TodoListService, IssueResponse } from './todo-list.service'; + describe('TodoListServiceService', () => { beforeEach(() => TestBed.configureTestingModule({ diff --git a/Timeline/ClientApp/src/app/todo-list-page/todo-list.service.ts b/Timeline/ClientApp/src/app/todo/todo-service/todo-list.service.ts index ffcbbc6f..83bf47ec 100644 --- a/Timeline/ClientApp/src/app/todo-list-page/todo-list.service.ts +++ b/Timeline/ClientApp/src/app/todo/todo-service/todo-list.service.ts @@ -3,6 +3,8 @@ import { HttpClient } from '@angular/common/http'; import { Observable, from } from 'rxjs'; import { switchMap, map, filter } from 'rxjs/operators'; +import {TodoItem} from '../todo-item'; + export interface IssueResponseItem { number: number; title: string; @@ -13,13 +15,6 @@ export interface IssueResponseItem { export type IssueResponse = IssueResponseItem[]; -export interface TodoItem { - number: number; - title: string; - isClosed: boolean; - detailUrl: string; -} - @Injectable({ providedIn: 'root' }) diff --git a/Timeline/ClientApp/src/app/user/user-dialog/user-dialog.component.css b/Timeline/ClientApp/src/app/user/user-dialog/user-dialog.component.css new file mode 100644 index 00000000..a443e3c0 --- /dev/null +++ b/Timeline/ClientApp/src/app/user/user-dialog/user-dialog.component.css @@ -0,0 +1,5 @@ +.container { + display: flex; + justify-content: center; + align-content: center; +} diff --git a/Timeline/ClientApp/src/app/user/user-dialog/user-dialog.component.html b/Timeline/ClientApp/src/app/user/user-dialog/user-dialog.component.html new file mode 100644 index 00000000..50d6ba56 --- /dev/null +++ b/Timeline/ClientApp/src/app/user/user-dialog/user-dialog.component.html @@ -0,0 +1,5 @@ +<div [ngSwitch]="state" class="container"> + <mat-progress-spinner *ngSwitchCase="'loading'" mode="indeterminate" diameter="50"></mat-progress-spinner> + <app-user-login *ngSwitchCase="'login'" (login)="login($event)" [message]="loginMessage"></app-user-login> + <app-user-login-success *ngSwitchCase="'success'" [userInfo]="userInfo" [displayLoginSuccessMessage]="displayLoginSuccessMessage"></app-user-login-success> +</div> 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 new file mode 100644 index 00000000..d24c0cd2 --- /dev/null +++ b/Timeline/ClientApp/src/app/user/user-dialog/user-dialog.component.spec.ts @@ -0,0 +1,114 @@ +import { Component, Output, EventEmitter } from '@angular/core'; +import { async, ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { of } from 'rxjs'; +import { delay } from 'rxjs/operators'; + +import { UserInfo } from '../user-info'; +import { UserDialogComponent } from './user-dialog.component'; +import { UserService, UserLoginState } from '../user-service/user.service'; +import { LoginEvent } from '../user-login/user-login.component'; + +@Component({ + /* tslint:disable-next-line:component-selector*/ + selector: 'mat-progress-spinner', + template: '' +}) +class MatProgressSpinnerStubComponent { } + +@Component({ + selector: 'app-user-login', + /* tslint:disable-next-line:use-input-property-decorator*/ + inputs: ['message'], + template: '' +}) +class UserLoginStubComponent { + @Output() + login = new EventEmitter<LoginEvent>(); +} + +@Component({ + selector: 'app-user-login-success', + /* tslint:disable-next-line:use-input-property-decorator*/ + inputs: ['userInfo', 'displayLoginSuccessMessage'], + template: '' +}) +class UserLoginSuccessStubComponent { } + +describe('UserDialogComponent', () => { + let component: UserDialogComponent; + let fixture: ComponentFixture<UserDialogComponent>; + let mockUserService: jasmine.SpyObj<UserService>; + + beforeEach(async(() => { + mockUserService = jasmine.createSpyObj('UserService', ['validateUserLoginState', 'tryLogin']); + + TestBed.configureTestingModule({ + declarations: [UserDialogComponent, MatProgressSpinnerStubComponent, + UserLoginStubComponent, UserLoginSuccessStubComponent], + providers: [{ provide: UserService, useValue: mockUserService }] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(UserDialogComponent); + component = fixture.componentInstance; + }); + + it('progress spinner should work well', fakeAsync(() => { + mockUserService.validateUserLoginState.and.returnValue(of(<UserLoginState>{ state: 'nologin' }).pipe(delay(10))); + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('mat-progress-spinner'))).toBeTruthy(); + tick(10); + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('mat-progress-spinner'))).toBeFalsy(); + })); + + it('nologin should work well', () => { + mockUserService.validateUserLoginState.and.returnValue(of(<UserLoginState>{ state: 'nologin' })); + + fixture.detectChanges(); + + expect(mockUserService.validateUserLoginState).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(<UserLoginState>{ state: 'success', userInfo: {} })); + + fixture.detectChanges(); + + expect(mockUserService.validateUserLoginState).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(<UserLoginState>{ state: 'nologin' })); + + fixture.detectChanges(); + expect(mockUserService.validateUserLoginState).toHaveBeenCalled(); + expect(fixture.debugElement.query(By.css('app-user-login'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('app-user-login-success'))).toBeFalsy(); + + mockUserService.tryLogin.withArgs('user', 'user').and.returnValue(of(<UserInfo>{ + username: 'user', + roles: ['user'] + })); + + (fixture.debugElement.query(By.css('app-user-login')).componentInstance as + UserLoginStubComponent).login.emit(<LoginEvent>{ + username: 'user', + password: 'user' + }); + + fixture.detectChanges(); + + expect(mockUserService.tryLogin).toHaveBeenCalledWith('user', 'user'); + + expect(fixture.debugElement.query(By.css('app-user-login'))).toBeFalsy(); + expect(fixture.debugElement.query(By.css('app-user-login-success'))).toBeTruthy(); + }); +}); 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 new file mode 100644 index 00000000..7511de16 --- /dev/null +++ b/Timeline/ClientApp/src/app/user/user-dialog/user-dialog.component.ts @@ -0,0 +1,43 @@ +import { Component, OnInit } from '@angular/core'; +import { UserInfo } from '../user-info'; +import { UserService } from '../user-service/user.service'; +import { LoginEvent, LoginMessage } from '../user-login/user-login.component'; + +@Component({ + selector: 'app-user-dialog', + templateUrl: './user-dialog.component.html', + styleUrls: ['./user-dialog.component.css'] +}) +export class UserDialogComponent implements OnInit { + + constructor(private userService: UserService) { } + + state: 'loading' | 'login' | 'success' = 'loading'; + + loginMessage: LoginMessage; + + displayLoginSuccessMessage = false; + userInfo: UserInfo; + + ngOnInit() { + this.userService.validateUserLoginState().subscribe(result => { + if (result.state === 'success') { + this.userInfo = result.userInfo; + this.state = 'success'; + } else { + this.loginMessage = result.state; + this.state = 'login'; + } + }); + } + + login(event: LoginEvent) { + this.userService.tryLogin(event.username, event.password).subscribe(result => { + this.userInfo = result; + this.displayLoginSuccessMessage = true; + this.state = 'success'; + }, (error: Error) => { + this.loginMessage = error.message; + }); + } +} diff --git a/Timeline/ClientApp/src/app/user/user-info.ts b/Timeline/ClientApp/src/app/user/user-info.ts new file mode 100644 index 00000000..490b00ba --- /dev/null +++ b/Timeline/ClientApp/src/app/user/user-info.ts @@ -0,0 +1,4 @@ +export interface UserInfo { + username: string; + roles: string[]; +} diff --git a/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.css b/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.css new file mode 100644 index 00000000..6486142b --- /dev/null +++ b/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.css @@ -0,0 +1,7 @@ +.login-success-message { + color: green; +} + +.username { + color: blue; +} diff --git a/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.html b/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.html new file mode 100644 index 00000000..e156f0f8 --- /dev/null +++ b/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.html @@ -0,0 +1,5 @@ +<p *ngIf="displayLoginSuccessMessage" class="mat-body login-success-message"> + Login succeeds! +</p> +<p class="mat-body">You have been login as <span class="username">{{ userInfo.username }}</span>.</p> +<p class="mat-body">Your roles are <span class="roles">{{ userInfo.roles.join(', ') }}</span>.</p> 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 new file mode 100644 index 00000000..ba015ae6 --- /dev/null +++ b/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.spec.ts @@ -0,0 +1,39 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UserLoginSuccessComponent } from './user-login-success.component'; +import { By } from '@angular/platform-browser'; + +describe('UserLoginSuccessComponent', () => { + let component: UserLoginSuccessComponent; + let fixture: ComponentFixture<UserLoginSuccessComponent>; + + const mockUserInfo = { + username: 'crupest', + roles: ['superman', 'coder'] + }; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [UserLoginSuccessComponent] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(UserLoginSuccessComponent); + component = fixture.componentInstance; + component.userInfo = mockUserInfo; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should work well', () => { + 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(', ')); + }); +}); 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 new file mode 100644 index 00000000..99de5970 --- /dev/null +++ b/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.ts @@ -0,0 +1,22 @@ +import { Component, OnInit, Input } from '@angular/core'; +import { UserInfo } from '../user-info'; + +@Component({ + selector: 'app-user-login-success', + templateUrl: './user-login-success.component.html', + styleUrls: ['./user-login-success.component.css'] +}) +export class UserLoginSuccessComponent implements OnInit { + + @Input() + displayLoginSuccessMessage = false; + + @Input() + userInfo: UserInfo; + + constructor() { } + + ngOnInit() { + } + +} diff --git a/Timeline/ClientApp/src/app/user/user-login/user-login.component.css b/Timeline/ClientApp/src/app/user/user-login/user-login.component.css new file mode 100644 index 00000000..8bf6b408 --- /dev/null +++ b/Timeline/ClientApp/src/app/user/user-login/user-login.component.css @@ -0,0 +1,24 @@ +form { + display: flex; + flex-wrap: wrap; +} + +div.w-100 { + width: 100%; +} + +.login-button { + margin-left: auto; +} + +.no-login-message { + color: blue; +} + +.invalid-login-message { + color: red; +} + +.error-message { + color: red; +} diff --git a/Timeline/ClientApp/src/app/user/user-login/user-login.component.html b/Timeline/ClientApp/src/app/user/user-login/user-login.component.html new file mode 100644 index 00000000..b1dd289d --- /dev/null +++ b/Timeline/ClientApp/src/app/user/user-login/user-login.component.html @@ -0,0 +1,18 @@ +<form [formGroup]="form"> + <ng-container *ngIf="message" [ngSwitch]="message"> + <p *ngSwitchCase="'nologin'" class="mat-body no-login-message">You haven't login.</p> + <p *ngSwitchCase="'invalidlogin'" class="mat-body invalid-login-message">Your login is no longer valid.</p> + <p *ngSwitchDefault class="mat-body error-message">{{ message }}</p> + </ng-container> + <mat-form-field> + <mat-label>Username</mat-label> + <input formControlName="username" matInput type="text" /> + </mat-form-field> + <div class="w-100"></div> + <mat-form-field> + <mat-label>Password</mat-label> + <input formControlName="password" matInput type="password" /> + </mat-form-field> + <div class="w-100"></div> + <button mat-flat-button class="login-button" (appDebounceClick)="onLoginButtonClick()">Login</button> +</form> 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 new file mode 100644 index 00000000..acd13721 --- /dev/null +++ b/Timeline/ClientApp/src/app/user/user-login/user-login.component.spec.ts @@ -0,0 +1,60 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; + +import { UserLoginComponent, LoginEvent } from './user-login.component'; + +describe('UserLoginComponent', () => { + let component: UserLoginComponent; + let fixture: ComponentFixture<UserLoginComponent>; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [UserLoginComponent], + imports: [ReactiveFormsModule], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(UserLoginComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('reactive form should work well', () => { + 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; + + usernameInput.value = 'user'; + usernameInput.dispatchEvent(new Event('input')); + passwordInput.value = 'user'; + passwordInput.dispatchEvent(new Event('input')); + + fixture.detectChanges(); + + expect(component.form.value).toEqual({ + username: 'user', + password: 'user' + }); + }); + + it('login event should work well', fakeAsync(() => { + let userCredential: LoginEvent; + component.login.subscribe((e: LoginEvent) => { userCredential = e; }); + fixture.detectChanges(); + const mockValue = { + username: 'user', + password: 'user' + }; + component.form.setValue(mockValue); + component.onLoginButtonClick(); + expect(userCredential).toEqual(mockValue); + })); +}); 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 new file mode 100644 index 00000000..da642cb8 --- /dev/null +++ b/Timeline/ClientApp/src/app/user/user-login/user-login.component.ts @@ -0,0 +1,32 @@ +import { Component, Output, OnInit, EventEmitter, Input } from '@angular/core'; +import { FormGroup, FormControl } from '@angular/forms'; + +export type LoginMessage = 'nologin' | 'invalidlogin' | string; + +export class LoginEvent { + username: string; + password: string; +} + +@Component({ + selector: 'app-user-login', + templateUrl: './user-login.component.html', + styleUrls: ['./user-login.component.css'] +}) +export class UserLoginComponent { + + @Input() + message: LoginMessage; + + @Output() + login = new EventEmitter<LoginEvent>(); + + form = new FormGroup({ + username: new FormControl(''), + password: new FormControl('') + }); + + onLoginButtonClick() { + this.login.emit(this.form.value); + } +} 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 new file mode 100644 index 00000000..0095f031 --- /dev/null +++ b/Timeline/ClientApp/src/app/user/user-service/user.service.spec.ts @@ -0,0 +1,114 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpRequest } from '@angular/common/http'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + +import { UserInfo } from '../user-info'; +import { + UserService, UserCredentials, CreateTokenResult, + UserLoginState, TokenValidationRequest, TokenValidationResult +} from './user.service'; + +describe('UserService', () => { + const tokenCreateUrl = '/api/User/CreateToken'; + + 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('user', 'user').subscribe(result => { + expect(result).toEqual(mockUserInfo); + }); + + const httpController = TestBed.get(HttpTestingController) as HttpTestingController; + + httpController.expectOne((request: HttpRequest<UserCredentials>) => + request.url === tokenCreateUrl && + request.body.username === 'user' && + request.body.password === 'user').flush(<CreateTokenResult>{ + 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<TokenValidationRequest>) => { + return req.url === '/api/User/ValidateToken' && req.body.token === mockToken; + }; + + beforeEach(() => { + service = TestBed.get(UserService); + httpController = TestBed.get(HttpTestingController); + + service.tryLogin('user', 'user').subscribe(); // subscribe to activate login + + httpController.expectOne(tokenCreateUrl).flush(<CreateTokenResult>{ + token: mockToken, + userInfo: mockUserInfo + }); + }); + + it('success should work well', () => { + service.validateUserLoginState().subscribe((result: UserLoginState) => { + expect(result).toEqual(<UserLoginState>{ + state: 'success', + userInfo: mockUserInfo + }); + }); + + httpController.expectOne(tokenValidateRequestMatcher).flush(<TokenValidationResult>{ + isValid: true, + userInfo: mockUserInfo + }); + + httpController.verify(); + }); + + it('invalid should work well', () => { + service.validateUserLoginState().subscribe((result: UserLoginState) => { + expect(result).toEqual(<UserLoginState>{ + state: 'invalidlogin' + }); + }); + + httpController.expectOne(tokenValidateRequestMatcher).flush(<TokenValidationResult>{ + 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 new file mode 100644 index 00000000..009e5292 --- /dev/null +++ b/Timeline/ClientApp/src/app/user/user-service/user.service.ts @@ -0,0 +1,116 @@ +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 { UserInfo } from '../user-info'; + +export interface UserCredentials { + username: string; + password: string; +} + +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; + private userInfo: UserInfo; + + constructor(private httpClient: HttpClient) { } + + validateUserLoginState(): Observable<UserLoginState> { + if (this.token === undefined || this.token === null) { + return of(<UserLoginState>{ state: 'nologin' }); + } + + return this.httpClient.post<TokenValidationResult>('/api/User/ValidateToken', <TokenValidationRequest>{ 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 <UserLoginState>{ + state: 'success', + userInfo: result.userInfo + }; + } else { + this.token = null; + this.userInfo = null; + return <UserLoginState>{ + state: 'invalidlogin' + }; + } + }) + ); + } + + tryLogin(username: string, password: string): Observable<UserInfo> { + if (this.token) { + return throwError(new AlreadyLoginException()); + } + + return this.httpClient.post<CreateTokenResult>('/api/User/CreateToken', <UserCredentials>{ + username, password + }).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/azure-pipelines.yml b/azure-pipelines.yml index 4bbf12af..1cdf1c3a 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -3,6 +3,9 @@ # Add steps that run tests, create a NuGet package, deploy, and more: # https://docs.microsoft.com/azure/devops/pipelines/languages/dotnet-core +trigger: +- master + pool: vmImage: 'Ubuntu-16.04' |