diff --git a/package-lock.json b/package-lock.json index eaec87f61..4e4cd152d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1260,6 +1260,21 @@ "integrity": "sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw==", "dev": true }, + "@ngrx/effects": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@ngrx/effects/-/effects-9.0.0.tgz", + "integrity": "sha512-6Rq7FsNZK26HqYlpOGCglKLenIkVOOKE0y6D/8KXjEJ1JlZWi00fdI7poclBGjm9pvMBGXfJA8a9MKuxb/t9cA==" + }, + "@ngrx/store": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@ngrx/store/-/store-9.0.0.tgz", + "integrity": "sha512-QnmfXJ4G2jp+vFaqT5Qfp6h0J9OHxfDKI2RbnMU93Tq1Xd/WVPzXnOQGjILBjwwWI6RFkSdIpUoQONr7VOW63g==" + }, + "@ngrx/store-devtools": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@ngrx/store-devtools/-/store-devtools-9.0.0.tgz", + "integrity": "sha512-Vj8sj8GclbSbnYCS8eqZXTOYDdip1nnjKhkYClUg2oFPh67haaCmvh7TXITnX8PpgDtj5akF84Xw9/1HRiG8mg==" + }, "@ngtools/webpack": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-9.0.5.tgz", @@ -7210,20 +7225,12 @@ } }, "mkdirp": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", - "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.4.tgz", + "integrity": "sha512-iG9AK/dJLtJ0XNgTuDbSyNS3zECqDlAhnQW4CsNxBG3LQJBbHmRX1egw39DmtOdCAqY+dKXV+sgPgilNWUKMVw==", "dev": true, "requires": { - "minimist": "0.0.8" - }, - "dependencies": { - "minimist": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", - "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", - "dev": true - } + "minimist": "^1.2.5" } }, "move-concurrently": { diff --git a/package.json b/package.json index deb40e2ce..8e55ac927 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,9 @@ "@angular/platform-browser": "~9.0.3", "@angular/platform-browser-dynamic": "~9.0.3", "@angular/router": "~9.0.3", + "@ngrx/effects": "^9.0.0", + "@ngrx/store": "^9.0.0", + "@ngrx/store-devtools": "^9.0.0", "bootstrap": "^4.4.1", "jquery": "^3.4.1", "minimist": "^1.2.5", diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 7bdac1471..fbf000e9d 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -3,6 +3,9 @@ import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { HttpClientModule } from '@angular/common/http'; +import { StoreModule } from '@ngrx/store'; +import { EffectsModule } from '@ngrx/effects'; +import { StoreDevtoolsModule } from '@ngrx/store-devtools'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; @@ -28,6 +31,8 @@ import { FilterProjectPipe } from './modules/shared/pipes/filter-project/filter- import { SearchProjectComponent } from './modules/shared/components/search-project/search-project.component'; import { HomeComponent } from './modules/home/home.component'; import { LoginComponent } from './modules/login/login.component'; +import { ActivityEffects } from './modules/activities-management/store/activity-management.effects'; +import { activityManagementReducer } from './modules/activities-management/store'; @NgModule({ declarations: [ @@ -56,7 +61,19 @@ import { LoginComponent } from './modules/login/login.component'; FilterProjectPipe, SearchProjectComponent, ], - imports: [CommonModule, BrowserModule, AppRoutingModule, FormsModule, ReactiveFormsModule, HttpClientModule], + imports: [ + CommonModule, + BrowserModule, + AppRoutingModule, + FormsModule, + ReactiveFormsModule, + HttpClientModule, + StoreModule.forRoot({ activities: activityManagementReducer }), + EffectsModule.forRoot([ActivityEffects]), + StoreDevtoolsModule.instrument({ + maxAge: 15, // Retains last 15 states + }), + ], providers: [], bootstrap: [AppComponent], }) diff --git a/src/app/modules/activities-management/components/activity-list/activity-list.component.spec.ts b/src/app/modules/activities-management/components/activity-list/activity-list.component.spec.ts index 4d660e1c2..fe4ade315 100644 --- a/src/app/modules/activities-management/components/activity-list/activity-list.component.spec.ts +++ b/src/app/modules/activities-management/components/activity-list/activity-list.component.spec.ts @@ -1,16 +1,29 @@ -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideMockStore, MockStore } from '@ngrx/store/testing'; +import { allActivities } from './../../store/activity-management.selectors'; +import { ActivityState } from './../../store/activity-management.reducers'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivityListComponent } from './activity-list.component'; describe('ActivityListComponent', () => { let component: ActivityListComponent; let fixture: ComponentFixture; + let mockActivitiesSelector; + + const state = { data: [{id: 'id', name: 'name', description: 'description'}], isLoading: false, message: '' }; + + let store: MockStore; beforeEach(async(() => { TestBed.configureTestingModule({ - declarations: [ ActivityListComponent ] + declarations: [ ActivityListComponent ], + providers: [ provideMockStore({ initialState: state }) ] }) .compileComponents(); + + store = TestBed.inject(MockStore); + + mockActivitiesSelector = store.overrideSelector( allActivities, state ); })); beforeEach(() => { @@ -22,4 +35,21 @@ describe('ActivityListComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('onInit, LoadActivities action is dispatched', () => { + spyOn(store, 'dispatch'); + + component.ngOnInit(); + + expect(store.dispatch).toHaveBeenCalled(); + }); + + it('onInit, activities field is populated with data from store', () => { + component.ngOnInit(); + + expect(component.activities).toBe(state.data); + }); + + afterEach(() => { fixture.destroy(); }); + }); diff --git a/src/app/modules/activities-management/components/activity-list/activity-list.component.ts b/src/app/modules/activities-management/components/activity-list/activity-list.component.ts index cb641d6e1..e26ba949d 100644 --- a/src/app/modules/activities-management/components/activity-list/activity-list.component.ts +++ b/src/app/modules/activities-management/components/activity-list/activity-list.component.ts @@ -1,16 +1,28 @@ -import { Input } from '@angular/core'; +import { Input, OnInit } from '@angular/core'; import { Component } from '@angular/core'; +import { Store, select } from '@ngrx/store'; + +import { allActivities } from '../../store'; +import { LoadActivities } from './../../store/activity-management.actions'; +import { ActivityState } from './../../store/activity-management.reducers'; import { Activity } from '../../../shared/models'; -@Component({ - selector: 'app-activity-list', - templateUrl: './activity-list.component.html', - styleUrls: ['./activity-list.component.scss'] -}) -export class ActivityListComponent { +@Component({selector: 'app-activity-list', templateUrl: './activity-list.component.html', styleUrls: ['./activity-list.component.scss']}) +export class ActivityListComponent implements OnInit { + + @Input()activities: Activity[] = []; + public isLoading: boolean; + + constructor(private store: Store) { } - @Input() activities: Activity[] = []; + ngOnInit() { + this.store.dispatch(new LoadActivities()); + const activities$ = this.store.pipe(select(allActivities)); - constructor() { } + activities$.subscribe(response => { + this.isLoading = response.isLoading; + this.activities = response.data; + }); + } } diff --git a/src/app/modules/activities-management/services/activity.service.spec.ts b/src/app/modules/activities-management/services/activity.service.spec.ts index de5817160..a7b42ac73 100644 --- a/src/app/modules/activities-management/services/activity.service.spec.ts +++ b/src/app/modules/activities-management/services/activity.service.spec.ts @@ -1,5 +1,6 @@ import { TestBed, inject } from '@angular/core/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + import { Activity } from '../../shared/models'; import { ActivityService } from './activity.service'; @@ -28,14 +29,15 @@ describe('Activity Service', () => { expect(httpClient).toBeTruthy(); })); - it('activities are read using GET from assets/activities.json URL', () => { + it('activities are read using GET from baseUrl', () => { const activitiesFoundSize = activities.length; + service.baseUrl = 'foo'; service .getActivities() .subscribe(activitiesInResponse => { expect(activitiesInResponse.length).toBe(activitiesFoundSize); }); - const getActivitiesRequest = httpMock.expectOne('assets/activities.json'); + const getActivitiesRequest = httpMock.expectOne(service.baseUrl); expect(getActivitiesRequest.request.method).toBe('GET'); getActivitiesRequest.flush(activities); }); diff --git a/src/app/modules/activities-management/services/activity.service.ts b/src/app/modules/activities-management/services/activity.service.ts index 9d1ce6734..a0e24397c 100644 --- a/src/app/modules/activities-management/services/activity.service.ts +++ b/src/app/modules/activities-management/services/activity.service.ts @@ -1,6 +1,8 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; + +import { environment } from './../../../../environments/environment'; import { Activity } from '../../shared/models'; @Injectable({ @@ -8,11 +10,11 @@ import { Activity } from '../../shared/models'; }) export class ActivityService { - url = 'assets/activities.json'; + baseUrl = `${environment.timeTrackerApiUrl}/activities`; constructor(private http: HttpClient) {} getActivities(): Observable { - return this.http.get(this.url); + return this.http.get(this.baseUrl); } } diff --git a/src/app/modules/activities-management/store/activity-management.actions.spec.ts b/src/app/modules/activities-management/store/activity-management.actions.spec.ts new file mode 100644 index 000000000..c776309e2 --- /dev/null +++ b/src/app/modules/activities-management/store/activity-management.actions.spec.ts @@ -0,0 +1,16 @@ +import { LoadActivitiesFail } from './activity-management.actions'; +import { LoadActivitiesSuccess, ActivityManagementActionTypes } from './activity-management.actions'; + +describe('LoadActivitiesSuccess', () => { + + it('LoadActivitiesSuccess type is ActivityManagementActionTypes.LoadActivitiesSuccess', () => { + const loadActivitiesSuccess = new LoadActivitiesSuccess([]); + expect(loadActivitiesSuccess.type).toEqual(ActivityManagementActionTypes.LoadActivitiesSuccess); + }); + + it('LoadActivitiesFail type is ActivityManagementActionTypes.LoadActivitiesFail', () => { + const loadActivitiesFail = new LoadActivitiesFail('error'); + expect(loadActivitiesFail.type).toEqual(ActivityManagementActionTypes.LoadActivitiesFail); + }); + +}); diff --git a/src/app/modules/activities-management/store/activity-management.actions.ts b/src/app/modules/activities-management/store/activity-management.actions.ts new file mode 100644 index 000000000..b1b500830 --- /dev/null +++ b/src/app/modules/activities-management/store/activity-management.actions.ts @@ -0,0 +1,28 @@ +import { Action } from '@ngrx/store'; + +import { Activity } from './../../shared/models/activity.model'; + +export enum ActivityManagementActionTypes { + LoadActivities = '[ActivityManagement] Load Activities', + LoadActivitiesSuccess = '[ActivityManagement] Load Activities Successs', + LoadActivitiesFail = '[ActivityManagement] Load Activities Fail', +} + + +export class LoadActivities implements Action { + public readonly type = ActivityManagementActionTypes.LoadActivities; +} + +export class LoadActivitiesSuccess implements Action { + public readonly type = ActivityManagementActionTypes.LoadActivitiesSuccess; + + constructor(public payload: Activity[]) { } +} + +export class LoadActivitiesFail implements Action { + public readonly type = ActivityManagementActionTypes.LoadActivitiesFail; + + constructor(public error) { } +} + +export type ActivityManagementActions = LoadActivities | LoadActivitiesSuccess | LoadActivitiesFail; diff --git a/src/app/modules/activities-management/store/activity-management.effects.ts b/src/app/modules/activities-management/store/activity-management.effects.ts new file mode 100644 index 000000000..c6f42e9d9 --- /dev/null +++ b/src/app/modules/activities-management/store/activity-management.effects.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@angular/core'; +import { Actions, Effect, ofType } from '@ngrx/effects'; +import { Action } from '@ngrx/store'; +import { Observable, of } from 'rxjs'; +import { catchError, map, mergeMap } from 'rxjs/operators'; + +import { ActivityManagementActionTypes, LoadActivitiesSuccess, LoadActivitiesFail } from './activity-management.actions'; +import { Activity } from './../../shared/models/activity.model'; +import { ActivityService } from './../services/activity.service'; + +@Injectable() +export class ActivityEffects { + + constructor(private actions$: Actions, private activityService: ActivityService) { } + + @Effect() + getActivities$: Observable = this.actions$.pipe( + ofType(ActivityManagementActionTypes.LoadActivities), + mergeMap(() => + this.activityService.getActivities().pipe( + map((activities: Activity[]) => { + return new LoadActivitiesSuccess(activities); + }), + catchError((error) => + of(new LoadActivitiesFail(error))) + ) + )); +} diff --git a/src/app/modules/activities-management/store/activity-management.reducers.spec.ts b/src/app/modules/activities-management/store/activity-management.reducers.spec.ts new file mode 100644 index 000000000..8f6294111 --- /dev/null +++ b/src/app/modules/activities-management/store/activity-management.reducers.spec.ts @@ -0,0 +1,34 @@ +import { Activity } from './../../shared/models/activity.model'; +import { LoadActivitiesFail, LoadActivities } from './activity-management.actions'; +import { LoadActivitiesSuccess } from './activity-management.actions'; +import { activityManagementReducer, ActivityState } from './activity-management.reducers'; + +describe('activityManagementReducer', () => { + const initialState: ActivityState = { data: [], isLoading: false, message: '' }; + + it('on LoadActivities, isLoading is true', () => { + const action = new LoadActivities(); + + const state = activityManagementReducer(initialState, action); + + expect(state.isLoading).toEqual(true); + }); + + it('on LoadActivitiesSuccess, activitiesFound are saved in the store', () => { + const activitiesFound: Activity[] = [{id: '', name: '', description: ''}]; + const action = new LoadActivitiesSuccess(activitiesFound); + + const state = activityManagementReducer(initialState, action); + + expect(state.data).toEqual(activitiesFound); + }); + + it('on LoadActivitiesFail, message equal to Something went wrong fetching activities!', () => { + const action = new LoadActivitiesFail('error'); + + const state = activityManagementReducer(initialState, action); + + expect(state.message).toEqual('Something went wrong fetching activities!'); + }); + +}); diff --git a/src/app/modules/activities-management/store/activity-management.reducers.ts b/src/app/modules/activities-management/store/activity-management.reducers.ts new file mode 100644 index 000000000..060a4978c --- /dev/null +++ b/src/app/modules/activities-management/store/activity-management.reducers.ts @@ -0,0 +1,38 @@ +import {ActivityManagementActions, ActivityManagementActionTypes} from './activity-management.actions'; +import {Activity} from './../../shared/models/activity.model'; + +export interface ActivityState { + data: Activity[]; + isLoading: boolean; + message: string; +} + +const initialState: ActivityState = { + data: [], + isLoading: false, + message: '' +}; + +export function activityManagementReducer(state = initialState, action: ActivityManagementActions): ActivityState { + + switch (action.type) { + case(ActivityManagementActionTypes.LoadActivities): { + return { + ...state, + isLoading: true + }; + } + + case ActivityManagementActionTypes.LoadActivitiesSuccess: { + return { + ...state, + data: action.payload, + isLoading: false, + message: 'Data fetch successfully!' + }; + } + case ActivityManagementActionTypes.LoadActivitiesFail: { + return { data: [], isLoading: false, message: 'Something went wrong fetching activities!' }; + } + } +} diff --git a/src/app/modules/activities-management/store/activity-management.selectors.ts b/src/app/modules/activities-management/store/activity-management.selectors.ts new file mode 100644 index 000000000..a016d3267 --- /dev/null +++ b/src/app/modules/activities-management/store/activity-management.selectors.ts @@ -0,0 +1,9 @@ +import { createFeatureSelector, createSelector } from '@ngrx/store'; + +import { ActivityState } from './activity-management.reducers'; + +const getActivityState = createFeatureSelector('activities'); + +export const allActivities = createSelector(getActivityState, (state: ActivityState) => { + return state; +}); diff --git a/src/app/modules/activities-management/store/index.ts b/src/app/modules/activities-management/store/index.ts new file mode 100644 index 000000000..2f3a93e9d --- /dev/null +++ b/src/app/modules/activities-management/store/index.ts @@ -0,0 +1,3 @@ +export * from './activity-management.actions'; +export * from './activity-management.reducers'; +export * from './activity-management.selectors'; diff --git a/src/assets/activities.json b/src/assets/activities.json deleted file mode 100644 index 4282c8fd6..000000000 --- a/src/assets/activities.json +++ /dev/null @@ -1,12 +0,0 @@ -[ - { - "id": "1234-9873-9999", - "name": "Development", - "description": "Generic activity for developers" - }, - { - "id": "3444-2311-6655", - "name": "Meeting", - "description": "Any meeting on-line or on-site" - } -] diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 3d84f6290..6d273def6 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -4,7 +4,8 @@ import * as keys from './.keys.json'; export const environment = { - production: false + production: false, + timeTrackerApiUrl: 'https://timetracker-api.azurewebsites.net' }; export const AUTHORITY = keys.authority;