From 8b44b2400dcdda249354637ab2306fa50e361d2a Mon Sep 17 00:00:00 2001 From: Sascha Grunert Date: Wed, 7 Aug 2019 12:25:56 +0200 Subject: [PATCH] Update data flow to use redux Signed-off-by: Sascha Grunert --- src/app/app.module.ts | 6 +- src/app/app.reducer.ts | 3 + src/app/filter/filter.actions.ts | 26 +++++++++ src/app/filter/filter.component.html | 2 +- src/app/filter/filter.component.ts | 55 +++++++++++++++--- src/app/filter/filter.effects.ts | 24 ++++++++ src/app/filter/filter.reducer.ts | 41 ++++++++++++++ src/app/main/main.component.html | 14 +---- src/app/main/main.component.spec.ts | 31 ---------- src/app/main/main.component.ts | 81 +++++++++------------------ src/app/notes/notes.component.ts | 39 +++++++------ src/app/shared/model/options.model.ts | 9 +++ 12 files changed, 206 insertions(+), 125 deletions(-) create mode 100644 src/app/filter/filter.actions.ts create mode 100644 src/app/filter/filter.effects.ts create mode 100644 src/app/filter/filter.reducer.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 2b0499f6..b044ccb1 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -17,10 +17,12 @@ import { LoggerService } from '@shared/services/logger.service'; import { EffectsModule } from '@ngrx/effects'; import { NotesEffects } from './notes/notes.effects'; +import { FilterEffects } from './filter/filter.effects'; import { StoreDevtoolsModule } from '@ngrx/store-devtools'; import { StoreModule } from '@ngrx/store'; import { environment } from '../environments/environment'; import { notesReducer } from './notes/notes.reducer'; +import { filterReducer } from './filter/filter.reducer'; import { reducers } from './app.reducer'; import { MarkdownModule, MarkedOptions } from 'ngx-markdown'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; @@ -48,8 +50,8 @@ import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; }, }, }), - StoreModule.forRoot({ notes: notesReducer }), - EffectsModule.forRoot([NotesEffects]), + StoreModule.forRoot({ filter: filterReducer, notes: notesReducer }), + EffectsModule.forRoot([FilterEffects, NotesEffects]), StoreDevtoolsModule.instrument({ maxAge: 25, logOnly: environment.production }), FontAwesomeModule, ], diff --git a/src/app/app.reducer.ts b/src/app/app.reducer.ts index 0284ca9c..e51e478a 100644 --- a/src/app/app.reducer.ts +++ b/src/app/app.reducer.ts @@ -6,11 +6,14 @@ import { MetaReducer, } from '@ngrx/store'; import { State as NotesState, notesReducer } from './notes/notes.reducer'; +import { State as FilterState, filterReducer } from './filter/filter.reducer'; export interface State { + filter: FilterState; notes: NotesState; } export const reducers: ActionReducerMap = { + filter: filterReducer, notes: notesReducer, }; diff --git a/src/app/filter/filter.actions.ts b/src/app/filter/filter.actions.ts new file mode 100644 index 00000000..51c39b53 --- /dev/null +++ b/src/app/filter/filter.actions.ts @@ -0,0 +1,26 @@ +import { Action } from '@ngrx/store'; +import { Filter } from '@app/shared/model/options.model'; + +export enum ActionTypes { + Failed = '[Filter Component] Failed', + + UpdateFilter = '[Filter Component] Update Filter', + UpdateFilterSuccess = '[Filter Component] Update Filter Success', +} + +export class Failed implements Action { + readonly type = ActionTypes.Failed; + constructor(public error: string) {} +} + +export class UpdateFilter implements Action { + readonly type = ActionTypes.UpdateFilter; + constructor(public filter: Filter) {} +} + +export class UpdateFilterSuccess implements Action { + readonly type = ActionTypes.UpdateFilterSuccess; + constructor(public filter: Filter) {} +} + +export type FilterAction = Failed | UpdateFilter | UpdateFilterSuccess; diff --git a/src/app/filter/filter.component.html b/src/app/filter/filter.component.html index c4cff464..ed4b73b7 100644 --- a/src/app/filter/filter.component.html +++ b/src/app/filter/filter.component.html @@ -8,7 +8,7 @@ type="checkbox" name="{{ data.key }}[]" [ngModel]="filter[data.key][a.value]" - (ngModelChange)="updateFilterObject(data.key, a.value, $event)" + (ngModelChange)="updateFilter(data.key, a.value, $event)" value="{{ a.value }}" /> {{ a.value }} (); + private options: Options = new Options(); + private filter: Filter = new Filter(); + + /** + * FilterComponent's constructor + */ + constructor(private store: Store) { + this.store.pipe(select(getAllNotesSelector)).subscribe(n => { + this.updateOptions(n); + }); + + this.store.pipe(select(getFilterSelector)).subscribe(filter => { + this.filter = filter; + }); + } + + /** + * Update the options from the provided notes + */ + updateOptions(notes: Note[]): void { + for (const note of Object.values(notes)) { + if ('areas' in note) { + this.options.areas = [...new Set(this.options.areas.concat(note.areas))]; + } + if ('kinds' in note) { + this.options.kinds = [...new Set(this.options.kinds.concat(note.kinds))]; + } + if ('sigs' in note) { + this.options.sigs = [...new Set(this.options.sigs.concat(note.sigs))]; + } + if (this.options.releaseVersions.indexOf(note.release_version) < 0) { + this.options.releaseVersions.push(note.release_version); + } + } + } /** * Update the filter object based on the given parameters */ - updateFilterObject(a: string, b: string, val: any): void { - if (val) { - this.filter.add(a, b); + updateFilter(key: string, value: string, event: any): void { + if (event) { + this.filter.add(key, value); } else { - delete this.filter[a][b]; + this.filter.del(key, value); } - this.filterUpdate.emit(this.filter); + this.store.dispatch(new UpdateFilter(this.filter)); } /** diff --git a/src/app/filter/filter.effects.ts b/src/app/filter/filter.effects.ts new file mode 100644 index 00000000..8ce83913 --- /dev/null +++ b/src/app/filter/filter.effects.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core'; +import { Actions, Effect, ofType } from '@ngrx/effects'; +import { of } from 'rxjs'; +import { exhaustMap, map } from 'rxjs/operators'; +import { ActionTypes, UpdateFilterSuccess, UpdateFilter } from './filter.actions'; +import { LoggerService } from '@shared/services/logger.service'; +import { Filter } from '@app/shared/model/options.model'; + +@Injectable() +export class FilterEffects { + @Effect() + updateFilter$ = this.actions$.pipe( + ofType(ActionTypes.UpdateFilter), + map((action: UpdateFilter) => action.filter), + exhaustMap(filter => { + this.logger.debug('[Filter Effects:UpdateFilter] SUCCESS'); + const copy = new (filter.constructor as { new () })(); + Object.assign(copy, filter); + return of(new UpdateFilterSuccess(copy)); + }), + ); + + constructor(private actions$: Actions, private logger: LoggerService) {} +} diff --git a/src/app/filter/filter.reducer.ts b/src/app/filter/filter.reducer.ts new file mode 100644 index 00000000..2fac1685 --- /dev/null +++ b/src/app/filter/filter.reducer.ts @@ -0,0 +1,41 @@ +import { Action, createSelector, createFeatureSelector } from '@ngrx/store'; +import { ActionTypes, FilterAction } from './filter.actions'; +import { Filter } from '@app/shared/model/options.model'; +import { State as RootState } from '@app/app.reducer'; + +export interface State { + error: string | null; + filter: Filter; +} + +export const initialState: State = { + error: null, + filter: new Filter(), +}; + +export function filterReducer(state = initialState, action: FilterAction): State { + switch (action.type) { + case ActionTypes.UpdateFilterSuccess: { + return { + ...state, + filter: action.filter, + }; + } + + case ActionTypes.Failed: { + return { + ...state, + error: action.error, + }; + } + + default: + return state; + } +} + +export const selectFilter = (state: RootState) => state.filter; +export const getFilterSelector = createSelector( + selectFilter, + (state: State) => state.filter, +); diff --git a/src/app/main/main.component.html b/src/app/main/main.component.html index da0991ad..fedec3a5 100644 --- a/src/app/main/main.component.html +++ b/src/app/main/main.component.html @@ -37,12 +37,7 @@
@@ -50,12 +45,7 @@
- +
diff --git a/src/app/main/main.component.spec.ts b/src/app/main/main.component.spec.ts index 645b2509..a37773ca 100644 --- a/src/app/main/main.component.spec.ts +++ b/src/app/main/main.component.spec.ts @@ -76,37 +76,6 @@ describe('MainComponent', () => { expect(component.filter.areas).toBe(''); }); - it('should succeed to update options on gotNotes', () => { - component.gotNotes(notesMock); - expect(component.options.areas.length).toEqual(1); - expect(component.options.kinds.length).toEqual(1); - expect(component.options.sigs.length).toEqual(1); - expect(component.options.releaseVersions.length).toEqual(1); - }); - - it('should succeed to update options on empty/invalid notes', () => { - component.gotNotes([{} as Note]); - expect(component.options.areas.length).toEqual(0); - expect(component.options.kinds.length).toEqual(0); - expect(component.options.sigs.length).toEqual(0); - expect(component.options.releaseVersions.length).toEqual(1); - }); - - it('should succeed to toggle filter to true', () => { - const event = { key: areas, value: b }; - component.onUpdateFromNotesComponent(event); - expect(component.filter[areas][b]).toBeTruthy(); - }); - - it('should succeed to toggle filter to false', () => { - const event = { key: areas, value: b }; - component.onUpdateFromNotesComponent(event); - expect(component.filter[areas][b]).toBeTruthy(); - - component.onUpdateFromNotesComponent(event); - expect(component.filter[areas][b]).toBeFalsy(); - }); - it('should succeed to open the modal view', () => { component.openModal(); }); diff --git a/src/app/main/main.component.ts b/src/app/main/main.component.ts index 057410e6..4fda21d2 100644 --- a/src/app/main/main.component.ts +++ b/src/app/main/main.component.ts @@ -1,46 +1,54 @@ -import { Component, ViewChild, ElementRef, OnInit } from '@angular/core'; +import { Component, ViewChild, ElementRef } from '@angular/core'; +import { Store, select } from '@ngrx/store'; +import { skip } from 'rxjs/operators'; +import { State } from '@app/app.reducer'; import { Note } from '@app/notes/notes.model'; -import { Filter, Options } from '@app/shared/model/options.model'; +import { Filter } from '@app/shared/model/options.model'; import { NotesComponent } from '@app/notes/notes.component'; import { FilterComponent } from '@app/filter/filter.component'; +import { UpdateFilter } from '@app/filter/filter.actions'; import { ModalComponent } from '@app/modal/modal.component'; import { Router, ActivatedRoute } from '@angular/router'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { getFilterSelector } from '@app/filter/filter.reducer'; @Component({ selector: 'app-main', templateUrl: './main.component.html', }) -export class MainComponent implements OnInit { - options: Options = new Options(); - filter: Filter = new Filter(); +export class MainComponent { + private filter: Filter = new Filter(); @ViewChild(NotesComponent, { static: true }) noteChild; @ViewChild(FilterComponent, { static: true }) filterChild; constructor( + private store: Store, private router: Router, private route: ActivatedRoute, private modalService: NgbModal, - ) {} - - ngOnInit() { + ) { this.route.queryParamMap.subscribe(queryParamMap => { - if ('filter' in this && this.filter.isEmpty()) { - for (const i of Object.keys(this.filter)) { - if (queryParamMap.getAll(i).length > 0) { - if (i !== 'markdown') { - for (const x of queryParamMap.getAll(i)) { - this.filter.add(i, x); - } - } else { - this.filter.setMarkdown(queryParamMap.get(i)); - } + for (let i = 0, len = queryParamMap.keys.length; i < len; i++) { + const key = queryParamMap.keys[i]; + if (key !== 'markdown') { + for (const value of queryParamMap.getAll(key)) { + this.filter.add(key, value); } + } else { + this.filter.setMarkdown(queryParamMap.get(key)); } - this.noteChild.update(this.filter); } }); + this.store.dispatch(new UpdateFilter(this.filter)); + + this.store + .pipe(select(getFilterSelector)) + .pipe(skip(1)) + .subscribe(filter => { + this.filter = filter; + this.updateURI(); + }); } updateFilterString(a, b): void { @@ -49,41 +57,6 @@ export class MainComponent implements OnInit { } else { this.filter[a] = ''; } - this.noteChild.update(this.filter); - - this.updateURI(); - } - - gotNotes(notes: Note[]): void { - for (const note of Object.values(notes)) { - if ('areas' in note) { - this.options.areas = [...new Set(this.options.areas.concat(note.areas))]; - } - if ('kinds' in note) { - this.options.kinds = [...new Set(this.options.kinds.concat(note.kinds))]; - } - if ('sigs' in note) { - this.options.sigs = [...new Set(this.options.sigs.concat(note.sigs))]; - } - if (this.options.releaseVersions.indexOf(note.release_version) < 0) { - this.options.releaseVersions.push(note.release_version); - } - } - } - - onUpdateFromFilterComponent(filter: Filter): void { - this.filter = filter; - this.noteChild.update(this.filter); - this.updateURI(); - } - - onUpdateFromNotesComponent(event: any): void { - if (typeof this.filter[event.key][event.value] === 'boolean') { - delete this.filter[event.key][event.value]; - } else { - this.filter.add(event.key, event.value); - } - this.updateURI(); } diff --git a/src/app/notes/notes.component.ts b/src/app/notes/notes.component.ts index 0495bfd1..37bdfe89 100644 --- a/src/app/notes/notes.component.ts +++ b/src/app/notes/notes.component.ts @@ -6,6 +6,8 @@ import { State } from '@app/app.reducer'; import { getAllNotesSelector, getFilteredNotesSelector } from './notes.reducer'; import { Filter } from '@app/shared/model/options.model'; import { faBook } from '@fortawesome/free-solid-svg-icons'; +import { UpdateFilter } from '@app/filter/filter.actions'; +import { getFilterSelector } from '@app/filter/filter.reducer'; @Component({ selector: 'app-notes', @@ -14,22 +16,19 @@ import { faBook } from '@fortawesome/free-solid-svg-icons'; styleUrls: ['./notes.component.scss'], }) export class NotesComponent { - @Input() filter: Filter; - @Output() filterUpdate = new EventEmitter(); - @Output() gotNotes = new EventEmitter(); - allNotes: Note[]; - filteredNotes: Note[]; - p = 1; - faBook = faBook; + private filter: Filter = new Filter(); + private allNotes: Note[] = []; + private filteredNotes: Note[] = []; + private p = 1; + private faBook = faBook; constructor(private store: Store) { - store.dispatch(new GetNotes()); + this.store.dispatch(new GetNotes()); - this.store.pipe(select(getAllNotesSelector)).subscribe(n => { + store.pipe(select(getAllNotesSelector)).subscribe(n => { // Initial retrieval of the notes this.allNotes = n; this.filteredNotes = n; - this.gotNotes.emit(n); this.store.dispatch(new DoFilter(this.allNotes, this.filter)); }); @@ -38,15 +37,23 @@ export class NotesComponent { // Filter update of the notes this.filteredNotes = n; }); - } - public update(filter: Filter) { - this.filter = filter; - this.store.dispatch(new DoFilter(this.allNotes, filter)); + this.store.pipe(select(getFilterSelector)).subscribe(filter => { + this.filter = filter; + this.store.dispatch(new DoFilter(this.allNotes, filter)); + }); } - public toggleFilter(key, value) { - this.filterUpdate.emit({ key, value }); + /** + * Add or remove a filter element based on its state + */ + public toggleFilter(key: string, value: string): void { + if (typeof this.filter[key][value] === 'boolean') { + this.filter.del(key, value); + } else { + this.filter.add(key, value); + } + this.store.dispatch(new UpdateFilter(this.filter)); this.store.dispatch(new DoFilter(this.allNotes, this.filter)); } diff --git a/src/app/shared/model/options.model.ts b/src/app/shared/model/options.model.ts index 8285aaf1..2c6c21b3 100644 --- a/src/app/shared/model/options.model.ts +++ b/src/app/shared/model/options.model.ts @@ -89,4 +89,13 @@ export class Filter extends Options { public add(key: string, value: string): void { this[key][value] = true; } + + /** + * Method to delete a value and a key + * + * @returns void + */ + public del(key: string, value: string): void { + delete this[key][value]; + } }