diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index b14250d03..fdf4ed65f 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -10,6 +10,7 @@ import { ActivitiesManagementComponent } from './modules/activities-management/p import { HomeComponent } from './modules/home/home.component'; import { LoginComponent } from './modules/login/login.component'; import { CustomerComponent } from './modules/customer-management/pages/customer.component'; +import { UsersComponent } from './modules/users/pages/users.component'; const routes: Routes = [ { @@ -22,6 +23,7 @@ const routes: Routes = [ { path: 'time-entries', component: TimeEntriesComponent }, { path: 'activities-management', component: ActivitiesManagementComponent }, { path: 'customers-management', canActivate: [AdminGuard], component: CustomerComponent }, + { path: 'users', canActivate: [AdminGuard], component: UsersComponent }, { path: '', pathMatch: 'full', redirectTo: 'time-clock' }, ], }, diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 881f80186..a9a6778ac 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -55,6 +55,7 @@ import { ProjectTypeListComponent } from './modules/customer-management/componen // tslint:disable-next-line: max-line-length import { CreateProjectTypeComponent } from './modules/customer-management/components/projects-type/components/create-project-type/create-project-type.component'; import { CustomerEffects } from './modules/customer-management/store/customer-management.effects'; +import { UserEffects } from './modules/users/store/user.effects'; import { EntryEffects } from './modules/time-clock/store/entry.effects'; import { InjectTokenInterceptor } from './modules/shared/interceptors/inject.token.interceptor'; import { SubstractDatePipe } from './modules/shared/pipes/substract-date/substract-date.pipe'; @@ -68,6 +69,8 @@ import { TimeRangeFormComponent } from './modules/reports/components/time-range- import { TimeEntriesTableComponent } from './modules/reports/components/time-entries-table/time-entries-table.component'; import { DialogComponent } from './modules/shared/components/dialog/dialog.component'; import { LoadingBarComponent } from './modules/shared/components/loading-bar/loading-bar.component'; +import { UsersComponent } from './modules/users/pages/users.component'; +import { UsersListComponent } from './modules/users/components/users-list/users-list.component'; const maskConfig: Partial = { validation: false, @@ -115,7 +118,9 @@ const maskConfig: Partial = { TimeRangeFormComponent, TimeEntriesTableComponent, DialogComponent, - LoadingBarComponent + LoadingBarComponent, + UsersComponent, + UsersListComponent ], imports: [ NgxMaskModule.forRoot(maskConfig), @@ -144,6 +149,7 @@ const maskConfig: Partial = { TechnologyEffects, ProjectTypeEffects, EntryEffects, + UserEffects, ]), ToastrModule.forRoot() ], diff --git a/src/app/modules/shared/components/sidebar/sidebar.component.spec.ts b/src/app/modules/shared/components/sidebar/sidebar.component.spec.ts index e53196ded..d6303868c 100644 --- a/src/app/modules/shared/components/sidebar/sidebar.component.spec.ts +++ b/src/app/modules/shared/components/sidebar/sidebar.component.spec.ts @@ -56,7 +56,7 @@ describe('SidebarComponent', () => { component.getSidebarItems(); const menuItems = component.itemsSidebar; - expect(menuItems.length).toBe(5); + expect(menuItems.length).toBe(6); }); it('non admin users have two menu items', () => { diff --git a/src/app/modules/shared/components/sidebar/sidebar.component.ts b/src/app/modules/shared/components/sidebar/sidebar.component.ts index f95a3215f..946a6e573 100644 --- a/src/app/modules/shared/components/sidebar/sidebar.component.ts +++ b/src/app/modules/shared/components/sidebar/sidebar.component.ts @@ -45,6 +45,7 @@ export class SidebarComponent implements OnInit { {route: '/reports', icon: 'fas fa-chart-pie', text: 'Reports', active: false}, {route: '/activities-management', icon: 'fas fa-file-alt', text: 'Activities', active: false}, {route: '/customers-management', icon: 'fas fa-user', text: 'Customers', active: false}, + {route: '/users', icon: 'fas fa-user', text: 'Users', active: false}, ]; } else { this.itemsSidebar = [ diff --git a/src/app/modules/users/components/users-list/users-list.component.css b/src/app/modules/users/components/users-list/users-list.component.css new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/modules/users/components/users-list/users-list.component.html b/src/app/modules/users/components/users-list/users-list.component.html new file mode 100644 index 000000000..3b4490c97 --- /dev/null +++ b/src/app/modules/users/components/users-list/users-list.component.html @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + +
User EmailNames
{{ user.email }}{{ user.name }}
diff --git a/src/app/modules/users/components/users-list/users-list.component.spec.ts b/src/app/modules/users/components/users-list/users-list.component.spec.ts new file mode 100644 index 000000000..5c7fbd390 --- /dev/null +++ b/src/app/modules/users/components/users-list/users-list.component.spec.ts @@ -0,0 +1,80 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; + +import { NgxPaginationModule } from 'ngx-pagination'; +import { UsersListComponent } from './users-list.component'; +import { UserActionTypes, UserState, LoadUsers } from '../../store'; +import { ActionsSubject } from '@ngrx/store'; +import { DataTablesModule } from 'angular-datatables'; + +describe('UsersListComponent', () => { + let component: UsersListComponent; + let fixture: ComponentFixture; + let store: MockStore; + const actionSub: ActionsSubject = new ActionsSubject(); + + const state: UserState = { + data: [{ name: 'name', email: 'email', role: 'role', id: 'id', tenant_id: 'tenant id', deleted: 'delete' }], + isLoading: false, + message: '', + }; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [NgxPaginationModule, DataTablesModule], + declarations: [UsersListComponent], + providers: [provideMockStore({ initialState: state }), { provide: ActionsSubject, useValue: actionSub }], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(UsersListComponent); + component = fixture.componentInstance; + store = TestBed.inject(MockStore); + store.setState(state); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('when the component is initialized the load User action is triggered', () => { + spyOn(store, 'dispatch'); + + component.ngOnInit(); + + expect(store.dispatch).toHaveBeenCalledWith(new LoadUsers()); + }); + + it('on success load users, the user list should be populated', () => { + const actionSubject = TestBed.inject(ActionsSubject) as ActionsSubject; + const action = { + type: UserActionTypes.LOAD_USERS_SUCCESS, + payload: state.data, + }; + + actionSubject.next(action); + + expect(component.users).toEqual(state.data); + }); + + it('on success load users, the datatable should be reloaded', async () => { + const actionSubject = TestBed.inject(ActionsSubject); + const action = { + type: UserActionTypes.LOAD_USERS_SUCCESS, + payload: state.data, + }; + spyOn(component.dtElement.dtInstance, 'then'); + + actionSubject.next(action); + + expect(component.dtElement.dtInstance.then).toHaveBeenCalled(); + }); + + afterEach(() => { + component.dtTrigger.unsubscribe(); + component.loadUsersSubscription.unsubscribe(); + fixture.destroy(); + }); +}); diff --git a/src/app/modules/users/components/users-list/users-list.component.ts b/src/app/modules/users/components/users-list/users-list.component.ts new file mode 100644 index 000000000..5d119fd47 --- /dev/null +++ b/src/app/modules/users/components/users-list/users-list.component.ts @@ -0,0 +1,55 @@ +import { AfterViewInit, Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { ActionsSubject, select, Store } from '@ngrx/store'; +import { DataTableDirective } from 'angular-datatables'; +import { Observable, Subject, Subscription } from 'rxjs'; +import { delay, filter } from 'rxjs/operators'; +import { User } from '../../models/users'; +import { LoadUsers, UserActionTypes } from '../../store/user.actions'; +import { getIsLoading } from '../../store/user.selectors'; +@Component({ + selector: 'app-users-list', + templateUrl: './users-list.component.html', + styleUrls: ['./users-list.component.css'], +}) +export class UsersListComponent implements OnInit, OnDestroy, AfterViewInit { + users: User[] = []; + isLoading$: Observable; + loadUsersSubscription: Subscription; + dtTrigger: Subject = new Subject(); + @ViewChild(DataTableDirective, { static: false }) + dtElement: DataTableDirective; + + constructor(private store: Store, private actionsSubject$: ActionsSubject) { + this.isLoading$ = store.pipe(delay(0), select(getIsLoading)); + } + + ngOnInit(): void { + this.loadUsersSubscription = this.actionsSubject$ + .pipe(filter((action: any) => action.type === UserActionTypes.LOAD_USERS_SUCCESS)) + .subscribe((action) => { + this.users = action.payload; + this.rerenderDataTable(); + }); + this.store.dispatch(new LoadUsers()); + } + + ngAfterViewInit(): void { + this.rerenderDataTable(); + } + + ngOnDestroy() { + this.loadUsersSubscription.unsubscribe(); + this.dtTrigger.unsubscribe(); + } + + private rerenderDataTable(): void { + if (this.dtElement && this.dtElement.dtInstance) { + this.dtElement.dtInstance.then((dtInstances: DataTables.Api) => { + dtInstances.destroy(); + this.dtTrigger.next(); + }); + } else { + this.dtTrigger.next(); + } + } +} diff --git a/src/app/modules/users/models/users.ts b/src/app/modules/users/models/users.ts new file mode 100644 index 000000000..3a7670f51 --- /dev/null +++ b/src/app/modules/users/models/users.ts @@ -0,0 +1,8 @@ +export interface User { + name: string; + email: string; + role?: string; + id: string; + tenant_id?: string; + deleted?: string; +} diff --git a/src/app/modules/users/pages/users.component.css b/src/app/modules/users/pages/users.component.css new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/modules/users/pages/users.component.html b/src/app/modules/users/pages/users.component.html new file mode 100644 index 000000000..76088ae85 --- /dev/null +++ b/src/app/modules/users/pages/users.component.html @@ -0,0 +1 @@ + diff --git a/src/app/modules/users/pages/users.component.spec.ts b/src/app/modules/users/pages/users.component.spec.ts new file mode 100644 index 000000000..909b5bafc --- /dev/null +++ b/src/app/modules/users/pages/users.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UsersComponent } from './users.component'; + +describe('UsersComponent', () => { + let component: UsersComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ UsersComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(UsersComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/modules/users/pages/users.component.ts b/src/app/modules/users/pages/users.component.ts new file mode 100644 index 000000000..278666b25 --- /dev/null +++ b/src/app/modules/users/pages/users.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-users', + templateUrl: './users.component.html', + styleUrls: ['./users.component.css'], +}) +export class UsersComponent { + constructor() {} +} diff --git a/src/app/modules/users/services/users.service.spec.ts b/src/app/modules/users/services/users.service.spec.ts new file mode 100644 index 000000000..6489f34b9 --- /dev/null +++ b/src/app/modules/users/services/users.service.spec.ts @@ -0,0 +1,34 @@ +import { inject, TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { UsersService } from './users.service'; + +describe('UsersService', () => { + let service: UsersService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [HttpClientTestingModule] }); + service = TestBed.inject(UsersService); + httpMock = TestBed.inject(HttpTestingController); + service.baseUrl = 'users'; + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('services are ready to used', inject( + [HttpClientTestingModule, UsersService], + (httpClient: HttpClientTestingModule, userService: UsersService) => { + expect(httpClient).toBeTruthy(); + expect(userService).toBeTruthy(); + } + )); + + it('load all users', () => { + service.loadUsers().subscribe(); + + const loadUserRequest = httpMock.expectOne(service.baseUrl); + expect(loadUserRequest.request.method).toBe('GET'); + }); +}); diff --git a/src/app/modules/users/services/users.service.ts b/src/app/modules/users/services/users.service.ts new file mode 100644 index 000000000..e41b0401a --- /dev/null +++ b/src/app/modules/users/services/users.service.ts @@ -0,0 +1,16 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { environment } from './../../../../environments/environment'; +@Injectable({ + providedIn: 'root', +}) +export class UsersService { + constructor(private http: HttpClient) {} + + baseUrl = `${environment.timeTrackerApiUrl}/users`; + + loadUsers(): Observable { + return this.http.get(this.baseUrl); + } +} diff --git a/src/app/modules/users/store/index.ts b/src/app/modules/users/store/index.ts new file mode 100644 index 000000000..428e4b856 --- /dev/null +++ b/src/app/modules/users/store/index.ts @@ -0,0 +1,2 @@ +export * from './user.actions'; +export * from './user.reducers'; diff --git a/src/app/modules/users/store/user.actions.spec.ts b/src/app/modules/users/store/user.actions.spec.ts new file mode 100644 index 000000000..6edb98176 --- /dev/null +++ b/src/app/modules/users/store/user.actions.spec.ts @@ -0,0 +1,18 @@ +import * as actions from './user.actions'; + +describe('UserActions', () => { + it('LoadUsers type is UserActionTypes.LOAD_USERS', () => { + const action = new actions.LoadUsers(); + expect(action.type).toEqual(actions.UserActionTypes.LOAD_USERS); + }); + + it('LoadUsersSuccess type is UserActionTypes.LOAD_USERS_SUCCESS', () => { + const action = new actions.LoadUsersSuccess([]); + expect(action.type).toEqual(actions.UserActionTypes.LOAD_USERS_SUCCESS); + }); + + it('LoadUsersFail type is UserActionTypes.LOAD_USERS_FAIL', () => { + const action = new actions.LoadUsersFail('error'); + expect(action.type).toEqual(actions.UserActionTypes.LOAD_USERS_FAIL); + }); +}); diff --git a/src/app/modules/users/store/user.actions.ts b/src/app/modules/users/store/user.actions.ts new file mode 100644 index 000000000..cd4654a1e --- /dev/null +++ b/src/app/modules/users/store/user.actions.ts @@ -0,0 +1,24 @@ +import { Action } from '@ngrx/store'; +import { User } from '../models/users'; + +export enum UserActionTypes { + LOAD_USERS = '[User] LOAD_USERS', + LOAD_USERS_SUCCESS = '[User] LOAD_USERS_SUCCESS', + LOAD_USERS_FAIL = '[User] LOAD_USERS_FAIL', +} + +export class LoadUsers implements Action { + public readonly type = UserActionTypes.LOAD_USERS; +} + +export class LoadUsersSuccess implements Action { + readonly type = UserActionTypes.LOAD_USERS_SUCCESS; + constructor(readonly payload: User[]) {} +} + +export class LoadUsersFail implements Action { + public readonly type = UserActionTypes.LOAD_USERS_FAIL; + constructor(public error: string) {} +} + +export type UserActions = LoadUsers | LoadUsersSuccess | LoadUsersFail; diff --git a/src/app/modules/users/store/user.effects.spec.ts b/src/app/modules/users/store/user.effects.spec.ts new file mode 100644 index 000000000..2ac02426a --- /dev/null +++ b/src/app/modules/users/store/user.effects.spec.ts @@ -0,0 +1,54 @@ +import { TestBed } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { Action } from '@ngrx/store'; +import { Observable, of, throwError } from 'rxjs'; +import { UsersService } from '../services/users.service'; +import { UserActionTypes } from './user.actions'; +import { UserEffects } from './user.effects'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ToastrModule, ToastrService } from 'ngx-toastr'; + +describe('UserEffects', () => { + let actions$: Observable; + let effects: UserEffects; + let service: UsersService; + let toastrService: ToastrService; + const user = { id: 'id', name: 'name', email: 'email' }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [UserEffects, provideMockActions(() => actions$)], + imports: [HttpClientTestingModule, ToastrModule.forRoot()], + declarations: [], + }); + effects = TestBed.inject(UserEffects); + service = TestBed.inject(UsersService); + toastrService = TestBed.inject(ToastrService); + }); + + it('should be created', async () => { + expect(effects).toBeTruthy(); + }); + + it('action type is LOAD_USER_SUCCESS when service is executed sucessfully', async () => { + actions$ = of({ type: UserActionTypes.LOAD_USERS }); + const serviceSpy = spyOn(service, 'loadUsers'); + serviceSpy.and.returnValue(of(user)); + + effects.loadUsers$.subscribe((action) => { + expect(action.type).toEqual(UserActionTypes.LOAD_USERS_SUCCESS); + }); + }); + + it('action type is LOAD_USER_FAIL when service fail in execution', async () => { + actions$ = of({ type: UserActionTypes.LOAD_USERS }); + const serviceSpy = spyOn(service, 'loadUsers'); + serviceSpy.and.returnValue(throwError({ error: { message: 'fail!' } })); + spyOn(toastrService, 'error'); + + effects.loadUsers$.subscribe((action) => { + expect(toastrService.error).toHaveBeenCalled(); + expect(action.type).toEqual(UserActionTypes.LOAD_USERS_FAIL); + }); + }); +}); diff --git a/src/app/modules/users/store/user.effects.ts b/src/app/modules/users/store/user.effects.ts new file mode 100644 index 000000000..8ed2e1be5 --- /dev/null +++ b/src/app/modules/users/store/user.effects.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@angular/core'; +import { Actions, Effect, ofType } from '@ngrx/effects'; +import { Action } from '@ngrx/store'; +import { Observable, of } from 'rxjs'; +import { map, catchError, mergeMap } from 'rxjs/operators'; + +import { ToastrService } from 'ngx-toastr'; +import { UsersService } from '../services/users.service'; +import * as actions from './user.actions'; + +@Injectable() +export class UserEffects { + constructor(private actions$: Actions, private userService: UsersService, private toastrService: ToastrService) {} + + @Effect() + loadUsers$: Observable = this.actions$.pipe( + ofType(actions.UserActionTypes.LOAD_USERS), + mergeMap(() => + this.userService.loadUsers().pipe( + map((users) => { + return new actions.LoadUsersSuccess(users); + }), + catchError((error) => { + this.toastrService.error(error.error.message); + return of(new actions.LoadUsersFail(error)); + }) + ) + ) + ); +} diff --git a/src/app/modules/users/store/user.reducer.spec.ts b/src/app/modules/users/store/user.reducer.spec.ts new file mode 100644 index 000000000..8fb02f4f7 --- /dev/null +++ b/src/app/modules/users/store/user.reducer.spec.ts @@ -0,0 +1,30 @@ +import { UserState, userReducer } from './user.reducers'; +import * as actions from './user.actions'; + +describe('userReducer', () => { + const initialState: UserState = { data: [], isLoading: false, message: '' }; + + it('on LoadUser, isLoading is true', () => { + const action = new actions.LoadUsers(); + const state = userReducer(initialState, action); + + expect(state.isLoading).toEqual(true); + }); + + it('on LoadUserSuccess, isLoading is false and state has data', () => { + const data = []; + const action = new actions.LoadUsersSuccess(data); + const state = userReducer(initialState, action); + + expect(state.isLoading).toEqual(false); + expect(state.data).toEqual(data); + }); + + it('on LoadUserFail, isLoading is false and state has empty data', () => { + const action = new actions.LoadUsersFail('fail'); + const state = userReducer(initialState, action); + + expect(state.isLoading).toEqual(false); + expect(state.data.length).toBe(0); + }); +}); diff --git a/src/app/modules/users/store/user.reducers.ts b/src/app/modules/users/store/user.reducers.ts new file mode 100644 index 000000000..8bd0d10e7 --- /dev/null +++ b/src/app/modules/users/store/user.reducers.ts @@ -0,0 +1,41 @@ +import { UserActions, UserActionTypes } from './user.actions'; +import { User } from '../models/users'; + +export interface UserState { + data: User[]; + isLoading: boolean; + message: string; +} + +export const initialState: UserState = { + data: [], + isLoading: false, + message: '', +}; + +export const userReducer = (state: UserState = initialState, action: UserActions) => { + switch (action.type) { + case UserActionTypes.LOAD_USERS: { + return { + ...state, + isLoading: true, + }; + } + case UserActionTypes.LOAD_USERS_SUCCESS: { + return { + ...state, + data: action.payload, + isLoading: false, + }; + } + case UserActionTypes.LOAD_USERS_FAIL: { + return { + ...state, + data: [], + isLoading: false, + }; + } + default: + return state; + } +}; diff --git a/src/app/modules/users/store/user.selectors.spec.ts b/src/app/modules/users/store/user.selectors.spec.ts new file mode 100644 index 000000000..43199aec6 --- /dev/null +++ b/src/app/modules/users/store/user.selectors.spec.ts @@ -0,0 +1,10 @@ +import * as selectors from './user.selectors'; + +describe('UserSelectors', () => { + it('should select is Loading', () => { + const isLoadingValue = true; + const userState = { isLoading: isLoadingValue }; + + expect(selectors.getIsLoading.projector(userState)).toBe(isLoadingValue); + }); +}); diff --git a/src/app/modules/users/store/user.selectors.ts b/src/app/modules/users/store/user.selectors.ts new file mode 100644 index 000000000..5fd127af9 --- /dev/null +++ b/src/app/modules/users/store/user.selectors.ts @@ -0,0 +1,8 @@ +import { createFeatureSelector, createSelector } from '@ngrx/store'; +import { UserState } from './user.reducers'; + +export const getUserState = createFeatureSelector('users'); + +export const getIsLoading = createSelector(getUserState, (state: UserState) => { + return state.isLoading; +}); diff --git a/src/app/reducers/index.ts b/src/app/reducers/index.ts index 183dce699..6e1316651 100644 --- a/src/app/reducers/index.ts +++ b/src/app/reducers/index.ts @@ -6,7 +6,7 @@ import { customerManagementReducer } from '../modules/customer-management/store/ import { projectTypeReducer } from '../modules/customer-management/components/projects-type/store/project-type.reducers'; import { entryReducer } from '../modules/time-clock/store/entry.reducer'; import { environment } from '../../environments/environment'; - +import { userReducer } from '../modules/users/store/user.reducers'; export interface State { projects; activities; @@ -14,6 +14,7 @@ export interface State { customers; projectType; entries; + users; } export const reducers: ActionReducerMap = { @@ -23,6 +24,7 @@ export const reducers: ActionReducerMap = { technologies: technologyReducer, projectType: projectTypeReducer, entries: entryReducer, + users: userReducer, }; export const metaReducers: MetaReducer[] = !environment.production ? [] : [];