diff --git a/apps/dashboard/src/app/widgets/widgets.component.html b/apps/dashboard/src/app/widgets/widgets.component.html index de6d7e4..f7767e4 100644 --- a/apps/dashboard/src/app/widgets/widgets.component.html +++ b/apps/dashboard/src/app/widgets/widgets.component.html @@ -6,7 +6,7 @@
- diff --git a/apps/dashboard/src/app/widgets/widgets.component.ts b/apps/dashboard/src/app/widgets/widgets.component.ts index f930d6d..cc4930e 100644 --- a/apps/dashboard/src/app/widgets/widgets.component.ts +++ b/apps/dashboard/src/app/widgets/widgets.component.ts @@ -1,6 +1,7 @@ import { Component, OnInit } from '@angular/core'; import { Widget } from '@fem/api-interfaces'; import { WidgetsService } from '@fem/core-data'; +import { WidgetsFacade } from '@fem/core-state'; import { Observable } from 'rxjs'; const emptyWidget: Widget = { @@ -15,10 +16,10 @@ const emptyWidget: Widget = { styleUrls: ['./widgets.component.scss'], }) export class WidgetsComponent implements OnInit { - widgets$: Observable; - selectedWidget: Widget; + widgets$: Observable = this.widgetsFacade.allWidget$; + selectedWidget$: Observable = this.widgetsFacade.selectedWidget$; - constructor(private widgetsService: WidgetsService) {} + constructor(private widgetsFacade: WidgetsFacade ) {} ngOnInit(): void { this.reset(); @@ -30,15 +31,15 @@ export class WidgetsComponent implements OnInit { } resetForm() { - this.selectedWidget = emptyWidget; + this.selectWidget(emptyWidget); } selectWidget(widget: Widget) { - this.selectedWidget = widget; + this.widgetsFacade.selectWidget(emptyWidget); } loadWidgets() { - this.widgets$ = this.widgetsService.all(); + this.widgetsFacade.loadWidgets(); } saveWidget(widget: Widget) { @@ -50,14 +51,14 @@ export class WidgetsComponent implements OnInit { } createWidget(widget: Widget) { - this.widgetsService.create(widget).subscribe((result) => this.reset()); + // this.widgetsService.create(widget).subscribe((result) => this.reset()); } updateWidget(widget: Widget) { - this.widgetsService.update(widget).subscribe((result) => this.reset()); + //this.widgetsService.update(widget).subscribe((result) => this.reset()); } deleteWidget(widget: Widget) { - this.widgetsService.delete(widget).subscribe((result) => this.reset()); + // this.widgetsService.delete(widget).subscribe((result) => this.reset()); } } diff --git a/libs/core-state/src/index.ts b/libs/core-state/src/index.ts index 6a6d556..3853607 100644 --- a/libs/core-state/src/index.ts +++ b/libs/core-state/src/index.ts @@ -1 +1,6 @@ +export * from './lib/widgets/widgets.actions'; +export * from './lib/widgets/widgets.reducer'; +export * from './lib/widgets/widgets.selectors'; +export * from './lib/widgets/widgets.models'; +export * from './lib/widgets/widgets.facade'; export * from './lib/core-state.module'; diff --git a/libs/core-state/src/lib/core-state.module.ts b/libs/core-state/src/lib/core-state.module.ts index c3333e5..f4e0249 100644 --- a/libs/core-state/src/lib/core-state.module.ts +++ b/libs/core-state/src/lib/core-state.module.ts @@ -1,7 +1,22 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; +import { StoreModule } from '@ngrx/store'; +import { EffectsModule } from '@ngrx/effects'; +import * as fromWidgets from './widgets/widgets.reducer'; +import { WidgetsEffects } from './widgets/widgets.effects'; +import { WidgetsFacade } from './widgets/widgets.facade'; +import { CoreDataModule } from '@fem/core-data'; @NgModule({ - imports: [CommonModule], + imports: [ + CommonModule, + CoreDataModule, + // StoreModule.forFeature( + // fromWidgets.WIDGETS_FEATURE_KEY, + // fromWidgets.reducer + // ), + // EffectsModule.forFeature([WidgetsEffects]), + ], + providers: [WidgetsFacade], }) export class CoreStateModule {} diff --git a/libs/core-state/src/lib/widgets/widgets.actions.ts b/libs/core-state/src/lib/widgets/widgets.actions.ts new file mode 100644 index 0000000..fcdd33a --- /dev/null +++ b/libs/core-state/src/lib/widgets/widgets.actions.ts @@ -0,0 +1,14 @@ +import { createAction, props } from '@ngrx/store'; +import { WidgetsEntity } from './widgets.models'; + +export const loadWidgets = createAction('[Widgets] Load Widgets'); + +export const loadWidgetsSuccess = createAction( + '[Widgets] Load Widgets Success', + props<{ widgets: WidgetsEntity[] }>() +); + +export const loadWidgetsFailure = createAction( + '[Widgets] Load Widgets Failure', + props<{ error: any }>() +); diff --git a/libs/core-state/src/lib/widgets/widgets.effects.spec.ts b/libs/core-state/src/lib/widgets/widgets.effects.spec.ts new file mode 100644 index 0000000..2284042 --- /dev/null +++ b/libs/core-state/src/lib/widgets/widgets.effects.spec.ts @@ -0,0 +1,43 @@ +import { TestBed, async } from '@angular/core/testing'; + +import { Observable } from 'rxjs'; + +import { provideMockActions } from '@ngrx/effects/testing'; +import { provideMockStore } from '@ngrx/store/testing'; + +import { NxModule, DataPersistence } from '@nrwl/angular'; +import { hot } from '@nrwl/angular/testing'; + +import { WidgetsEffects } from './widgets.effects'; +import * as WidgetsActions from './widgets.actions'; + +describe('WidgetsEffects', () => { + let actions: Observable; + let effects: WidgetsEffects; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [NxModule.forRoot()], + providers: [ + WidgetsEffects, + DataPersistence, + provideMockActions(() => actions), + provideMockStore(), + ], + }); + + effects = TestBed.get(WidgetsEffects); + }); + + describe('loadWidgets$', () => { + it('should work', () => { + actions = hot('-a-|', { a: WidgetsActions.loadWidgets() }); + + const expected = hot('-a-|', { + a: WidgetsActions.loadWidgetsSuccess({ widgets: [] }), + }); + + expect(effects.loadWidgets$).toBeObservable(expected); + }); + }); +}); diff --git a/libs/core-state/src/lib/widgets/widgets.effects.ts b/libs/core-state/src/lib/widgets/widgets.effects.ts new file mode 100644 index 0000000..7c2c182 --- /dev/null +++ b/libs/core-state/src/lib/widgets/widgets.effects.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@angular/core'; +import { createEffect, Actions, ofType } from '@ngrx/effects'; +import { fetch } from '@nrwl/angular'; + +import * as fromWidgets from './widgets.reducer'; +import * as WidgetsActions from './widgets.actions'; + +@Injectable() +export class WidgetsEffects { + loadWidgets$ = createEffect(() => + this.actions$.pipe( + ofType(WidgetsActions.loadWidgets), + fetch({ + run: (action) => { + // Your custom service 'load' logic goes here. For now just return a success action... + return WidgetsActions.loadWidgetsSuccess({ widgets: [] }); + }, + + onError: (action, error) => { + console.error('Error', error); + return WidgetsActions.loadWidgetsFailure({ error }); + }, + }) + ) + ); + + constructor(private actions$: Actions) {} +} diff --git a/libs/core-state/src/lib/widgets/widgets.facade.spec.ts b/libs/core-state/src/lib/widgets/widgets.facade.spec.ts new file mode 100644 index 0000000..2ea9855 --- /dev/null +++ b/libs/core-state/src/lib/widgets/widgets.facade.spec.ts @@ -0,0 +1,118 @@ +import { NgModule } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { readFirst } from '@nrwl/angular/testing'; + +import { EffectsModule } from '@ngrx/effects'; +import { StoreModule, Store } from '@ngrx/store'; + +import { NxModule } from '@nrwl/angular'; + +import { WidgetsEntity } from './widgets.models'; +import { WidgetsEffects } from './widgets.effects'; +import { WidgetsFacade } from './widgets.facade'; + +import * as WidgetsSelectors from './widgets.selectors'; +import * as WidgetsActions from './widgets.actions'; +import { + WIDGETS_FEATURE_KEY, + State, + initialState, + reducer, +} from './widgets.reducer'; + +interface TestSchema { + widgets: State; +} + +describe('WidgetsFacade', () => { + let facade: WidgetsFacade; + let store: Store; + const createWidgetsEntity = (id: string, name = '') => + ({ + id, + name: name || `name-${id}`, + } as WidgetsEntity); + + beforeEach(() => {}); + + describe('used in NgModule', () => { + beforeEach(() => { + @NgModule({ + imports: [ + StoreModule.forFeature(WIDGETS_FEATURE_KEY, reducer), + EffectsModule.forFeature([WidgetsEffects]), + ], + providers: [WidgetsFacade], + }) + class CustomFeatureModule {} + + @NgModule({ + imports: [ + NxModule.forRoot(), + StoreModule.forRoot({}), + EffectsModule.forRoot([]), + CustomFeatureModule, + ], + }) + class RootModule {} + TestBed.configureTestingModule({ imports: [RootModule] }); + + store = TestBed.get(Store); + facade = TestBed.get(WidgetsFacade); + }); + + /** + * The initially generated facade::loadAll() returns empty array + */ + it('loadAll() should return empty list with loaded == true', async (done) => { + try { + let list = await readFirst(facade.allWidgets$); + let isLoaded = await readFirst(facade.loaded$); + + expect(list.length).toBe(0); + expect(isLoaded).toBe(false); + + facade.dispatch(WidgetsActions.loadWidgets()); + + list = await readFirst(facade.allWidgets$); + isLoaded = await readFirst(facade.loaded$); + + expect(list.length).toBe(0); + expect(isLoaded).toBe(true); + + done(); + } catch (err) { + done.fail(err); + } + }); + + /** + * Use `loadWidgetsSuccess` to manually update list + */ + it('allWidgets$ should return the loaded list; and loaded flag == true', async (done) => { + try { + let list = await readFirst(facade.allWidgets$); + let isLoaded = await readFirst(facade.loaded$); + + expect(list.length).toBe(0); + expect(isLoaded).toBe(false); + + facade.dispatch( + WidgetsActions.loadWidgetsSuccess({ + widgets: [createWidgetsEntity('AAA'), createWidgetsEntity('BBB')], + }) + ); + + list = await readFirst(facade.allWidgets$); + isLoaded = await readFirst(facade.loaded$); + + expect(list.length).toBe(2); + expect(isLoaded).toBe(true); + + done(); + } catch (err) { + done.fail(err); + } + }); + }); +}); diff --git a/libs/core-state/src/lib/widgets/widgets.facade.ts b/libs/core-state/src/lib/widgets/widgets.facade.ts new file mode 100644 index 0000000..c29d864 --- /dev/null +++ b/libs/core-state/src/lib/widgets/widgets.facade.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@angular/core'; + +import { select, Store, Action } from '@ngrx/store'; +import { Subject } from 'rxjs'; + +import * as fromWidgets from './widgets.reducer'; +import * as WidgetsSelectors from './widgets.selectors'; + +import {Widget} from '@fem/api-interfaces' +import { WidgetsService } from '@fem/core-data'; + +@Injectable() +export class WidgetsFacade { + private allWidgets = new Subject(); + private selectedWidget = new Subject(); + private mutations = new Subject(); + + allWidget$ = this.allWidgets.asObservable(); + selectedWidget$ = this.selectedWidget.asObservable(); + mutations$ = this.mutations.asObservable(); + + constructor(private widgetsService: WidgetsService) { } + + selectWidget(widget: Widget) { + this.selectedWidget.next(widget); + } + + loadWidgets() { + this.widgetsService + .all() + .subscribe((widgets: Widget[]) => this.allWidgets.next(widgets)); + } +} diff --git a/libs/core-state/src/lib/widgets/widgets.models.ts b/libs/core-state/src/lib/widgets/widgets.models.ts new file mode 100644 index 0000000..f857dfa --- /dev/null +++ b/libs/core-state/src/lib/widgets/widgets.models.ts @@ -0,0 +1,6 @@ +/** + * Interface for the 'Widgets' data + */ +export interface WidgetsEntity { + id: string | number; // Primary ID +} diff --git a/libs/core-state/src/lib/widgets/widgets.reducer.spec.ts b/libs/core-state/src/lib/widgets/widgets.reducer.spec.ts new file mode 100644 index 0000000..155dde7 --- /dev/null +++ b/libs/core-state/src/lib/widgets/widgets.reducer.spec.ts @@ -0,0 +1,38 @@ +import { WidgetsEntity } from './widgets.models'; +import * as WidgetsActions from './widgets.actions'; +import { State, initialState, reducer } from './widgets.reducer'; + +describe('Widgets Reducer', () => { + const createWidgetsEntity = (id: string, name = '') => + ({ + id, + name: name || `name-${id}`, + } as WidgetsEntity); + + beforeEach(() => {}); + + describe('valid Widgets actions', () => { + it('loadWidgetsSuccess should return set the list of known Widgets', () => { + const widgets = [ + createWidgetsEntity('PRODUCT-AAA'), + createWidgetsEntity('PRODUCT-zzz'), + ]; + const action = WidgetsActions.loadWidgetsSuccess({ widgets }); + + const result: State = reducer(initialState, action); + + expect(result.loaded).toBe(true); + expect(result.ids.length).toBe(2); + }); + }); + + describe('unknown action', () => { + it('should return the previous state', () => { + const action = {} as any; + + const result = reducer(initialState, action); + + expect(result).toBe(initialState); + }); + }); +}); diff --git a/libs/core-state/src/lib/widgets/widgets.reducer.ts b/libs/core-state/src/lib/widgets/widgets.reducer.ts new file mode 100644 index 0000000..b09d42a --- /dev/null +++ b/libs/core-state/src/lib/widgets/widgets.reducer.ts @@ -0,0 +1,46 @@ +import { createReducer, on, Action } from '@ngrx/store'; +import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity'; + +import * as WidgetsActions from './widgets.actions'; +import { WidgetsEntity } from './widgets.models'; + +export const WIDGETS_FEATURE_KEY = 'widgets'; + +export interface State extends EntityState { + selectedId?: string | number; // which Widgets record has been selected + loaded: boolean; // has the Widgets list been loaded + error?: string | null; // last known error (if any) +} + +export interface WidgetsPartialState { + readonly [WIDGETS_FEATURE_KEY]: State; +} + +export const widgetsAdapter: EntityAdapter = createEntityAdapter< + WidgetsEntity +>(); + +export const initialState: State = widgetsAdapter.getInitialState({ + // set initial required properties + loaded: false, +}); + +const widgetsReducer = createReducer( + initialState, + on(WidgetsActions.loadWidgets, (state) => ({ + ...state, + loaded: false, + error: null, + })), + on(WidgetsActions.loadWidgetsSuccess, (state, { widgets }) => + widgetsAdapter.setAll(widgets, { ...state, loaded: true }) + ), + on(WidgetsActions.loadWidgetsFailure, (state, { error }) => ({ + ...state, + error, + })) +); + +export function reducer(state: State | undefined, action: Action) { + return widgetsReducer(state, action); +} diff --git a/libs/core-state/src/lib/widgets/widgets.selectors.spec.ts b/libs/core-state/src/lib/widgets/widgets.selectors.spec.ts new file mode 100644 index 0000000..9505368 --- /dev/null +++ b/libs/core-state/src/lib/widgets/widgets.selectors.spec.ts @@ -0,0 +1,62 @@ +import { WidgetsEntity } from './widgets.models'; +import { State, widgetsAdapter, initialState } from './widgets.reducer'; +import * as WidgetsSelectors from './widgets.selectors'; + +describe('Widgets Selectors', () => { + const ERROR_MSG = 'No Error Available'; + const getWidgetsId = (it) => it['id']; + const createWidgetsEntity = (id: string, name = '') => + ({ + id, + name: name || `name-${id}`, + } as WidgetsEntity); + + let state; + + beforeEach(() => { + state = { + widgets: widgetsAdapter.setAll( + [ + createWidgetsEntity('PRODUCT-AAA'), + createWidgetsEntity('PRODUCT-BBB'), + createWidgetsEntity('PRODUCT-CCC'), + ], + { + ...initialState, + selectedId: 'PRODUCT-BBB', + error: ERROR_MSG, + loaded: true, + } + ), + }; + }); + + describe('Widgets Selectors', () => { + it('getAllWidgets() should return the list of Widgets', () => { + const results = WidgetsSelectors.getAllWidgets(state); + const selId = getWidgetsId(results[1]); + + expect(results.length).toBe(3); + expect(selId).toBe('PRODUCT-BBB'); + }); + + it('getSelected() should return the selected Entity', () => { + const result = WidgetsSelectors.getSelected(state); + const selId = getWidgetsId(result); + + expect(selId).toBe('PRODUCT-BBB'); + }); + + it("getWidgetsLoaded() should return the current 'loaded' status", () => { + const result = WidgetsSelectors.getWidgetsLoaded(state); + + expect(result).toBe(true); + }); + + it("getWidgetsError() should return the current 'error' state", () => { + const result = WidgetsSelectors.getWidgetsError(state); + + expect(result).toBe(ERROR_MSG); + }); + }); +}); diff --git a/libs/core-state/src/lib/widgets/widgets.selectors.ts b/libs/core-state/src/lib/widgets/widgets.selectors.ts new file mode 100644 index 0000000..384efb0 --- /dev/null +++ b/libs/core-state/src/lib/widgets/widgets.selectors.ts @@ -0,0 +1,45 @@ +import { createFeatureSelector, createSelector } from '@ngrx/store'; +import { + WIDGETS_FEATURE_KEY, + State, + WidgetsPartialState, + widgetsAdapter, +} from './widgets.reducer'; + +// Lookup the 'Widgets' feature state managed by NgRx +export const getWidgetsState = createFeatureSelector< + WidgetsPartialState, + State +>(WIDGETS_FEATURE_KEY); + +const { selectAll, selectEntities } = widgetsAdapter.getSelectors(); + +export const getWidgetsLoaded = createSelector( + getWidgetsState, + (state: State) => state.loaded +); + +export const getWidgetsError = createSelector( + getWidgetsState, + (state: State) => state.error +); + +export const getAllWidgets = createSelector(getWidgetsState, (state: State) => + selectAll(state) +); + +export const getWidgetsEntities = createSelector( + getWidgetsState, + (state: State) => selectEntities(state) +); + +export const getSelectedId = createSelector( + getWidgetsState, + (state: State) => state.selectedId +); + +export const getSelected = createSelector( + getWidgetsEntities, + getSelectedId, + (entities, selectedId) => selectedId && entities[selectedId] +); diff --git a/package.json b/package.json index 6e305d2..3271fe3 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,9 @@ "@nestjs/mapped-types": "^0.1.1", "@nestjs/platform-express": "^7.0.0", "@nestjs/swagger": "^4.7.0", + "@ngrx/effects": "10.0.0", + "@ngrx/entity": "10.0.0", + "@ngrx/router-store": "10.0.0", "@ngrx/store": "10.0.1", "@nrwl/angular": "10.3.2", "concurrently": "^5.3.0", @@ -66,6 +69,8 @@ "@angular/language-service": "^10.1.0", "@nestjs/schematics": "^7.0.0", "@nestjs/testing": "^7.0.0", + "@ngrx/schematics": "10.0.0", + "@ngrx/store-devtools": "10.0.0", "@nrwl/cli": "10.3.2", "@nrwl/cypress": "10.3.2", "@nrwl/jest": "10.3.2", diff --git a/yarn.lock b/yarn.lock index 93b0aa9..d9b463d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1580,6 +1580,39 @@ optional "0.1.4" tslib "2.0.3" +"@ngrx/effects@10.0.0": + version "10.0.0" + resolved "https://registry.yarnpkg.com/@ngrx/effects/-/effects-10.0.0.tgz#d58151b1d7f5731ea42de7ed239bbab3d5866542" + integrity sha512-HHcQQ6mj1Cd0rQgnX5Wp3f7G8PKhh+Rk+jofsOsE6aHQPuuNhmnDwSA1U4PT4sXNv6JmFi5GjUqBz+tuw83oFQ== + dependencies: + tslib "^2.0.0" + +"@ngrx/entity@10.0.0": + version "10.0.0" + resolved "https://registry.yarnpkg.com/@ngrx/entity/-/entity-10.0.0.tgz#ad46f448c856c4c5902b7f9446af88a580f4d50d" + integrity sha512-vm8vR6hbJKuIq+PjX4C7li3thPPt1Wnrl9Zf2VYgSP+Mr6vaU0Q+5UgBIRbMDKS1AthGPv+MDVI+kAOlhOyDog== + dependencies: + tslib "^2.0.0" + +"@ngrx/router-store@10.0.0": + version "10.0.0" + resolved "https://registry.yarnpkg.com/@ngrx/router-store/-/router-store-10.0.0.tgz#e162e69da138f5a3e2d194f9e81d76d117cd2124" + integrity sha512-naK+IlgTQNEWWlKndQIBS/EQZ2h3pRF4owF+4kVpn+OI5Il7+Nugf0spj+0IFGd21Z7YvBe9C54SQPOPP4HARg== + dependencies: + tslib "^2.0.0" + +"@ngrx/schematics@10.0.0": + version "10.0.0" + resolved "https://registry.yarnpkg.com/@ngrx/schematics/-/schematics-10.0.0.tgz#5e5b22b354bb725c48162e516cc770f9f20fe241" + integrity sha512-MAZaxTEzny92ISMmHPu1djQFZRDpC875mJ2WhBQrNe66EBXIkJq7feb9/26hH1mKmOqVR83k9TpXPsJA9YimgA== + +"@ngrx/store-devtools@10.0.0": + version "10.0.0" + resolved "https://registry.yarnpkg.com/@ngrx/store-devtools/-/store-devtools-10.0.0.tgz#018716afd3df89bd2fa1f603966878f82e80d727" + integrity sha512-+7SSPW9H+IdGX04QYmfgqYOeFM++PLD6CxGRUkIIc+6jFovanMS6CVKw6V+WeerPwoZaRn43cMIDj9FChMQ4oA== + dependencies: + tslib "^2.0.0" + "@ngrx/store@10.0.1": version "10.0.1" resolved "https://registry.yarnpkg.com/@ngrx/store/-/store-10.0.1.tgz#74c3bb383cc507f927ba63710cc6622f2f2859db"