NGRX entity
Neil Haddley • February 18, 2023
json-server
I extended my haddley-ngrx project to include support for posts.
I used json-server to create a simple api
BASH
1% npm i json-server 2

npm i json-server

Github co-pilot suggested an addition to the scripts section of package.json

I created a db.json in the project's root folder

I started the server by running npm run server

I used chrome to request details of all posts.
Accessing the server

ng generate service services/post

ng test

providedIn: 'root' (no need to add service to app.module.ts)

co-pilot suggests creating a post.model file (why not?)

co-pilot suggests that we include a body field (why not?)

co-pilot suggests that we include a deletePost method (why not)

imports HttpClientModule
Testing a service
Initially I wrote tests that called the running json-server.

Executed 6 of 6 success

Karma results in a web page

The json-server shows the POST, DELETE and GET requests
Mock the HTTP client
I installed jasmine-auto-spies.
BASH
1% npm i --include=dev jasmine-auto-spies
I updated the tests to use the mock.

npm i --include=dev jasmine-auto-spies

Executed 6 of 6 success

Karma results in a web page
Entity provides an API to manipulate and query entity collections.
Reduces boilerplate for creating reducers that manage a collection of models.
Provides performant CRUD operations for managing entity collections.
Extensible type-safe adapters for selecting entity information.

Load Posts Success

Load Posts Failure (service not running)
post.service.spec.ts
TEXT
1import { HttpClient, HttpErrorResponse } from '@angular/common/http'; 2import { createSpyFromClass, Spy } from 'jasmine-auto-spies'; 3import { Post } from '../models/post.model'; 4import { PostService } from './post.service'; 5import { TestBed } from '@angular/core/testing'; 6 7describe('PostService', () => { 8 let service: PostService; 9 let httpSpy: Spy<HttpClient>; 10 11 beforeEach(() => { 12 TestBed.configureTestingModule({ 13 providers: [ 14 PostService, 15 { provide: HttpClient, useValue: createSpyFromClass(HttpClient) } 16 ] 17 }); 18 19 service = TestBed.inject(PostService); 20 httpSpy = TestBed.inject<any>(HttpClient); 21 }); 22 23 it('should create a new post', (done: DoneFn) => { 24 25 const newPost = { id: 100, title: "json-server100", author: "typicode", body: "some body100" } as Post; 26 27 httpSpy.post.and.nextWith(newPost); 28 29 service.addPost(newPost).subscribe(post => { 30 expect(post).toEqual(newPost); 31 done(); 32 }); 33 expect(httpSpy.post.calls.count()).toBe(1); 34 }); 35 36 it('should return an existing post', (done: DoneFn) => { 37 38 const existingPost = { id: 1, title: "json-server", author: "typicode", body: "some body" } as Post; 39 const postId = existingPost.id; 40 41 httpSpy.get.and.nextWith(existingPost); 42 43 service.getPost(postId).subscribe(post => { 44 expect(post).toEqual(existingPost); 45 done(); 46 }); 47 48 expect(httpSpy.get.calls.count()).toBe(1); 49 }); 50 51 it('should return a 404 if post does not exist', (done: DoneFn) => { 52 53 const postId = 89776683; 54 55 httpSpy.get.and.throwWith(new HttpErrorResponse({ 56 error: "404 - Not Found", 57 status: 404 58 })); 59 60 service.getPost(postId).subscribe({ 61 next: (post) => { 62 done.fail("Expected a 404"); 63 }, 64 error: (error) => { 65 expect(error.status).toEqual(404); 66 done(); 67 } 68 }); 69 70 expect(httpSpy.get.calls.count()).toBe(1); 71 }); 72 73 74});
post.actions.ts
TEXT
1import { createAction, props } from '@ngrx/store'; 2import { Post } from 'src/app/models/post.model'; 3 4export const loadPosts = createAction('[Posts] Load Posts'); 5export const loadPostsSuccess = createAction('[Posts] Load Posts Success', props<{ posts: Post[] }>()); 6export const loadPostsFailure = createAction('[Posts] Load Posts Failure', props<{ error: string }>());
post.effect.ts
TEXT
1import { Injectable } from "@angular/core"; 2import { catchError, exhaustMap, map, of } from "rxjs"; 3import { loadPosts, loadPostsFailure, loadPostsSuccess } from "./post.actions"; 4import { Actions, createEffect, ofType } from "@ngrx/effects"; 5import { PostService } from "src/app/services/post.service"; 6 7@Injectable() 8export class PostEffects { 9 10 constructor(private actions$: Actions, private postService: PostService) { } 11 12 loadPosts$ = createEffect(() => 13 this.actions$.pipe( 14 ofType(loadPosts), 15 exhaustMap(() => 16 // call the service 17 this.postService.getPosts().pipe( 18 // return a Success action when the HTTP request was successfull 19 map((posts) => loadPostsSuccess({ posts: posts })), 20 // return a Failed action when something went wrong during the HTTP request 21 catchError((error) => of(loadPostsFailure({ error: 'http error' }))), 22 ), 23 ), 24 ) 25 ); 26}
post.reducer.ts
TEXT
1import { 2 createEntityAdapter, 3 EntityAdapter, 4 EntityState 5} from '@ngrx/entity'; 6import { Post } from '../../models/post.model'; 7import { createReducer, on } from '@ngrx/store'; 8import { loadPostsFailure, loadPostsSuccess } from './post.actions'; 9 10export interface PostState extends EntityState<Post> { 11 // additional entities state properties 12 currentPostId: number | null; 13 error: any; 14} 15 16export const postAdapter: EntityAdapter<Post> = createEntityAdapter<Post>(); 17 18export const initialState: PostState = postAdapter.getInitialState({ 19 currentPostId: null, 20 error: null 21}); 22 23export const postReducer = createReducer( 24 initialState, 25 on(loadPostsSuccess, (state, { posts }) => { 26 return postAdapter.setAll(posts, {...state,error:null}); 27 }), 28 on(loadPostsFailure, (state, { error }) => { 29 return { ...state, error}; 30 }) 31 32)
post.selector.ts
TEXT
1import { createSelector } from "@ngrx/store"; 2import { AppState } from "../app.state"; 3import { postAdapter, PostState } from "./post.reducer"; 4 5export const selectPosts = (state: AppState) => state.posts; 6 7export const { selectIds, selectEntities, selectAll, selectTotal } = postAdapter.getSelectors(); 8 9export const selectAllPosts = createSelector(selectPosts, selectAll); 10 11export const selectEntitiesPosts = createSelector(selectPosts, selectEntities); 12 13export const selectTotalPosts = createSelector(selectPosts, selectTotal); 14 15export const selectIdsPosts = createSelector(selectPosts, selectIds); 16 17export const selectError = createSelector( 18 selectPosts, 19 (state: PostState) => state.error 20);
app.component.html
TEXT
1<div class="dice" (click)="updateValue()" *ngIf="value$|async as value">{{value}}</div> 2 3<div *ngIf="postsError$ | async as postsError; else loaded"> 4 <p>There was an error loading the posts</p> 5 <p>{{postsError}}</p> 6</div> 7 8<ng-template #loaded> 9 <table> 10 <thead> 11 <th>Title</th> 12 <th>Author</th> 13 <th>Body</th> 14 </thead> 15 <tbody> 16 <tr *ngFor="let post of posts$ | async"> 17 <td>{{post.title}}</td> 18 <td>{{post.author}}</td> 19 <td>{{post.body}}</td> 20 </tr> 21 </tbody> 22 </table> 23</ng-template>
app.component.ts
TEXT
1import { Component, OnInit } from '@angular/core'; 2import { selectDiceError } from './state/dice/dice.selectors'; 3import { selectDiceValue } from './state/dice/dice.selectors'; 4import { Store } from '@ngrx/store'; 5import { roll} from './state/dice/dice.actions'; 6import { AppState } from './state/app.state'; 7import { selectAllPosts, selectEntitiesPosts, selectTotalPosts, selectIdsPosts, selectError } from './state/post/post.selector'; 8import { loadPosts } from './state/post/post.actions'; 9 10@Component({ 11 selector: 'app-root', 12 templateUrl: './app.component.html', 13 styleUrls: ['./app.component.scss'] 14}) 15export class AppComponent implements OnInit { 16 17 // random number beteen 1 and 6 18 value$ = this.appStore.select(selectDiceValue); 19 error$ = this.appStore.select(selectDiceError); 20 21 // posts 22 posts$ = this.appStore.select(selectAllPosts); 23 postsError$ = this.appStore.select(selectError); 24 25 constructor(private appStore: Store<AppState>) {} 26 27 updateValue() { 28 this.appStore.dispatch(roll()); 29 } 30 31 ngOnInit() { 32 this.appStore.dispatch(loadPosts()); 33 } 34 35 title = 'haddley-ngrx'; 36 37}