diff --git a/karma.conf.js b/karma.conf.js index ecf8659..f390a6d 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -8,12 +8,16 @@ module.exports = function (config) { plugins: [ require('karma-jasmine'), require('karma-chrome-launcher'), + require('karma-firefox-launcher'), require('karma-jasmine-html-reporter'), require('karma-coverage-istanbul-reporter'), require('@angular-devkit/build-angular/plugins/karma') ], client: { - clearContext: false // leave Jasmine Spec Runner output visible in browser + clearContext: false, // leave Jasmine Spec Runner output visible in browser + jasmine: { + random: false + } }, coverageIstanbulReporter: { dir: require('path').join(__dirname, './coverage/fe-test-app'), @@ -25,7 +29,7 @@ module.exports = function (config) { colors: true, logLevel: config.LOG_INFO, autoWatch: true, - browsers: ['Chrome'], + browsers: ['Firefox'], singleRun: false, restartOnFileChange: true }); diff --git a/package-lock.json b/package-lock.json index 4b7e90c..8c7f895 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "requires": true, "packages": { "": { + "name": "fe-test-app", "version": "1.0.0", "dependencies": { "@angular/animations": "~8.2.14", @@ -43,6 +44,7 @@ "karma": "~5.0.0", "karma-chrome-launcher": "~3.1.0", "karma-coverage-istanbul-reporter": "~2.0.1", + "karma-firefox-launcher": "^2.1.2", "karma-jasmine": "~3.3.0", "karma-jasmine-html-reporter": "^1.6.0", "path-browserify": "^1.0.1", @@ -6640,6 +6642,21 @@ "node": ">=0.10.0" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extendable": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", @@ -7441,6 +7458,43 @@ "minimatch": "^3.0.4" } }, + "node_modules/karma-firefox-launcher": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/karma-firefox-launcher/-/karma-firefox-launcher-2.1.2.tgz", + "integrity": "sha512-VV9xDQU1QIboTrjtGVD4NCfzIH7n01ZXqy/qpBhnOeGVOkG5JYPEm8kuSd7psHE6WouZaQ9Ool92g8LFweSNMA==", + "dev": true, + "dependencies": { + "is-wsl": "^2.2.0", + "which": "^2.0.1" + } + }, + "node_modules/karma-firefox-launcher/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/karma-firefox-launcher/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/karma-jasmine": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-3.3.1.tgz", @@ -20537,6 +20591,12 @@ "integrity": "sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE=", "dev": true }, + "is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true + }, "is-extendable": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", @@ -21464,6 +21524,36 @@ "minimatch": "^3.0.4" } }, + "karma-firefox-launcher": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/karma-firefox-launcher/-/karma-firefox-launcher-2.1.2.tgz", + "integrity": "sha512-VV9xDQU1QIboTrjtGVD4NCfzIH7n01ZXqy/qpBhnOeGVOkG5JYPEm8kuSd7psHE6WouZaQ9Ool92g8LFweSNMA==", + "dev": true, + "requires": { + "is-wsl": "^2.2.0", + "which": "^2.0.1" + }, + "dependencies": { + "is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "requires": { + "is-docker": "^2.0.0" + } + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, "karma-jasmine": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-3.3.1.tgz", diff --git a/package.json b/package.json index 1d24dcd..292e0ff 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "karma": "~5.0.0", "karma-chrome-launcher": "~3.1.0", "karma-coverage-istanbul-reporter": "~2.0.1", + "karma-firefox-launcher": "^2.1.2", "karma-jasmine": "~3.3.0", "karma-jasmine-html-reporter": "^1.6.0", "path-browserify": "^1.0.1", diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 06c7342..d606e9a 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -1,8 +1,22 @@ import { NgModule } from '@angular/core'; -import { Routes, RouterModule } from '@angular/router'; +import { RouterModule, Routes } from '@angular/router'; +import { CreateUserComponent } from './components/create-user/create-user.component'; +import { UsersTableComponent } from './components/users-table/users-table.component'; +import { APP_ROUTES } from './enums/app-routes.enum'; -const routes: Routes = []; +const routes: Routes = [ + { + path: APP_ROUTES.USERS, + component: UsersTableComponent, + }, + { + path: APP_ROUTES.CREATE_USER, + component: CreateUserComponent, + pathMatch: 'full' + }, + { path: '**', redirectTo: APP_ROUTES.USERS, pathMatch: 'full' } +]; @NgModule({ imports: [RouterModule.forRoot(routes)], diff --git a/src/app/app.component.html b/src/app/app.component.html index 90c6b64..b2d0dfa 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1 +1,3 @@ - \ No newline at end of file +{{'APP.TITLE'}} + + diff --git a/src/app/app.component.scss b/src/app/app.component.scss index e69de29..e679d90 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -0,0 +1,3 @@ +mat-progress-bar { + position: absolute; +} diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index caa6302..1c2d85a 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -1,16 +1,36 @@ -import { TestBed, async } from '@angular/core/testing'; +import { async, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { MatProgressBarModule, MatToolbarModule } from '@angular/material'; import { RouterTestingModule } from '@angular/router/testing'; +import { of, BehaviorSubject } from 'rxjs'; import { AppComponent } from './app.component'; +import { LoadingService } from './services/loading.service'; describe('AppComponent', () => { + + let _loading = new BehaviorSubject(false); beforeEach(async(() => { + const mockLoadingService = jasmine.createSpyObj('LoadingService', ['isLoading', 'setLoading', 'loading']); + + mockLoadingService.isLoading.and.callFake(function(){ + return _loading; + }); + mockLoadingService.setLoading.and.callFake(function(loading:boolean){ + _loading.next(loading); + }); + mockLoadingService.loading.and.callFake(function(){ + return _loading.asObservable(); + }); + TestBed.configureTestingModule({ imports: [ - RouterTestingModule + RouterTestingModule, + MatToolbarModule, + MatProgressBarModule ], declarations: [ AppComponent ], + providers: [{ provide: LoadingService, useValue: mockLoadingService }] }).compileComponents(); })); @@ -19,4 +39,36 @@ describe('AppComponent', () => { const app = fixture.debugElement.componentInstance; expect(app).toBeTruthy(); }); + + it('should subscribe to loading events', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.debugElement.componentInstance; + app.ngOnInit(); + + expect(app.subscriptions).toBeTruthy(); + expect(app.subscriptions.length).toBeGreaterThan(0); + expect(app.subscriptions[0].closed).toBeFalsy(); + }); + + it('it should update loading value', (fakeAsync(() => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.debugElement.componentInstance; + const service = fixture.debugElement.injector.get(LoadingService); + app.ngOnInit(); + + expect(app.isLoading).toBe(false); + service.setLoading(true); + tick(150); + expect(app.isLoading).toBe(true); + }))); + + it('it should unsubscribe on destroy', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.debugElement.componentInstance; + app.ngOnInit(); + + expect(app.subscriptions).toBeTruthy(); + app.ngOnDestroy(); + expect(app.subscriptions[0].closed).toBeTruthy(); + }); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 3b59aaa..29cc348 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,8 +1,32 @@ -import { Component } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Subscription } from 'rxjs'; +import { LoadingService } from './services/loading.service'; @Component({ selector: 'exads-root', templateUrl: './app.component.html', styleUrls: ['./app.component.scss'] }) -export class AppComponent { } +export class AppComponent implements OnInit { + + private subscriptions: Subscription[] = []; + public isLoading: boolean = false; + constructor(public loadingService: LoadingService) { + } + ngOnInit(): void { + this.subscriptions.push( + this.loadingService.loading().subscribe((loading) => { + // use short timeout to prevent ExpressionChangedAfterItHasBeenCheckedError + setTimeout(() => { + this.isLoading = loading; + }, 100) + }) + ); + } + + ngOnDestroy(): void { + this.subscriptions.forEach(sub => { + sub.unsubscribe(); + }); + } +} diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 0cadd23..223d00f 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -1,20 +1,44 @@ -import { BrowserModule } from '@angular/platform-browser'; +import { LoadingInterceptor } from './interceptors/loading.interceptor'; +import { HttpClientModule, HTTP_INTERCEPTORS, HttpInterceptor } from '@angular/common/http'; import { NgModule } from '@angular/core'; - +import { ReactiveFormsModule } from '@angular/forms'; +import { MatFormFieldModule, MatInputModule, MatPaginatorIntl, MatPaginatorModule, MatProgressBarModule, MatSelectModule, MatSnackBarModule, MatTableModule, MatToolbarModule } from '@angular/material'; +import { MatButtonModule } from '@angular/material/button'; +import { BrowserModule } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { CreateUserComponent } from './components/create-user/create-user.component'; +import { UsersTableComponent } from './components/users-table/users-table.component'; +import { CustomMatPagerIntl } from './mat-helpers/custom-mat-pager-intl'; + @NgModule({ declarations: [ - AppComponent + AppComponent, + UsersTableComponent, + CreateUserComponent ], imports: [ BrowserModule, AppRoutingModule, - BrowserAnimationsModule + BrowserAnimationsModule, + HttpClientModule, + MatTableModule, + MatPaginatorModule, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + ReactiveFormsModule, + MatToolbarModule, + MatProgressBarModule, + MatSnackBarModule + ], + providers: [ + { provide: HTTP_INTERCEPTORS, useClass: LoadingInterceptor, multi: true }, + { provide: MatPaginatorIntl, useClass: CustomMatPagerIntl} ], - providers: [], bootstrap: [AppComponent] }) export class AppModule { } diff --git a/src/app/components/create-user/create-user.component.html b/src/app/components/create-user/create-user.component.html new file mode 100644 index 0000000..f2be7a3 --- /dev/null +++ b/src/app/components/create-user/create-user.component.html @@ -0,0 +1,36 @@ +
+

CREATE_USER.TITLE

+
+
+
+ + + {{'INPUT.VALIDATION.REQUIRED'}} + {{'INPUT.VALIDATION.TOO_LONG'}} + {{'INPUT.VALIDATION.INVALID_CHARACTER'}} + +
+ + + {{'INPUT.VALIDATION.REQUIRED'}} + {{'INPUT.VALIDATION.TOO_LONG'}} + + + + +
+ + + {{'FORM.USER.INVALID_INPUT'}} + {{'INPUT.VALIDATION.EMAIL_REQUIRED'}} + {{'INPUT.VALIDATION.TOO_LONG'}} + +
+
+
+
+ + + +
+
diff --git a/src/app/components/create-user/create-user.component.scss b/src/app/components/create-user/create-user.component.scss new file mode 100644 index 0000000..afa2d77 --- /dev/null +++ b/src/app/components/create-user/create-user.component.scss @@ -0,0 +1,41 @@ +h2 { + margin-top: 50px; + margin-left: 30px; + text-transform: capitalize; +} +.main-container { + background-color: rgb(231, 231, 231); + height: 100vh; + + .container-inner { + margin-left: 30px; + margin-right: 30px; + padding: 20px; + background-color: white; + border: 1px solid #808080a6; + } +} + +#first-name { + margin-right: 20px; + } + +.inputs { + flex-grow: 1; +} + +.buttons { + margin-left: 10px; + margin-right: 10px; +} + +mat-form-field { + width: 100%; +} + +::ng-deep label.mat-form-field-label { + + span { + text-transform: capitalize !important; + } +} diff --git a/src/app/components/create-user/create-user.component.spec.ts b/src/app/components/create-user/create-user.component.spec.ts new file mode 100644 index 0000000..825c94d --- /dev/null +++ b/src/app/components/create-user/create-user.component.spec.ts @@ -0,0 +1,144 @@ +import { HttpClientModule } from '@angular/common/http'; +import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatFormFieldModule, MatInputModule, MatPaginatorModule, MatSnackBar, MatSnackBarModule, MatTableModule } from '@angular/material'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; +import { asyncScheduler, of } from 'rxjs'; +import { User } from 'src/app/models/user.model'; +import { UserRequest } from 'src/app/requests/user.request'; +import { UsersResponse } from 'src/app/responses/users.response'; +import { RoutingService } from 'src/app/services/routing.service'; +import { ApiResponse } from './../../interfaces/api-response.interface'; +import { UserService } from './../../services/user.service'; +import { CreateUserComponent } from './create-user.component'; + + +describe('CreateUserComponent', () => { + let component: CreateUserComponent; + let fixture: ComponentFixture; + + let mockUserService: jasmine.SpyObj = jasmine.createSpyObj('UserService', ['getUsers', 'postUser', 'getByUsername']) + let mockUserData: User[] = []; + let mockUserDataByUsername: UsersResponse; + const mockSnackbarMock = jasmine.createSpyObj(['open']); + const mockRoutingService = jasmine.createSpyObj(['navigateToCreateUser']); + const mockNewUser = new UserRequest(); + const userCreatedResponse: ApiResponse = { + data: new User + }; + + beforeEach(() => { + mockUserService.getUsers.and.callFake(function () { + return of(mockUserData, asyncScheduler) + }); + mockUserService.postUser.and.callFake(function (newUser: UserRequest) { + return of(userCreatedResponse) + }); + mockUserService.getByUsername.and.callFake(function (userName: string) { + return of(mockUserDataByUsername, asyncScheduler) + }); + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + ReactiveFormsModule, + MatTableModule, + MatPaginatorModule, + MatFormFieldModule, + RouterTestingModule, + HttpClientModule, + MatSnackBarModule, + MatInputModule, + BrowserAnimationsModule + ], + declarations: [CreateUserComponent], + providers: [{ provide: UserService, useValue: mockUserService }, { provide: MatSnackBar, useValue: mockSnackbarMock }, + { provide: RoutingService, useValue: mockRoutingService }] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CreateUserComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + + it('it should add new user, display success toast', (fakeAsync(() => { + const addUserButton = fixture.debugElement.query(By.css('#btn-add-user')); + spyOn(component, 'addNewUser').and.callThrough(); + spyOn(component, 'assignDataToNewUser').and.callFake(() => { + mockNewUser.first_name = 'DUMMY_first_name'; + mockNewUser.last_name = 'DUMMY_last_name'; + mockNewUser.username = 'DUMMY_username'; + mockNewUser.email = 'DUMMY_email'; + mockNewUser.id_status = 1; + component.newUser = mockNewUser; + }); + mockUserDataByUsername = new UsersResponse(); + mockUserService.getByUsername.and.returnValue(of(mockUserDataByUsername, asyncScheduler)); + addUserButton.triggerEventHandler('click', null); + expect(component.addNewUser).toHaveBeenCalled(); + tick(100); + expect(mockUserService.getByUsername).toHaveBeenCalled(); + expect(component.assignDataToNewUser).toHaveBeenCalled(); + expect(mockUserService.postUser).toHaveBeenCalledWith(component.newUser); + expect(component.newUser).toEqual(mockNewUser); + expect(mockSnackbarMock.open).toHaveBeenCalledWith("TOAST.SUCCESS.USER_CREATED", '', { duration: 10000, panelClass: 'success' }); + } + ))); + + it('if user present, it should NOT add new user, display error toas', (fakeAsync(() => { + const addUserButton = fixture.debugElement.query(By.css('#btn-add-user')); + spyOn(component, 'addNewUser').and.callThrough(); + spyOn(component, 'assignDataToNewUser'); + mockUserDataByUsername = new UsersResponse(); + mockUserDataByUsername.data.count = 1; + mockUserService.getByUsername.and.returnValue(of(mockUserDataByUsername, asyncScheduler)); + addUserButton.triggerEventHandler('click', null); + expect(component.addNewUser).toHaveBeenCalled(); + tick(100); + expect(mockUserService.getByUsername).toHaveBeenCalled(); + expect(component.assignDataToNewUser).not.toHaveBeenCalled(); + expect(component.newUser).toEqual(new UserRequest()); + expect(mockSnackbarMock.open).toHaveBeenCalledWith("TOAST.ERROR.USERNAME_TAKEN", '', { duration: 10000, panelClass: 'warn' }); + } + ))); + + it('it should assign data from form fields to user object', (fakeAsync(() => { + const userToSet = new UserRequest(); + userToSet.first_name = 'DUMMY_firstName'; + userToSet.last_name = 'DUMMY_lastName'; + userToSet.username = 'DUMMY_userName'; + userToSet.email = 'DUMMY_email@email.com'; + userToSet.id_status = 1; + + component.newUserForm.setValue({ + firstName: userToSet.first_name, + lastName: userToSet.last_name, + userName: userToSet.username, + email: userToSet.email, + }); + spyOn(component, 'addNewUser').and.callThrough(); + spyOn(component, 'assignDataToNewUser').and.callThrough(); + mockUserDataByUsername = new UsersResponse(); + mockUserService.getByUsername.and.returnValue(of(mockUserDataByUsername, asyncScheduler)); + const addUserButton = fixture.debugElement.query(By.css('#btn-add-user')); + addUserButton.triggerEventHandler('click', null); + expect(component.addNewUser).toHaveBeenCalled(); + tick(100); + expect(mockUserService.getByUsername).toHaveBeenCalled(); + expect(component.assignDataToNewUser).toHaveBeenCalled(); + expect(component.newUser).toEqual(userToSet); + } + ))); + +}); diff --git a/src/app/components/create-user/create-user.component.ts b/src/app/components/create-user/create-user.component.ts new file mode 100644 index 0000000..f0c36ab --- /dev/null +++ b/src/app/components/create-user/create-user.component.ts @@ -0,0 +1,71 @@ +import { MatSnackBar } from '@angular/material'; +import { Component } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { emailValidator } from 'src/app/validators/email.validator'; +import { userNameValidator } from 'src/app/validators/user-name-validator'; +import { UserRequest } from './../../requests/user.request'; +import { UsersResponse } from './../../responses/users.response'; +import { RoutingService } from './../../services/routing.service'; +import { UserService } from './../../services/user.service'; + +@Component({ + selector: 'exads-create-user', + templateUrl: './create-user.component.html', + styleUrls: ['./create-user.component.scss'] +}) +export class CreateUserComponent { + + public newUser: UserRequest = new UserRequest(); + public newUserForm: FormGroup = new FormGroup({ + firstName: new FormControl('', [Validators.required]), + lastName: new FormControl('', []), + userName: new FormControl('', [Validators.required, Validators.maxLength(20), Validators.minLength(3), userNameValidator()]), + email: new FormControl('', [Validators.required, emailValidator()]) + });; + + constructor(private userService: UserService, private routingService: RoutingService, + private snackBar: MatSnackBar) { } + + public cancel(): void { + this.routingService.navigateToUsersTable(); + } + + public addNewUser(): void { + this.userService.getByUsername(this.newUserForm.get("userName").value).toPromise().then((result: UsersResponse) => { + if (result && result.data && result.data.count > 0) { + this.snackBar.open("TOAST.ERROR.USERNAME_TAKEN", '', { duration: 10000 , panelClass: 'warn'}); + } else if(result && result.data && result.data.count === 0) { + this.assignDataToNewUser(); + this.postUser(); + } else { + this.snackBar.open('TOAST.ERROR.UNKOWN', '', { duration: 10000, panelClass: 'error' }); + console.error("something went wrong"); + } + }).catch((err: Error) => { + console.error(err) + this.snackBar.open(err.message, '', { duration: 10000, panelClass: 'error' }); + }); + } + + public assignDataToNewUser(): void { + this.newUser.first_name = this.newUserForm.get("firstName").value; + this.newUser.last_name = this.newUserForm.get("lastName").value; + this.newUser.username = this.newUserForm.get("userName").value; + this.newUser.email = this.newUserForm.get("email").value; + this.newUser.id_status = 1; + } + + private postUser() { + this.userService.postUser(this.newUser).toPromise().then(() => { + this.snackBar.open("TOAST.SUCCESS.USER_CREATED", '', { duration: 10000 , panelClass: 'success'}); + }, + (err: Error) => { + console.error(err); + this.snackBar.open("TOAST.ERROR.REQUEST_FAILED", '', { duration: 10000, panelClass: 'error' }); + }); + } + + public checkForError(controlName: string, errorName: string): boolean { + return this.newUserForm.controls[controlName].hasError(errorName); + } +} diff --git a/src/app/components/users-table/users-table.component.html b/src/app/components/users-table/users-table.component.html new file mode 100644 index 0000000..a4d077f --- /dev/null +++ b/src/app/components/users-table/users-table.component.html @@ -0,0 +1,59 @@ +
+ + + + + + + USERS.TABLE.USERNAME + + {{element.username}} + + + + + USERS.TABLE.FULLNAME + + {{element.first_name}} {{element.last_name}} + + + + + + USERS.TABLE.EMAIL + + + {{element.email}} + + + + + + USERS.TABLE.STATUS + + +
+ {{element.id_status == 1 ? 'USERS.ACTIVE' : element.id_status == 3 ? 'USERS.DISABLED' : ''}} +
+
+
+ + + + USERS.TABLE.CREATED_DATE + + + {{element.created_date | date:'yyyy-mm-dd'}} + + + + + + +
+
diff --git a/src/app/components/users-table/users-table.component.scss b/src/app/components/users-table/users-table.component.scss new file mode 100644 index 0000000..83731c3 --- /dev/null +++ b/src/app/components/users-table/users-table.component.scss @@ -0,0 +1,34 @@ +.active { + color: green; +} + +.disabled { + color: red; +} + +.mat-row { + min-height: 20px; +} +.mat-row:nth-child(even) { + background-color: #f1f1f1; +} + +::ng-deep #top-paginator { + + .mat-paginator-page-size { + display: flex; + flex-direction: row-reverse; + } + + .mat-paginator-range-actions { + position: absolute; + bottom: calc(100vh - 713px); + width: 100%; + + .mat-paginator-range-label { + text-align: left; + width: 100%; + } + } +} + diff --git a/src/app/components/users-table/users-table.component.spec.ts b/src/app/components/users-table/users-table.component.spec.ts new file mode 100644 index 0000000..0d73b17 --- /dev/null +++ b/src/app/components/users-table/users-table.component.spec.ts @@ -0,0 +1,136 @@ +import { RoutingService } from './../../services/routing.service'; +import { CreateUserComponent } from './../create-user/create-user.component'; +import { + HttpClientTestingModule +} from '@angular/common/http/testing'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatInputModule, MatPaginatorModule, MatSnackBar, MatSnackBarModule, MatTableModule, PageEvent } from '@angular/material'; +import { MatButtonModule } from '@angular/material/button'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; +import { asyncScheduler, of, throwError } from 'rxjs'; +import { User } from 'src/app/models/user.model'; +import { UsersResponse } from 'src/app/responses/users.response'; +import { UserRequest } from './../../requests/user.request'; +import { UserService } from './../../services/user.service'; +import { UsersTableComponent } from './users-table.component'; +import { fakeAsync, tick, discardPeriodicTasks } from '@angular/core/testing'; + + +describe('UsersTableComponent', () => { + let component: UsersTableComponent; + let fixture: ComponentFixture; + let app: UsersTableComponent; + let mockUserService: jasmine.SpyObj = jasmine.createSpyObj('UserService', ['getUsers', 'postUser', 'getByUsername']) + let mockUserData: User[] = []; + let mockUserDataByUsername: UsersResponse; + const mockSnackbarMock = jasmine.createSpyObj(['open']); + const mockRoutingService = jasmine.createSpyObj(['navigateToCreateUser']); + + beforeEach(() => { + mockUserService.getUsers.and.callFake(function () { + return of(mockUserData, asyncScheduler) + }); + mockUserService.postUser.and.callFake(function (newUser: UserRequest) { + return of(undefined) + }); + mockUserService.getByUsername.and.callFake(function (userName: string) { + return of(mockUserDataByUsername, asyncScheduler) + }); + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserAnimationsModule, + HttpClientTestingModule, + MatSnackBarModule, + MatInputModule, MatPaginatorModule, MatTableModule, MatButtonModule], + declarations: [UsersTableComponent], + providers: [{ provide: UserService, useValue: mockUserService }, { provide: MatSnackBar, useValue: mockSnackbarMock }, + { provide: RoutingService, useValue: mockRoutingService }] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(UsersTableComponent); + component = fixture.componentInstance; + app = fixture.debugElement.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('it should assign a paginator', () => { + app.ngOnInit(); + app.ngAfterViewInit(); + expect(app.dataSource.paginator).toEqual(app.paginator); + }); + + it('it should retrieve users', async () => { + mockUserData = [ + new User(), + new User(), + new User() + ]; + mockUserData.forEach((user: User) => { + user.first_name = 'DUMMY_first_name' + user.last_name = 'DUMMY_last_name' + user.username = 'DUMMY_username' + user.email = 'DUMMY_email' + user.id = 123 + user.id_status = 'DUMMY_id_status' + user.created_date = new Date(Date.now()); + }) + app.ngOnInit(); + expect(mockUserService.getUsers).toHaveBeenCalled(); + await mockUserService.getUsers().toPromise().then(() => { + expect(app.dataSource.data).toEqual(mockUserData); + }) + }); + + it('it should set isMobile', () => { + app.ngOnInit(); + app.ngAfterViewInit(); + expect(app.isMobile).toEqual(window.innerWidth <= 600); + }); + + it('it should show error on getUsers failure', async () => { + mockUserService.getUsers.and.callFake(function () { + return throwError(new Error('error'), asyncScheduler); + }); + app.ngOnInit(); + expect(mockUserService.getUsers).toHaveBeenCalled(); + await mockUserService.getUsers().subscribe(() => { }, () => { + expect(app.dataSource.data).toEqual([]); + expect(mockSnackbarMock.open).toHaveBeenCalledWith('error', '', { duration: 10000, panelClass: 'error' }); + }) + }); + + it('it should navigate to create user page', async () => { + const navigateButton = fixture.debugElement.query(By.css('#btn-nav-create-user')); + spyOn(app, 'navigateToCreateUser').and.callThrough(); + navigateButton.triggerEventHandler('click', null); + expect(app.navigateToCreateUser).toHaveBeenCalled(); + expect(mockRoutingService.navigateToCreateUser).toHaveBeenCalled(); + }); + + + it('it should adjust paginator position on page event', fakeAsync(() => { + spyOn(app, 'handlePageEvent').and.callThrough(); + var calculateFunction = spyOn(app, 'calculateNewBottom').and.callThrough(); + const pageEvent = new PageEvent(); + pageEvent.pageSize = 10; + app.paginator.page.emit(pageEvent); + expect(app.handlePageEvent).toHaveBeenCalled(); + tick(100); + expect(calculateFunction).toHaveBeenCalled(); + const rangeActions = fixture.debugElement.query(By.css('.mat-paginator-range-actions')) + expect(rangeActions.nativeElement.style.bottom).toEqual((window.innerHeight - (app.userTableContainer.nativeElement.offsetHeight + 64 + 40)) + 'px'); + })); + +}); diff --git a/src/app/components/users-table/users-table.component.ts b/src/app/components/users-table/users-table.component.ts new file mode 100644 index 0000000..b2f3565 --- /dev/null +++ b/src/app/components/users-table/users-table.component.ts @@ -0,0 +1,59 @@ +import { AfterContentChecked, AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core'; +import { MatPaginator, MatSnackBar, MatTableDataSource, PageEvent } from '@angular/material'; +import { User } from 'src/app/models/user.model'; +import { RoutingService } from './../../services/routing.service'; +import { UserService } from './../../services/user.service'; + +@Component({ + selector: 'exads-users-table', + templateUrl: './users-table.component.html', + styleUrls: ['./users-table.component.scss'] +}) +export class UsersTableComponent implements OnInit, AfterViewInit, AfterContentChecked { + + public readonly fullScreenColumns = ['username', 'fullname', 'email', 'status', 'created']; + public readonly mobileColumns = ['username', 'fullname']; + public readonly pageSizes = [5, 10, 20]; + public isMobile: boolean; + public dataSource = new MatTableDataSource(); + @ViewChild(MatPaginator, { static: false }) paginator: MatPaginator; + + @ViewChild('userTableContainer', { static: false }) userTableContainer: ElementRef; + constructor(private userService: UserService, + private routingService: RoutingService, private snackBar: MatSnackBar) { } + + ngAfterViewInit(): void { + this.dataSource.paginator = this.paginator; + } + + ngOnInit(): void { + this.userService.getUsers().toPromise().then((users: User[]) => { + this.dataSource.data = users; + }).catch((err: Error) => { + this.snackBar.open(err.message, '', { duration: 10000, panelClass: 'error' }); + console.error(err); + }); + } + + ngAfterContentChecked(): void { + this.isMobile = window.innerWidth <= 600; + } + + public navigateToCreateUser(): void { + this.routingService.navigateToCreateUser(); + } + + public handlePageEvent(event: PageEvent) { + if (event && !!event.pageSize) { + setTimeout(() => { + const matPaginatorBottom: Element = document.querySelector('.mat-paginator-range-actions'); + //@ts-ignore + matPaginatorBottom.style.bottom = this.calculateNewBottom(event.pageSize); + }, 60) + } + } + + private calculateNewBottom() { + return (window.innerHeight - (this.userTableContainer.nativeElement.offsetHeight + 64 + 40)) + 'px' + } +} diff --git a/src/app/enums/api-routes.enum.ts b/src/app/enums/api-routes.enum.ts new file mode 100644 index 0000000..b5cf8fa --- /dev/null +++ b/src/app/enums/api-routes.enum.ts @@ -0,0 +1,4 @@ +export enum API_ROUTES { + USERS = 'users', + STATUSES = 'statuses' +} diff --git a/src/app/enums/app-routes.enum.ts b/src/app/enums/app-routes.enum.ts new file mode 100644 index 0000000..3e8fd5c --- /dev/null +++ b/src/app/enums/app-routes.enum.ts @@ -0,0 +1,4 @@ +export enum APP_ROUTES { + USERS = 'users', + CREATE_USER = 'users/create' +} diff --git a/src/app/interceptors/loading.interceptor.ts b/src/app/interceptors/loading.interceptor.ts new file mode 100644 index 0000000..acab858 --- /dev/null +++ b/src/app/interceptors/loading.interceptor.ts @@ -0,0 +1,26 @@ +import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { LoadingService } from './../services/loading.service'; + + +@Injectable() +export class LoadingInterceptor implements HttpInterceptor { + + constructor(private loadingService: LoadingService) { } + + intercept(req: HttpRequest, next: HttpHandler): Observable> { + this.loadingService.setLoading(true); + return next.handle(req) + .pipe( + map((event) => { + if(event && event.hasOwnProperty('url')){ + this.loadingService.setLoading(false) + } + return event; + }) + ); + + } +} diff --git a/src/app/interfaces/api-response.interface.ts b/src/app/interfaces/api-response.interface.ts new file mode 100644 index 0000000..8ad58db --- /dev/null +++ b/src/app/interfaces/api-response.interface.ts @@ -0,0 +1,5 @@ +export interface ApiResponse { + message?: string; + missingParams?: []; + data: T | { count?: number}; +} diff --git a/src/app/interfaces/user.interface.ts b/src/app/interfaces/user.interface.ts new file mode 100644 index 0000000..f2fd4a7 --- /dev/null +++ b/src/app/interfaces/user.interface.ts @@ -0,0 +1,7 @@ +export interface IUser { + first_name: string; + last_name: string; + email: string; + username: string; + id_status: string; +} diff --git a/src/app/mat-helpers/custom-mat-pager-intl.ts b/src/app/mat-helpers/custom-mat-pager-intl.ts new file mode 100644 index 0000000..6b93522 --- /dev/null +++ b/src/app/mat-helpers/custom-mat-pager-intl.ts @@ -0,0 +1,16 @@ +import { Injectable } from "@angular/core"; +import { MatPaginatorIntl } from "@angular/material"; + +@Injectable() +export class CustomMatPagerIntl extends MatPaginatorIntl { + itemsPerPageLabel = 'MAT.PAGINATOR.ITEMS_PER_PAGE'; + nextPageLabel = 'MAT.PAGINATOR.NEXT'; + previousPageLabel = 'MAT.PAGINATOR.PREV'; + showingLabel = 'MAT.PAGINATOR.SHOWING'; + entriesLabel = 'MAT.PAGINATOR.SHOWING'; + + getRangeLabel = (page: number, pageSize: number, length: number) => { + // showing 1 - 16 / 16 entires + return this.showingLabel.concat(" ", page.toString(), " - ", pageSize.toString(), "/", length.toString(), " ", this.entriesLabel); + }; +} diff --git a/src/app/models/status.model.ts b/src/app/models/status.model.ts new file mode 100644 index 0000000..e6d959e --- /dev/null +++ b/src/app/models/status.model.ts @@ -0,0 +1,3 @@ +export class Status { + +} diff --git a/src/app/models/user.model.ts b/src/app/models/user.model.ts new file mode 100644 index 0000000..e1010b1 --- /dev/null +++ b/src/app/models/user.model.ts @@ -0,0 +1,10 @@ +import { IUser } from 'src/app/interfaces/user.interface'; +export class User implements IUser { + public id: number; + public first_name: string; + public last_name: string; + public email: string; + public username: string; + public created_date: Date; + public id_status: string; +} diff --git a/src/app/requests/user.request.ts b/src/app/requests/user.request.ts new file mode 100644 index 0000000..f7f8d6e --- /dev/null +++ b/src/app/requests/user.request.ts @@ -0,0 +1,7 @@ +export class UserRequest { + public first_name: string; + public last_name: string; + public email: string; + public username: string; + public id_status: number; +} diff --git a/src/app/responses/users.response.ts b/src/app/responses/users.response.ts new file mode 100644 index 0000000..43feda9 --- /dev/null +++ b/src/app/responses/users.response.ts @@ -0,0 +1,15 @@ +import { User } from '../models/user.model'; +import { ApiResponse } from './../interfaces/api-response.interface'; +export class UsersResponse implements ApiResponse { + + constructor(){ + this.data = { + count: 0, + users: [] + } + } + public data: { + count: number; + users: User[]; + }; +} diff --git a/src/app/services/loading.service.spec.ts b/src/app/services/loading.service.spec.ts new file mode 100644 index 0000000..b6518ad --- /dev/null +++ b/src/app/services/loading.service.spec.ts @@ -0,0 +1,12 @@ +import { TestBed } from '@angular/core/testing'; + +import { LoadingService } from './loading.service'; + +describe('LoadingService', () => { + beforeEach(() => TestBed.configureTestingModule({})); + + it('should be created', () => { + const service: LoadingService = TestBed.get(LoadingService); + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/services/loading.service.ts b/src/app/services/loading.service.ts new file mode 100644 index 0000000..aca9e0c --- /dev/null +++ b/src/app/services/loading.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class LoadingService { + + private _isLoading: BehaviorSubject = new BehaviorSubject(false); + + constructor() { } + + public isLoading(): boolean { + return this._isLoading.getValue(); + } + + public setLoading(loading: boolean){ + this._isLoading.next(loading); + } + + public loading(): Observable { + return this._isLoading.asObservable(); + } +} diff --git a/src/app/services/routing.service.spec.ts b/src/app/services/routing.service.spec.ts new file mode 100644 index 0000000..34d8633 --- /dev/null +++ b/src/app/services/routing.service.spec.ts @@ -0,0 +1,15 @@ +import { RouterTestingModule } from '@angular/router/testing'; +import { TestBed } from '@angular/core/testing'; + +import { RoutingService } from './routing.service'; + +describe('RoutingService', () => { + beforeEach(() => TestBed.configureTestingModule({ + imports: [RouterTestingModule] + })); + + it('should be created', () => { + const service: RoutingService = TestBed.get(RoutingService); + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/services/routing.service.ts b/src/app/services/routing.service.ts new file mode 100644 index 0000000..e257124 --- /dev/null +++ b/src/app/services/routing.service.ts @@ -0,0 +1,20 @@ +import { APP_ROUTES } from '../enums/app-routes.enum'; +import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; + +@Injectable({ + providedIn: 'root' +}) +export class RoutingService { + + constructor(private router: Router) { } + + public navigateToCreateUser(): void { + this.router.navigateByUrl(APP_ROUTES.CREATE_USER); + } + + public navigateToUsersTable(): void { + this.router.navigateByUrl(APP_ROUTES.USERS); + } + +} diff --git a/src/app/services/user.service.spec.ts b/src/app/services/user.service.spec.ts new file mode 100644 index 0000000..e0bce59 --- /dev/null +++ b/src/app/services/user.service.spec.ts @@ -0,0 +1,15 @@ +import { HttpClientModule } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; + +import { UserService } from './user.service'; + +describe('UserService', () => { + beforeEach(() => TestBed.configureTestingModule({ + imports: [HttpClientModule] + })); + + it('should be created', () => { + const service: UserService = TestBed.get(UserService); + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/services/user.service.ts b/src/app/services/user.service.ts new file mode 100644 index 0000000..4c38a85 --- /dev/null +++ b/src/app/services/user.service.ts @@ -0,0 +1,33 @@ +import { UserRequest } from './../requests/user.request'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { environment } from 'src/environments/environment'; +import { User } from '../models/user.model'; +import { API_ROUTES } from './../enums/api-routes.enum'; +import { UsersResponse } from './../responses/users.response'; + +@Injectable({ + providedIn: 'root' +}) +export class UserService { + + constructor(private http: HttpClient) { } + + public getUsers(): Observable { + return this.http.get(environment.apiBaseUrl + '/' + API_ROUTES.USERS) + .pipe(map((response: UsersResponse) => { return response.data.users })); + } + + public postUser(newUser: UserRequest): Observable { + return this.http.post(environment.apiBaseUrl + '/' + API_ROUTES.USERS, {user: newUser}); + } + + public getByUsername(username: string): Observable { + let queryParams = new HttpParams(); + queryParams = queryParams.append("username", username); + return this.http.get(environment.apiBaseUrl + '/' + API_ROUTES.USERS,{params:queryParams}) + } +} + diff --git a/src/app/validators/email.validator.ts b/src/app/validators/email.validator.ts new file mode 100644 index 0000000..d9e4dcc --- /dev/null +++ b/src/app/validators/email.validator.ts @@ -0,0 +1,8 @@ +import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; + +export function emailValidator(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const email = control.value; + return !email || /\b[\w\.-]+@[\w\.-]+\.\w{2,4}\b/.test(email) ? null: { invalidEmail: true }; + } +} diff --git a/src/app/validators/user-name-validator.ts b/src/app/validators/user-name-validator.ts new file mode 100644 index 0000000..9b1a375 --- /dev/null +++ b/src/app/validators/user-name-validator.ts @@ -0,0 +1,11 @@ +import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; + +export function userNameValidator(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const userName = control.value; + if (!userName) { + return null; + } + return /[(){}".![\]]+/.test(userName) ? { invalidCharacter: true } : null; + } +} diff --git a/src/environments/environment.ts b/src/environments/environment.ts index ffe8aed..60aaac8 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -1,3 +1,4 @@ export const environment = { - production: false + production: false, + apiBaseUrl: 'http://localhost:3000', }; diff --git a/src/global-styling/flex.scss b/src/global-styling/flex.scss new file mode 100644 index 0000000..a1f632f --- /dev/null +++ b/src/global-styling/flex.scss @@ -0,0 +1,24 @@ + +.flex { + display: flex; + + &.column { + flex-direction: column; + } + + &.space-between { + justify-content: space-between; + } + // style overrides for "sm" breakpoint + .sm { + // breakpont + @media only screen and (max-width: $small) { + &-column { + flex-direction: column; + } + } + + } +} + + diff --git a/src/global-styling/snackbar.scss b/src/global-styling/snackbar.scss new file mode 100644 index 0000000..32a7eeb --- /dev/null +++ b/src/global-styling/snackbar.scss @@ -0,0 +1,13 @@ +snack-bar-container { + &.success{ + color: #35f817; + } + + &.warn { + color: #f27500; + } + + &.error { + background-color: #f32c1e; + } +} diff --git a/src/global-styling/variables.scss b/src/global-styling/variables.scss new file mode 100644 index 0000000..8cad78f --- /dev/null +++ b/src/global-styling/variables.scss @@ -0,0 +1,2 @@ +// sttyling breakpoints +$small: 600px; diff --git a/src/styles.scss b/src/styles.scss index dda6814..4318327 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -1,3 +1,10 @@ +// angular material theming +@import '../node_modules/@swimlane/ngx-datatable/src/themes/material.scss'; +@import "~@angular/material/prebuilt-themes/indigo-pink.css"; +// project specific global stylings +@import "global-styling/variables.scss"; +@import "global-styling/flex.scss"; +@import "global-styling/snackbar.scss"; html, body { height: 100%; } body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }