diff --git a/.circleci/config.yml b/.circleci/config.yml index bddddfb..91d9e65 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -9,9 +9,9 @@ jobs: - setup_remote_docker: docker_layer_caching: false - store_artifacts: - path: /usr/src/covarage + path: /usr/src/coverage - store_test_results: - path: covarage + path: coverage - run: name: Build Docker Image command: | diff --git a/package-lock.json b/package-lock.json index 8851178..c8e2af5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10130,6 +10130,11 @@ "glob": "^7.1.2" } }, + "ts-enum-util": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/ts-enum-util/-/ts-enum-util-3.1.0.tgz", + "integrity": "sha512-X3rvaVckjES5KXheW8KdMiQmhWwGnNPpM5wDydz3mPhuym2qe7asWMlYHj0OOaHN7a2REgcT3JWpIAi7S8HoNA==" + }, "ts-node": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-7.0.1.tgz", diff --git a/package.json b/package.json index ea6664d..f6a20df 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "hammerjs": "^2.0.8", "jwt-decode": "^2.2.0", "rxjs": "~6.3.3", + "ts-enum-util": "^3.1.0", "tslib": "^1.9.0", "zone.js": "~0.8.26" }, diff --git a/src/app/material.module.ts b/src/app/app.material.module.ts similarity index 68% rename from src/app/material.module.ts rename to src/app/app.material.module.ts index fcdf742..9344a78 100644 --- a/src/app/material.module.ts +++ b/src/app/app.material.module.ts @@ -8,9 +8,14 @@ import { MatIconModule, MatInputModule, MatListModule, + MatPaginatorModule, + MatProgressSpinnerModule, MatSidenavModule, MatSnackBarModule, + MatSortModule, + MatTableModule, MatToolbarModule, + MatDatepickerModule, } from '@angular/material' @NgModule({ @@ -27,6 +32,11 @@ import { MatSnackBarModule, MatToolbarModule, MatDialogModule, + MatTableModule, + MatSortModule, + MatPaginatorModule, + MatProgressSpinnerModule + ], exports: [ CommonModule, @@ -39,7 +49,12 @@ import { MatSidenavModule, MatSnackBarModule, MatToolbarModule, - MatDialogModule + MatDialogModule, + MatTableModule, + MatSortModule, + MatPaginatorModule, + MatProgressSpinnerModule, + MatDatepickerModule, ], }) -export class MaterialModule {} +export class AppMaterialModule {} diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 3697e0f..0bbbeba 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -5,7 +5,7 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations' import { AppRoutingModule } from './app-routing.module' import { AppComponent } from './app.component' import { HomeComponent } from './home/home.component' -import { MaterialModule } from './material.module' +import { AppMaterialModule } from './app.material.module' import { PageNotFoundComponent } from './page-not-found/page-not-found.component' import { FlexLayoutModule } from '@angular/flex-layout' @@ -31,7 +31,7 @@ import { AuthGuard } from './auth/auth-guard.guard' BrowserModule, AppRoutingModule, BrowserAnimationsModule, - MaterialModule, + AppMaterialModule, HttpClientModule, FlexLayoutModule, FormsModule, diff --git a/src/app/auth/auth-guard.guard.ts b/src/app/auth/auth-guard.guard.ts index b35a81b..eb1f010 100644 --- a/src/app/auth/auth-guard.guard.ts +++ b/src/app/auth/auth-guard.guard.ts @@ -6,7 +6,7 @@ import { CanLoad, CanActivateChild, Router, } from '@angular/router' import { Observable } from 'rxjs' -import { AuthService, IAuthStatus } from './auth.service' +import { AuthService, AuthStatusInterface } from './auth.service' import { Route } from '@angular/compiler/src/core' import { UiService } from '../common/ui.service' @@ -14,7 +14,7 @@ import { UiService } from '../common/ui.service' providedIn: 'root', }) export class AuthGuard implements CanActivate, CanActivateChild, CanLoad { - protected currentAuthStatus: IAuthStatus + protected currentAuthStatus: AuthStatusInterface constructor( protected authService: AuthService, diff --git a/src/app/auth/auth.service.fake.ts b/src/app/auth/auth.service.fake.ts index 90505cd..354412d 100644 --- a/src/app/auth/auth.service.fake.ts +++ b/src/app/auth/auth.service.fake.ts @@ -1,15 +1,15 @@ -import { IAuthService, IAuthStatus, defaultAuthStatus } from './auth.service' +import { AuthServiceInterface, AuthStatusInterface, defaultAuthStatus } from './auth.service' import { BehaviorSubject, Observable, of } from 'rxjs' import { Injectable } from '@angular/core' @Injectable() -export class AuthServiceFake implements IAuthService { - authStatus = new BehaviorSubject(defaultAuthStatus) +export class AuthServiceFake implements AuthServiceInterface { + authStatus = new BehaviorSubject(defaultAuthStatus) constructor() { } - login(email: string, password: string): Observable { + login(email: string, password: string): Observable { return of(defaultAuthStatus) } diff --git a/src/app/auth/auth.service.ts b/src/app/auth/auth.service.ts index 67e0273..3ad1c74 100644 --- a/src/app/auth/auth.service.ts +++ b/src/app/auth/auth.service.ts @@ -3,7 +3,7 @@ import { Injectable } from '@angular/core' import { Role } from './role.enum' import { BehaviorSubject, Observable, of, throwError as observableThrowError } from 'rxjs' -import { sign } from 'fake-jwt-sign' +import { sign } from 'fake-jwt-sign' // for fakeAuthProvider import { transformError } from '../common/common' import * as decode from 'jwt-decode' import { catchError, map } from 'rxjs/operators' @@ -11,20 +11,20 @@ import { catchError, map } from 'rxjs/operators' import { environment } from '../../environments/environment' import { CacheService } from './cache.service' -export interface IAuthStatus { +export interface AuthStatusInterface { isAuthenticated: boolean userRole: Role userId: string } -export interface IAuthService { - authStatus: BehaviorSubject - login(email: string, password: string): Observable +export interface AuthServiceInterface { + authStatus: BehaviorSubject + login(email: string, password: string): Observable logout() getToken(): string } -interface IServerAuthResponse { +interface ServerAuthResponseInterface { accessToken: string } @@ -36,16 +36,16 @@ export const defaultAuthStatus = { @Injectable({ providedIn: 'root' }) -export class AuthService extends CacheService implements IAuthService { +export class AuthService extends CacheService implements AuthServiceInterface { - authStatus = new BehaviorSubject( + authStatus = new BehaviorSubject( this.getItem('authStatus') || defaultAuthStatus ) private readonly authProvider: ( email: string, password: string - ) => Observable + ) => Observable constructor(private httpClient: HttpClient) { super() @@ -58,8 +58,8 @@ export class AuthService extends CacheService implements IAuthService { // private exampleAuthProvider( // email: string, // password: string -// ): Observable { -// return this.httpClient.post(`${environment.baseUrl}/v1/login`, { +// ): Observable { +// return this.httpClient.post(`${environment.baseUrl}/v1/login`, { // email: email, // password: password, // }) @@ -67,7 +67,7 @@ export class AuthService extends CacheService implements IAuthService { private fakeAuthProvider ( email: string, password: string - ): Observable { + ): Observable { if (!email.toLowerCase().endsWith('@test.com')) { return observableThrowError('Failed to login Email needs to end with @test.com') } @@ -80,25 +80,25 @@ export class AuthService extends CacheService implements IAuthService { : email.toLowerCase().includes('clerk') ? Role.Clerk : email.toLowerCase().includes('manager') ? Role.Manager : Role.None, - } as IAuthStatus + } as AuthStatusInterface const authResponse = { accessToken: sign(authStatus, 'secret', { expiresIn: '1h', algorithm: 'none', }), - } as IServerAuthResponse + } as ServerAuthResponseInterface return of(authResponse) } - login(email: string, password: string): Observable { + login(email: string, password: string): Observable { this.logout() const loginResponse = this.authProvider(email, password).pipe( map(value => { this.setToken(value.accessToken) - return decode(value.accessToken) as IAuthStatus + return decode(value.accessToken) as AuthStatusInterface }), catchError(transformError) ) @@ -125,7 +125,7 @@ export class AuthService extends CacheService implements IAuthService { this.setItem('jwt', jwt) } - private getDecodedToken(): IAuthStatus { + private getDecodedToken(): AuthStatusInterface { return decode(this.getItem('jwt')) } diff --git a/src/app/common/common.testing.ts b/src/app/common/common.testing.ts index ac5577e..8deba98 100644 --- a/src/app/common/common.testing.ts +++ b/src/app/common/common.testing.ts @@ -3,7 +3,7 @@ import { MediaChange } from '@angular/flex-layout' import { SafeResourceUrl, SafeValue } from '@angular/platform-browser' import { SecurityContext } from '@angular/platform-browser/src/security/dom_sanitization_service' import { FormsModule, ReactiveFormsModule } from '@angular/forms' -import { MaterialModule } from '../material.module' +import { AppMaterialModule } from '../app.material.module' import { NoopAnimationsModule } from '@angular/platform-browser/animations' import { HttpClientTestingModule } from '@angular/common/http/testing' import { RouterTestingModule } from '@angular/router/testing' @@ -11,6 +11,10 @@ import { HttpErrorResponse } from '@angular/common/http' import { AuthService } from '../auth/auth.service' import { AuthServiceFake } from '../auth/auth.service.fake' import { UiService } from './ui.service' +import { UserService } from '../user/userModel/user.service' +import { UserServiceFake } from '../user/userModel/user.service.fake' +import { SharedComponentsModule } from '../shared-components.module' +import { UserMaterialModule } from '../user/user-material.module' const FAKE_SVGS = { grocery: '', @@ -75,12 +79,16 @@ export class DomSanitizerFake { export const commonTestingModules: any[] = [ FormsModule, ReactiveFormsModule, - MaterialModule, + AppMaterialModule, NoopAnimationsModule, HttpClientTestingModule, RouterTestingModule, + SharedComponentsModule, + UserMaterialModule, ] export const commonTestingProviders: any[] = [ - { provide: AuthService, useClass: AuthServiceFake }, UiService, + { provide: AuthService, useClass: AuthServiceFake }, + { provide: UserService, useClass: UserServiceFake }, + UiService, ] diff --git a/src/app/common/validations.ts b/src/app/common/validations.ts index 8f52ba8..88ed979 100644 --- a/src/app/common/validations.ts +++ b/src/app/common/validations.ts @@ -6,3 +6,25 @@ export const PasswordValidation = [ Validators.minLength(8), Validators.maxLength(50), ] +export const OptionalTextValidation = [Validators.minLength(2), + Validators.maxLength(50)] + +export const RequiredTextValidation = OptionalTextValidation.concat([Validators.required]) + +export const OneCharValidation = [Validators.minLength(1)] + +export const BirthDateValidation = [ + Validators.required, + Validators.min(new Date().getFullYear() - 100), + Validators.max(new Date().getFullYear()), +] + +export const CountyZipCodeValidation = [ + Validators.required, + Validators.pattern(/^\\d{5}$/), /** "Format": "NNNNN"*/ +] + +export const KenyaPhoneNumberValidation = [ + Validators.required, + Validators.pattern(/(\+254|^)[ ]?[7]([0-3][0-9])[ ]?[0-9]{3}[ ]?[0-9]{3}\z/), +] diff --git a/src/app/inventory/inventory.module.ts b/src/app/inventory/inventory.module.ts index 268c632..5e3c7d3 100644 --- a/src/app/inventory/inventory.module.ts +++ b/src/app/inventory/inventory.module.ts @@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common' import { NgModule } from '@angular/core' import { InventoryRoutingModule } from './inventory-routing.module' import { InventoryComponent } from './inventory.component' -import { MaterialModule } from '../material.module' +import { AppMaterialModule } from '../app.material.module' import { InventoryDashboardComponent } from './inventory-dashboard/inventory-dashboard.component' import { StockEntryComponent } from './stock-entry/stock-entry.component' import { ProductsComponent } from './products/products.component' @@ -11,6 +11,6 @@ import { FlexLayoutModule } from '@angular/flex-layout' @NgModule({ declarations: [InventoryComponent, InventoryDashboardComponent, StockEntryComponent, ProductsComponent, CategoriesComponent], - imports: [CommonModule, InventoryRoutingModule, MaterialModule, FlexLayoutModule, ], + imports: [CommonModule, InventoryRoutingModule, AppMaterialModule, FlexLayoutModule, ], }) export class InventoryModule {} diff --git a/src/app/manager/manager-routing.module.ts b/src/app/manager/manager-routing.module.ts index 8217bcc..d54f6f3 100644 --- a/src/app/manager/manager-routing.module.ts +++ b/src/app/manager/manager-routing.module.ts @@ -6,6 +6,9 @@ import { ReceiptLookupComponent } from './receipt-lookup/receipt-lookup.componen import { UserManagementComponent } from './user-management/user-management.component' import { AuthGuard } from '../auth/auth-guard.guard' import { Role } from '../auth/role.enum' +import { UserTableComponent } from './user-table/user-table.component' +import { ViewUserComponent } from '../user/view-user/view-user.component' +import { UserResolve } from '../user/userModel/user.resolve' const routes: Routes = [ { @@ -24,7 +27,16 @@ const routes: Routes = [ { path: 'users', component: UserManagementComponent, + children: [ + { path: '', component: UserTableComponent, outlet: 'master' }, + { path: 'user', component: ViewUserComponent, outlet: 'detail', + resolve: { + user: UserResolve + } + }, + ], canActivate: [AuthGuard], + canActivateChild: [AuthGuard], data: { expectedRole: Role.Manager, }, diff --git a/src/app/manager/manager.module.ts b/src/app/manager/manager.module.ts index c85333b..ad72317 100644 --- a/src/app/manager/manager.module.ts +++ b/src/app/manager/manager.module.ts @@ -1,6 +1,6 @@ import { CommonModule } from '@angular/common' import { NgModule } from '@angular/core' -import { MaterialModule } from '../material.module' +import { AppMaterialModule } from '../app.material.module' import { ManagerHomeComponent } from './manager-home/manager-home.component' import { ManagerRoutingModule } from './manager-routing.module' import { ManagerComponent } from './manager.component' @@ -9,6 +9,11 @@ import { UserManagementComponent } from './user-management/user-management.compo import { FlexLayoutModule } from '@angular/flex-layout' import { AuthGuard } from '../auth/auth-guard.guard' import { AuthService } from '../auth/auth.service' +import { UserTableComponent } from './user-table/user-table.component' +import { UserResolve } from '../user/userModel/user.resolve' +import { SharedComponentsModule } from '../shared-components.module' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { UserService } from '../user/userModel/user.service' @NgModule({ declarations: [ @@ -16,8 +21,18 @@ import { AuthService } from '../auth/auth.service' ManagerComponent, UserManagementComponent, ReceiptLookupComponent, + UserTableComponent, ], - imports: [CommonModule, ManagerRoutingModule, MaterialModule, FlexLayoutModule], - providers: [AuthGuard, AuthService] + imports: [ + CommonModule, + ManagerRoutingModule, + AppMaterialModule, + FlexLayoutModule, + SharedComponentsModule, + FormsModule, + ReactiveFormsModule + ], + providers: [AuthGuard, AuthService, UserService, UserResolve], }) -export class ManagerModule {} +export class ManagerModule { +} diff --git a/src/app/manager/user-management/user-management.component.html b/src/app/manager/user-management/user-management.component.html deleted file mode 100644 index e453d52..0000000 --- a/src/app/manager/user-management/user-management.component.html +++ /dev/null @@ -1,3 +0,0 @@ -

- user-management works! -

diff --git a/src/app/manager/user-management/user-management.component.spec.ts b/src/app/manager/user-management/user-management.component.spec.ts index 4c1c14b..ff92a57 100644 --- a/src/app/manager/user-management/user-management.component.spec.ts +++ b/src/app/manager/user-management/user-management.component.spec.ts @@ -1,25 +1,28 @@ -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing' -import { UserManagementComponent } from './user-management.component'; +import { UserManagementComponent } from './user-management.component' +import { commonTestingModules, commonTestingProviders } from '../../common/common.testing' describe('UserManagementComponent', () => { - let component: UserManagementComponent; - let fixture: ComponentFixture; + let component: UserManagementComponent + let fixture: ComponentFixture beforeEach(async(() => { TestBed.configureTestingModule({ - declarations: [ UserManagementComponent ] + declarations: [UserManagementComponent], + providers: commonTestingProviders, + imports: commonTestingModules, }) - .compileComponents(); - })); + .compileComponents() + })) beforeEach(() => { - fixture = TestBed.createComponent(UserManagementComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); + fixture = TestBed.createComponent(UserManagementComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) it('should create', () => { - expect(component).toBeTruthy(); - }); -}); + expect(component).toBeTruthy() + }) +}) diff --git a/src/app/manager/user-management/user-management.component.ts b/src/app/manager/user-management/user-management.component.ts index a374bb2..afca5a1 100644 --- a/src/app/manager/user-management/user-management.component.ts +++ b/src/app/manager/user-management/user-management.component.ts @@ -1,13 +1,20 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit } from '@angular/core' @Component({ selector: 'app-user-management', - templateUrl: './user-management.component.html', - styleUrls: ['./user-management.component.scss'] + template: + ` +
+ +
+ +
`, + styleUrls: ['./user-management.component.scss'], }) export class UserManagementComponent implements OnInit { - constructor() { } + constructor() { + } ngOnInit() { } diff --git a/src/app/manager/user-table/user-table.component.html b/src/app/manager/user-table/user-table.component.html new file mode 100644 index 0000000..e8680e6 --- /dev/null +++ b/src/app/manager/user-table/user-table.component.html @@ -0,0 +1,53 @@ +
+
+
+ + search + + Search by email or Name + + Type more than one character to Search + + +
+
+
+
+ +
+ {{errorText}} +
+
+ + + Name + {{row.name.first}} {{row.name.last}} + + + E-mail + {{row.email}} + + + Role + {{row.role}} + + + Status + {{row.status}} + + + View Details + + visibility + + + + + + +
+
diff --git a/src/app/manager/user-table/user-table.component.scss b/src/app/manager/user-table/user-table.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/manager/user-table/user-table.component.spec.ts b/src/app/manager/user-table/user-table.component.spec.ts new file mode 100644 index 0000000..b5b296e --- /dev/null +++ b/src/app/manager/user-table/user-table.component.spec.ts @@ -0,0 +1,33 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing' + +import { UserTableComponent } from './user-table.component' +import { MatTableDataSource } from '@angular/material' +import { User } from '../../user/userModel/user' +import { commonTestingModules, commonTestingProviders } from '../../common/common.testing' + +describe('UserTableComponent', () => { + let component: UserTableComponent + let fixture: ComponentFixture + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ UserTableComponent ], + providers: commonTestingProviders, + imports: commonTestingModules, + }) + .compileComponents() + })) + + beforeEach(() => { + fixture = TestBed.createComponent(UserTableComponent) + component = fixture.componentInstance + component.dataSource = new MatTableDataSource() + component.dataSource.data = [new User()] + component._skipLoading = true + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) +}) diff --git a/src/app/manager/user-table/user-table.component.ts b/src/app/manager/user-table/user-table.component.ts new file mode 100644 index 0000000..25ec555 --- /dev/null +++ b/src/app/manager/user-table/user-table.component.ts @@ -0,0 +1,85 @@ +import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core' +import { MatPaginator, MatSort, MatTableDataSource } from '@angular/material' +import { FormControl } from '@angular/forms' +import { OptionalTextValidation } from '../../common/validations' +import { UserService } from '../../user/userModel/user.service' +import { merge, of } from 'rxjs' +import { debounceTime, map, startWith, switchMap, catchError } from 'rxjs/operators' +import { UserInterface } from '../../user/userModel/user' + +@Component({ + selector: 'app-user-table', + templateUrl: './user-table.component.html', + styleUrls: ['./user-table.component.scss'], +}) + +export class UserTableComponent implements OnInit, AfterViewInit { + displayedColumns = ['name', 'email', 'role', 'status', 'id'] + dataSource = new MatTableDataSource() + resultsLength = 0 + _isLoadingResults = true + _hasError = false + errorText = '' + _skipLoading = false + + search = new FormControl('', OptionalTextValidation) + + @ViewChild(MatPaginator) paginator: MatPaginator + @ViewChild(MatSort) sort: MatSort + + constructor(private userService: UserService) { + } + + ngOnInit() { + } + + ngAfterViewInit() { + this.dataSource.paginator = this.paginator + this.dataSource.sort = this.sort + + this.sort.sortChange.subscribe(() => (this.paginator.pageIndex = 0)) + + if (this._skipLoading) { + return + } + + merge( + this.sort.sortChange, + this.paginator.page, + this.search.valueChanges.pipe(debounceTime(1000)), + ) + .pipe( + startWith({}), + switchMap(() => { + this._isLoadingResults = true + return this.userService.getUsers( + this.paginator.pageSize, + this.search.value, + this.paginator.pageIndex, + ) + }), + map((data: { total: number; items: UserInterface[] }) => { + this._isLoadingResults = false + this._hasError = false + this.resultsLength = data.total + + return data.items + }), + catchError(err => { + this._isLoadingResults = false + this._hasError = true + this.errorText = err + return of([]) + }) + ) + .subscribe(data => (this.dataSource.data = data)) + } + + get isLoadingResults() { + return this._isLoadingResults + } + + get hasError() { + return this._hasError + } +} diff --git a/src/app/shared-components.module.ts b/src/app/shared-components.module.ts new file mode 100644 index 0000000..cccb2b6 --- /dev/null +++ b/src/app/shared-components.module.ts @@ -0,0 +1,19 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { FlexLayoutModule } from '@angular/flex-layout' +import { AppMaterialModule } from './app.material.module' +import { ViewUserComponent } from './user/view-user/view-user.component' + +@NgModule({ + declarations: [ViewUserComponent], + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + FlexLayoutModule, + AppMaterialModule, + ], + exports: [ViewUserComponent] +}) +export class SharedComponentsModule { } diff --git a/src/app/user/profile/data.ts b/src/app/user/profile/data.ts new file mode 100644 index 0000000..4db4db2 --- /dev/null +++ b/src/app/user/profile/data.ts @@ -0,0 +1,70 @@ +export interface CountyInterface { + code: number + name: string +} + +export function CountiesFilter(value: string): CountyInterface[] { + return Counties.filter(state => { + return ( + state.name.toLowerCase().indexOf(value.toLowerCase()) === 0 || + state.code.valueOf() + + ) + }) +} + +export enum PhoneType { + Mobile, + Home, + Work +} + +const Counties = [ + { code: 1, name: 'Mombasa' }, + { code: 2, name: 'Kwale' }, + { code: 3, name: 'Kilifi' }, + { code: 4, name: 'Tana River' }, + { code: 5, name: 'Lamu' }, + { code: 6, name: 'Taita–Taveta' }, + { code: 7, name: 'Garissa' }, + { code: 8, name: 'Wajir' }, + { code: 9, name: 'Mandera' }, + { code: 10, name: 'Marsabit' }, + { code: 11, name: 'Isiolo' }, + { code: 12, name: 'Meru' }, + { code: 13, name: 'Tharaka-Nithi' }, + { code: 14, name: 'Embu' }, + { code: 15, name: 'Kitui' }, + { code: 16, name: 'Machakos' }, + { code: 17, name: 'Makueni' }, + { code: 18, name: 'Nyandarua' }, + { code: 19, name: 'Nyeri' }, + { code: 20, name: 'Kirinyaga' }, + { code: 21, name: 'Muranga' }, + { code: 22, name: 'Kiambu' }, + { code: 23, name: 'Turkana' }, + { code: 24, name: 'West Pokot' }, + { code: 25, name: 'Samburu' }, + { code: 26, name: 'Trans-Nzoia' }, + { code: 27, name: 'Uashin Gishu' }, + { code: 28, name: 'Elgeyo-Marakwet' }, + { code: 29, name: 'Nandi' }, + { code: 30, name: 'Baringo' }, + { code: 31, name: 'Laikipia' }, + { code: 32, name: 'Nakuru' }, + { code: 33, name: 'Narok' }, + { code: 34, name: 'Kajiado' }, + { code: 35, name: 'Kericho' }, + { code: 36, name: 'Bomet' }, + { code: 37, name: 'Kakamega' }, + { code: 38, name: 'Vihiga' }, + { code: 39, name: 'Bungoma' }, + { code: 40, name: 'Busia' }, + { code: 41, name: 'Siaya' }, + { code: 42, name: 'Kisumu' }, + { code: 43, name: 'Homa Bay' }, + { code: 44, name: 'Migori' }, + { code: 45, name: 'Kisii' }, + { code: 46, name: 'Nyamira' }, + { code: 47, name: 'Nairobi' }, +] diff --git a/src/app/user/profile/profile.component.html b/src/app/user/profile/profile.component.html index cd8b801..1fdbc0f 100644 --- a/src/app/user/profile/profile.component.html +++ b/src/app/user/profile/profile.component.html @@ -1,3 +1,207 @@ -

- profile works! -

+ +
User Profile
+
+ + +
+ Account Information +
+
+ + + + First Name is Required + + + Must be at least 2 Characters + + + Cant Exceed 50 characters + + + + + + Only Initial + + + + + + Last Name is Required + + + Must be at least 2 characters + + + Can't exceed 50 characters + + + +
+
+ + + {{this.age}} years(s) old + + + + Date must be within the last 100 years + + + + + Only your manager can update your e-mail. + + A valid E-mail is required + + + +
+
+
+ Role + + + None + + + Cashier + + + Clerk + + + Manager + + + + Role is required + +
+
+
+
+
{{userError}}
+ +
+
+
+
+ +
+ Contact Information +
+
+ + + + Line 1 is required + + + Must be at least 2 characters + + + Can't exceed 50 characters + + +
+
+ + + Optional + + Must be at least 2 characters + + + Can't exceed 50 characters + + +
+
+ + + + City is required + + + Must be at least 2 characters + + + Can't exceed 50 characters + + + + + + + {{ county.name }} + + + + County is required + + + + + + A valid Zip Code is required + + +
+
+ +

Phone Number(s)

+ + + + + + {{ type }} + + + + + + + A valid phone number is required + + + + +
+
+
+
+ +
+
{{userError}}
+ +
+
+
+ +
+ Review +
+ Review and update your user profile. + +
+
+ +
+
{{userError}}
+ + +
+
+
+
diff --git a/src/app/user/profile/profile.component.spec.ts b/src/app/user/profile/profile.component.spec.ts index 692b234..2e3c3f2 100644 --- a/src/app/user/profile/profile.component.spec.ts +++ b/src/app/user/profile/profile.component.spec.ts @@ -1,25 +1,28 @@ -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing' -import { ProfileComponent } from './profile.component'; +import { ProfileComponent } from './profile.component' +import { commonTestingModules, commonTestingProviders } from '../../common/common.testing' describe('ProfileComponent', () => { - let component: ProfileComponent; - let fixture: ComponentFixture; + let component: ProfileComponent + let fixture: ComponentFixture beforeEach(async(() => { TestBed.configureTestingModule({ - declarations: [ ProfileComponent ] + declarations: [ ProfileComponent ], + providers: commonTestingProviders, + imports: commonTestingModules, }) - .compileComponents(); - })); + .compileComponents() + })) beforeEach(() => { - fixture = TestBed.createComponent(ProfileComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); + fixture = TestBed.createComponent(ProfileComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) it('should create', () => { - expect(component).toBeTruthy(); - }); -}); + expect(component).toBeTruthy() + }) +}) diff --git a/src/app/user/profile/profile.component.ts b/src/app/user/profile/profile.component.ts index a9b65fc..1a6b078 100644 --- a/src/app/user/profile/profile.component.ts +++ b/src/app/user/profile/profile.component.ts @@ -1,15 +1,133 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit } from '@angular/core' +import { Role as UserRole } from '../../auth/role.enum' +import { $enum } from 'ts-enum-util' +import { CountiesFilter, CountyInterface, PhoneType } from './data' +import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms' +import { Observable } from 'rxjs' +import { Router } from '@angular/router' +import { UserService } from '../userModel/user.service' +import { AuthService } from '../../auth/auth.service' +import { PhoneInterface, UserInterface } from '../userModel/user' +import { + BirthDateValidation, CountyZipCodeValidation, + EmailValidation, KenyaPhoneNumberValidation, + OneCharValidation, + OptionalTextValidation, + RequiredTextValidation, +} from '../../common/validations' +import { map, startWith } from 'rxjs/operators' @Component({ selector: 'app-profile', templateUrl: './profile.component.html', - styleUrls: ['./profile.component.scss'] + styleUrls: ['./profile.component.scss'], }) export class ProfileComponent implements OnInit { + Role = UserRole + PhoneTypes = $enum(PhoneType).getKeys() + userForm: FormGroup + counties: Observable + userError = '' + currentUserRole = this.Role.None - constructor() { } + constructor( + private formBuilder: FormBuilder, + private router: Router, + private userService: UserService, + private authService: AuthService, + ) { + } ngOnInit() { + this.authService.authStatus.subscribe( + authStatus => (this.currentUserRole = authStatus.userRole), + ) + + this.userService.getCurrentUser().subscribe(user => { + this.buildUserForm(user) + }) + this.buildUserForm() + } + + buildUserForm(user?: UserInterface) { + this.userForm = this.formBuilder.group({ + email: [ + { + value: (user && user.email) || '', + disabled: this.currentUserRole !== this.Role.Manager, + }, + EmailValidation, + ], + name: this.formBuilder.group({ + first: [(user && user.name.first) || '', RequiredTextValidation], + middle: [(user && user.name.middle) || '', OneCharValidation], + last: [(user && user.name.last) || '', RequiredTextValidation], + }), + role: [ + { + value: (user && user.role) || '', + disabled: this.currentUserRole !== this.Role.Manager, + }, [Validators.required], + ], + dateOfBirth: [(user && user.dateOfBirth) || '', BirthDateValidation], + address: this.formBuilder.group({ + line1: [(user && user.address && user.address.line1) || '', RequiredTextValidation], + line2: [(user && user.address && user.address.line2) || '', OptionalTextValidation], + city: [(user && user.address && user.address.city) || '', RequiredTextValidation], + county: [(user && user.address && user.address.county) || '', RequiredTextValidation], + zip: [(user && user.address && user.address.zip) || '', CountyZipCodeValidation], + }), + phones: this.formBuilder.array(this.buildPhoneArray(user ? user.phone : [])), + }) + + this.counties = this.userForm + .get('address') + .get('county') + .valueChanges.pipe(startWith(''), map(value => CountiesFilter(value))) } + addPhone() { + this.phonesArray.push( + this.buildPhoneFormControl(this.userForm.get('phones').value.length + 1), + ) + } + + get phonesArray(): FormArray { + return this.userForm.get('phones') + } + + private buildPhoneArray(phones: PhoneInterface[]) { + const groups = [] + + if (!phones || (phones && phones.length === 0)) { + groups.push(this.buildPhoneFormControl(1)) + } else { + phones.forEach(p => { + groups.push(this.buildPhoneFormControl(p.id, p.type, p.number)) + }) + } + return groups + } + + private buildPhoneFormControl(id, type?: string, number?: string) { + return this.formBuilder.group({ + id: [id], + type: [type || '', Validators.required], + number: [number || '', KenyaPhoneNumberValidation], + }) + } + + get dateOfBirth() { + return this.userForm.get('dateOfBirth').value || new Date() + } + + get age() { + return new Date().getFullYear() - this.dateOfBirth.getFullYear() + } + + async save(form: FormGroup) { + this.userService + .updateUser(form.value) + .subscribe(res => this.buildUserForm(res), err => (this.userError = err)) + } } diff --git a/src/app/user/user-material.module.ts b/src/app/user/user-material.module.ts new file mode 100644 index 0000000..a656401 --- /dev/null +++ b/src/app/user/user-material.module.ts @@ -0,0 +1,37 @@ +import { NgModule } from '@angular/core' +import { + MatAutocompleteModule, + MatDatepickerModule, + MatLineModule, + MatNativeDateModule, + MatRadioModule, + MatStepperModule, + MatSelectModule, MatDividerModule, + +} from '@angular/material' + +@NgModule({ + declarations: [], + imports: [ + MatAutocompleteModule, + MatDatepickerModule, + MatDividerModule, + MatLineModule, + MatNativeDateModule, + MatRadioModule, + MatSelectModule, + MatStepperModule + ], + exports: [ + MatAutocompleteModule, + MatDatepickerModule, + MatDividerModule, + MatLineModule, + MatNativeDateModule, + MatRadioModule, + MatSelectModule, + MatStepperModule + ] +}) +export class UserMaterialModule { +} diff --git a/src/app/user/user.module.ts b/src/app/user/user.module.ts index 11fc4c4..12d26c1 100644 --- a/src/app/user/user.module.ts +++ b/src/app/user/user.module.ts @@ -1,11 +1,25 @@ import { CommonModule } from '@angular/common' import { NgModule } from '@angular/core' -import { UserRoutingModule } from './user-routing.module'; -import { ProfileComponent } from './profile/profile.component'; -import { LogoutComponent } from './logout/logout.component'; +import { UserRoutingModule } from './user-routing.module' +import { ProfileComponent } from './profile/profile.component' +import { LogoutComponent } from './logout/logout.component' +import { UserMaterialModule } from './user-material.module' +import { AppMaterialModule } from '../app.material.module' +import { FlexLayoutModule } from '@angular/flex-layout' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { SharedComponentsModule } from '../shared-components.module' @NgModule({ - declarations: [ProfileComponent, LogoutComponent], - imports: [CommonModule, UserRoutingModule], + declarations: [ ProfileComponent, LogoutComponent], + imports: [ + CommonModule, + UserRoutingModule, + UserMaterialModule, + AppMaterialModule, + FlexLayoutModule, + FormsModule, + ReactiveFormsModule, + SharedComponentsModule], }) -export class UserModule {} +export class UserModule { +} diff --git a/src/app/user/userModel/user.resolve.ts b/src/app/user/userModel/user.resolve.ts new file mode 100644 index 0000000..81c0847 --- /dev/null +++ b/src/app/user/userModel/user.resolve.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@angular/core' +import { ActivatedRouteSnapshot, Resolve } from '@angular/router' +import { UserInterface } from './user' +import { UserService } from './user.service' + +@Injectable() +export class UserResolve implements Resolve { + constructor(private userService: UserService) { + } + + resolve(route: ActivatedRouteSnapshot) { + return this.userService.getUser(route.paramMap.get('userId')) + } +} diff --git a/src/app/user/userModel/user.service.fake.ts b/src/app/user/userModel/user.service.fake.ts new file mode 100644 index 0000000..7f06ad8 --- /dev/null +++ b/src/app/user/userModel/user.service.fake.ts @@ -0,0 +1,31 @@ +import {Injectable} from '@angular/core' +import { UserServiceInterface, UsersInterface } from './user.service' +import { BehaviorSubject, Observable, of } from 'rxjs' +import { User, UserInterface } from './user' + +@Injectable() +export class UserServiceFake implements UserServiceInterface { + currentUser = new BehaviorSubject(new User()) + + constructor() { + } + + getCurrentUser(): Observable { + return of(new User()) + } + + getUser(id): Observable { + return of(new User((id = id))) + } + + updateUser(user: UserInterface): Observable { + return of(user) + } + + getUsers(pageSize: number, searchText: '', pagesToSkip = 0): Observable { + return of({ + total: 1, + items: [new User()], + } as UsersInterface) + } +} diff --git a/src/app/user/userModel/user.service.spec.ts b/src/app/user/userModel/user.service.spec.ts new file mode 100644 index 0000000..7a5d4cb --- /dev/null +++ b/src/app/user/userModel/user.service.spec.ts @@ -0,0 +1,21 @@ +import { TestBed } from '@angular/core/testing' + +import { UserService } from './user.service' +import { AuthService } from '../../auth/auth.service' +import { AuthServiceFake } from '../../auth/auth.service.fake' +import { HttpClientTestingModule } from '@angular/common/http/testing' + +describe('UserService', () => { + beforeEach(() => TestBed.configureTestingModule({ + providers: [ + UserService, + { provide: AuthService, useClass: AuthServiceFake } + ], + imports: [HttpClientTestingModule] + })) + + it('should be created', () => { + const service: UserService = TestBed.get(UserService) + expect(service).toBeTruthy() + }) +}) diff --git a/src/app/user/userModel/user.service.ts b/src/app/user/userModel/user.service.ts new file mode 100644 index 0000000..ffcd6e1 --- /dev/null +++ b/src/app/user/userModel/user.service.ts @@ -0,0 +1,85 @@ +import { Injectable } from '@angular/core' +import { CacheService } from '../../auth/cache.service' +import { BehaviorSubject, Observable, throwError } from 'rxjs' +import { User, UserInterface } from './user' +import { AuthService, AuthStatusInterface } from '../../auth/auth.service' +import { HttpClient } from '@angular/common/http' +import { catchError } from 'rxjs/operators' +import { transformError } from '../../common/common' +import { environment } from '../../../environments/environment' + +export interface UsersInterface { + items: UserInterface[] + total: number +} + +export interface UserServiceInterface { + currentUser: BehaviorSubject + getCurrentUser(): Observable + getUser(id): Observable + updateUser(user: UserInterface): Observable + getUsers(pageSize: number, searchText: string, pagesToSkip: number): + Observable +} + + +@Injectable({ + providedIn: 'root', +}) +export class UserService extends CacheService implements UserServiceInterface { + currentUser = new BehaviorSubject(this.getItem('user') || new User()) + private currentAuthStatus: AuthStatusInterface + + constructor(private httpClient: HttpClient, private authService: AuthService) { + super() + this.currentUser.subscribe(user => this.setItem('user', user)) + this.authService.authStatus.subscribe( + authStatus => (this.currentAuthStatus = authStatus), + ) + } + + getCurrentUser(): Observable { + const userObservable = this.getUser(this.currentAuthStatus.userId).pipe( + catchError(transformError), + ) + userObservable.subscribe( + user => this.currentUser.next(user), + err => throwError(err), + ) + return userObservable + } + + getUser(id): Observable { + return this.httpClient.get + (`${environment.baseUrl}/v1/user/${id}`) + } + + updateUser(user: UserInterface): Observable { + this.setItem('draft-user', user) // cache user data incase of errors + const updateResponse = this.httpClient + .put(`${environment.baseUrl}/v1/user/${user.id || 0}`, user) + .pipe(catchError(transformError)) + + updateResponse.subscribe( + res => { + this.currentUser.next(res) + this.removeItem('draft-user') + }, + err => throwError(err), + ) + return updateResponse + } + + getUsers(pageSize: number, searchText = '', pagesToSkip = 0): + Observable { + return this.httpClient.get(`${environment.baseUrl}/v1/users`, + { + params: { + search: searchText, + offset: pagesToSkip.toString(), + limit: pageSize.toString(), + }, + }) + } +} + diff --git a/src/app/user/userModel/user.ts b/src/app/user/userModel/user.ts new file mode 100644 index 0000000..1b4c5bc --- /dev/null +++ b/src/app/user/userModel/user.ts @@ -0,0 +1,70 @@ +import { Role } from '../../auth/role.enum' + +export interface UserInterface { + id: string + email: string + name: { + first: string + middle: string + last: string + } + picture: string + role: Role + userStatus: boolean + dateOfBirth: Date + address: { + line1: string + line2: string + city: string + county: string + zip: string + } + phone: PhoneInterface[] +} + +export interface PhoneInterface { + type: string + number: string + id: number +} + +export class User implements UserInterface { + constructor( + public id = '', + public email = '', + public name = { first: '', middle: '', last: '' }, + public picture = '', + public role = Role.None, + public dateOfBirth = null, + public userStatus = false, + public address = { + line1: '', + line2: '', + city: '', + county: '', + zip: '', + }, + public phone = [], + ) { + } + + static BuildUser(user: UserInterface) { + return new User( + user.id, + user.email, + user.name, + user.picture, + user.role, + user.dateOfBirth, + user.userStatus, + user.address, + user.phone, + ) + } + + get fullName() { + return `${this.name.first} ${this.name.middle} ${this.name.last} ` + } + + +} diff --git a/src/app/user/view-user/view-user.component.spec.ts b/src/app/user/view-user/view-user.component.spec.ts new file mode 100644 index 0000000..2184557 --- /dev/null +++ b/src/app/user/view-user/view-user.component.spec.ts @@ -0,0 +1,37 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing' + +import { ViewUserComponent } from './view-user.component' +import { ReactiveFormsModule } from '@angular/forms' +import { FlexLayoutModule } from '@angular/flex-layout' +import { AppMaterialModule } from '../../app.material.module' +import { UserMaterialModule } from '../user-material.module' +import { RouterTestingModule } from '@angular/router/testing' + +describe('ViewUserComponent', () => { + let component: ViewUserComponent + let fixture: ComponentFixture + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ ViewUserComponent ], + imports: [ + ReactiveFormsModule, + FlexLayoutModule, + AppMaterialModule, + UserMaterialModule, + RouterTestingModule + ] + }) + .compileComponents() + })) + + beforeEach(() => { + fixture = TestBed.createComponent(ViewUserComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) +}) diff --git a/src/app/user/view-user/view-user.component.ts b/src/app/user/view-user/view-user.component.ts new file mode 100644 index 0000000..8a465d7 --- /dev/null +++ b/src/app/user/view-user/view-user.component.ts @@ -0,0 +1,53 @@ +import { Component, Input, OnChanges, OnInit } from '@angular/core' +import { User, UserInterface } from '../userModel/user' +import { ActivatedRoute } from '@angular/router' + +@Component({ + selector: 'app-view-user', + template: ` + + +
+ account_circle +
+ {{currentUser.fullName}} + {{currentUser.role}} +
+ +

E-mail

+

{{currentUser.email}}

+

Date of Birth

+

{{currentUser.dateOfBirth | date:'mediumDate'}}

+
+ + + +
+ `, + styles: [ + ` + .bold { + font-weight: bold; + }`, + ], +}) +export class ViewUserComponent implements OnChanges, OnInit { + @Input() user: UserInterface + currentUser = new User() + + constructor(private route: ActivatedRoute) { + } + ngOnChanges() { + if (this.user) { + this.currentUser = User.BuildUser(this.user) + } + } + + ngOnInit() { + if (this.route.snapshot && this.route.snapshot.data['user']) { + this.currentUser = User.BuildUser(this.route.snapshot.data['user']) + this.currentUser.dateOfBirth = Date.now() // for data mocking purposes + } + } + +} diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index 3612073..b53709d 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -1,3 +1,5 @@ export const environment = { - production: true -}; + production: true, + baseUrl: 'http://localhost:3000' + +} diff --git a/src/environments/environment.ts b/src/environments/environment.ts index f61201c..2c03b39 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -3,7 +3,8 @@ // The list of file replacements can be found in `angular.json`. export const environment = { - production: false + production: false, + baseUrl: 'http://localhost:3000' } /* @@ -13,4 +14,4 @@ export const environment = { * This import should be commented out in production mode because it will have a negative impact * on performance if an error is thrown. */ - import 'zone.js/dist/zone-error' // Included with Angular CLI. +import 'zone.js/dist/zone-error' // Included with Angular CLI. diff --git a/src/index.html b/src/index.html index a106556..d906388 100644 --- a/src/index.html +++ b/src/index.html @@ -13,7 +13,7 @@ - + diff --git a/src/site.webmanifest b/src/site.webmanifest index b20abb7..2729cf2 100644 --- a/src/site.webmanifest +++ b/src/site.webmanifest @@ -1,6 +1,6 @@ { - "name": "", - "short_name": "", + "name": "soko-bora", + "short_name": "soko-bora", "icons": [ { "src": "/android-chrome-192x192.png",