NGRX entity

Neil HaddleyFebruary 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

npm i json-server

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

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 created a db.json in the project's root folder

I started the server by running npm run server

I started the server by running npm run server

I used chrome to request details of all posts.

I used chrome to request details of all posts.

Accessing the server

ng generate service services/post

ng generate service services/post

ng test

ng test

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

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

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

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 body field (why not?)

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

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

imports HttpClientModule

imports HttpClientModule

Testing a service

Initially I wrote tests that called the running json-server.

Executed 6 of 6 success

Executed 6 of 6 success

Karma results in a web page

Karma results in a web page

The json-server shows the POST, DELETE and GET requests

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

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

Executed 6 of 6 success

Executed 6 of 6 success

Karma results in a web page

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 Success

Load Posts Failure (service not running)

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}