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"