Commit 8c0b7311 authored by Julian's avatar Julian
Browse files

refactor to blog example

parent eb0bb135
......@@ -2,7 +2,7 @@
![Reactive Architecture and UX Patterns - Combining Stream and Behavior](./assets/images/Reactive-architecture-and-ux-patterns_angular_combining-streams-and-behavior_michael-hladky.png)
This set of lessons are all about combination operators.
This set of lessons is all about combination operators.
As this course focuses on real-life use cases,
we will use these operators in the context of state derivation, data fetching and update behavior.
......@@ -44,18 +44,45 @@ If we understand every of those "broken parts" we are intuitively able to unders
Within this set of lessons we will walk through the following exercises:
- [ ] We start we a simple setup where we derive data in our component directly over HTTP requests by using `forkJoin`
- with this architecture we realize, we quickly run into the problem of over-fetching
- [ ] `forkJoin` -> `combineLatest`
- We start with a simple setup where we derive data in our component directly over HTTP requests by using `forkJoin`
- We notice that `forkJoin` results in too many http calls
- Rebuild
- [ ] To solve it we refactor the give HTTP service to get more control over when we fetch the data
- this reviles one of the special behaviours of `frokJoin` and we need to rethink it usage
- this reviles one of the special behaviours of `forkJoin` and we need to rethink it usage
- http-service-v1
- [ ] We learn the difference of `forkJoin` and `combineLatest`
- this knowledge helps us to refactor the service and component.
- combineLatest
- [ ] As we go we start to introduce more features into our UI
- again we run into a problem, this time over-rendering.
- [ ] To understand the problem we learn about the terms `Normalized` and `Denormalized` data
- by using `zip` for our calculation we are able to solve the problem of over-rendering
- zip
- [ ] As it was quite technical so far we learn about `withLatestFrom` with a more playful example
- by doing so we understand the concept of `promary` and `secondary` streams
- withLatestFrom
- [ ] With a fresh and open mine we think about those concepts in combination with a UX Pattern called `opt-in updates`
- to give a better experience to our users we implement this pattern in our example
## Domain
```Typescript
interface Post {
id: string;
title: string;
}
interface BlogPost {
id: string;
title: string;
comments: Comment[];
commentCount: number;
}
interface Comment {
id: string;
postId: string;
text: string;
}
```
import {Injectable} from '@angular/core';
import {BehaviorSubject, combineLatest, Observable, of} from 'rxjs';
import {catchError, map} from 'rxjs/operators';
import {Item, JoinedItem, List, mergeListsAndItems, upsertEntities} from "shared";
import {StartHttpV1Service} from "combining-streams/lib/exercises/http-service-v1/start.http-v1.service";
import {HttpClient} from "@angular/common/http";
interface ListServiceState {
lists: List[];
items: Item[];
}
@Injectable({
providedIn: 'root'
})
export class combineLatestListService {
private readonly baseUrl = 'api';
private readonly itemUrl = [this.baseUrl, 'item'].join('/');
private readonly listUrl = [this.baseUrl, 'list'].join('/');
private readonly state$ = new BehaviorSubject<ListServiceState>({
lists: [] as List[],
items: [] as Item[]
});
lists$ = this.state$.pipe(map(s => s.lists));
items$ = this.state$.pipe(map(s => s.items));
constructor(private http: HttpClient) {
}
refetchLists() {
this.httpGetLists()
.subscribe(lists => {
console.log('upsert:', upsertEntities(this.state$.getValue().lists, lists, 'id'));
this.state$.next({
...this.state$.getValue(),
lists: upsertEntities(this.state$.getValue().lists, lists, 'id')
});
});
}
httpGetLists(): Observable<List[]> {
return this.http.get<List[]>(this.listUrl).pipe(
catchError(() => of([] as List[]))
);
}
httpPostItems(item: Pick<Item, 'iName' | 'lId'>): Observable<Item[]> {
return this.http.post<Item[]>(this.itemUrl, item);
}
addItem(item: Pick<Item, 'iName' | 'lId'>) {
this.httpPostItems(item)
.subscribe((v) => {
console.log(v);
this.refetchItems()
}, console.log);
}
refetchItems() {
this.httpGetItems()
.subscribe(items => {
this.state$.next({
...this.state$.getValue(),
items: upsertEntities(this.state$.getValue().items, items, 'id')
});
});
}
httpGetItems(): Observable<Item[]> {
return this.http.get<Item[]>(this.itemUrl).pipe(
catchError(() => of([] as Item[]))
);
}
}
import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core';
import {combineLatest, Observable} from "rxjs";
import {map} from "rxjs/operators";
import {JoinedItem, ListService, mergeListsAndItems} from "shared";
import {combineLatestListService} from "combining-streams/lib/exercises/combineLatest/combineLatest-list.service";
import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core';
import { combineLatest, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { BlogBasicService, BlogPost, mergeListsAndItems } from 'shared';
@Component({
......@@ -11,14 +10,15 @@ import {combineLatestListService} from "combining-streams/lib/exercises/combineL
<mat-form-field>
<label>Name</label>
<input matInput name="iName" [(ngModel)]="iName"/>
<input matInput name="text" [(ngModel)]="text"/>
</mat-form-field>
<button mat-raised-button color="primary" (click)="listService.addItem({'iName': iName, 'lId': 1})">AddItem</button>
<button mat-raised-button color="primary" (click)="listService.addComment({'text': text, 'postId': 1})">AddItem</button>
<div *ngIf="list$ | async as list">
<div *ngIf="blog$ | async as blog">
<mat-list>
<mat-list-item *ngFor="let item of list">
{{item.iName}} - {{item.lName}}
<mat-list-item *ngFor="let post of blog">
<span mat-line>{{post.title}}</span>
<span mat-line>Comments: {{post.commentCount}}</span>
</mat-list-item>
</mat-list>
</div>
......@@ -27,17 +27,17 @@ import {combineLatestListService} from "combining-streams/lib/exercises/combineL
encapsulation: ViewEncapsulation.None
})
export class SolutionCombineLatestComponent {
iName: string = '';
list$: Observable<JoinedItem[]> = combineLatest([
this.listService.lists$,
this.listService.items$
text: string = '';
blog$: Observable<BlogPost[]> = combineLatest([
this.listService.posts$,
this.listService.comments$
])
.pipe(
map(([list, items]) => mergeListsAndItems(list, items))
map(([posts, comments]) => mergeListsAndItems(posts, comments))
);
constructor(public listService: combineLatestListService) {
this.listService.refetchLists();
this.listService.refetchItems();
constructor(public listService: BlogBasicService) {
this.listService.fetchPosts();
this.listService.fetchComments();
}
}
import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core';
import {combineLatest, forkJoin, NEVER, Observable} from "rxjs";
import {JoinedItem, ListService, mergeListsAndItems} from "shared";
import {map} from "rxjs/operators";
import {combineLatestListService} from "combining-streams/lib/exercises/combineLatest/combineLatest-list.service";
import { Component } from '@angular/core';
import { forkJoin, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { BlogBasicService, BlogPost, mergeListsAndItems } from 'shared';
@Component({
selector: 'combineLatest',
template: `<h3>combineLatest</h3>
template: `
<h3>combineLatest</h3>
<mat-form-field>
<label>Name</label>
<input matInput name="iName" [(ngModel)]="iName"/>
<label>Title</label>
<input matInput name="comment" [(ngModel)]="comment"/>
</mat-form-field>
<button mat-raised-button color="primary" (click)="listService.addItem({'iName': iName, 'lId': 1})">AddItem</button>
<div *ngIf="list$ | async as list">
<button mat-raised-button color="primary" (click)="listService.addPost({'title': comment})">Add Post</button>
<div *ngIf="blog$ | async as blog">
<mat-list>
<mat-list-item *ngFor="let item of list">
{{item.iName}} - {{item.lName}}
<mat-list-item *ngFor="let post of blog">
<span mat-line>{{post.title}}</span>
<span mat-line>Comments: {{post.commentCount}}</span>
</mat-list-item>
</mat-list>
</div>
`
})
export class StartCombineLatestComponent {
iName: string = '';
list$: Observable<JoinedItem[]> = forkJoin([
this.listService.lists$,
this.listService.items$
comment: string = '';
blog$: Observable<BlogPost[]> = forkJoin([
this.listService.posts$,
this.listService.comments$
])
.pipe(
map(([list, items]) => mergeListsAndItems(list, items))
map(([posts, comments]) => mergeListsAndItems(posts, comments))
);
constructor(public listService: combineLatestListService) {
this.listService.refetchLists();
this.listService.refetchItems();
constructor(public listService: BlogBasicService) {
this.listService.fetchPosts();
this.listService.fetchComments();
}
}
import {Injectable} from '@angular/core';
import {Observable, of} from 'rxjs';
import {catchError} from 'rxjs/operators';
import {Item, List} from "shared";
import {HttpClient} from "@angular/common/http";
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { Comment, Post } from 'shared';
@Injectable({
......@@ -17,15 +17,15 @@ export class ForkJoinListService {
}
httpGetItems(): Observable<Item[]> {
return this.http.get<Item[]>(this.itemUrl).pipe(
catchError(e => of([] as Item[]))
httpGetItems(): Observable<Comment[]> {
return this.http.get<Comment[]>(this.itemUrl).pipe(
catchError(e => of([] as Comment[]))
);
}
httpGetLists(): Observable<List[]> {
return this.http.get<List[]>(this.listUrl).pipe(
catchError(e => of([] as List[]))
httpGetLists(): Observable<Post[]> {
return this.http.get<Post[]>(this.listUrl).pipe(
catchError(e => of([] as Post[]))
);
}
......
import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core';
import {forkJoin, Observable} from "rxjs";
import {map} from "rxjs/operators";
import {JoinedItem, mergeListsAndItems} from "shared";
import {BlogPost, mergeListsAndItems} from "shared";
import {ForkJoinListService} from "combining-streams/lib/exercises/forkJoin/forkJoin-list.service";
@Component({
selector: 'forkJoin',
template: `<h3>(Solution) ForkJoin</h3>
<div *ngIf="list$ | async as list">
template: `
<h3>(Solution) ForkJoin</h3>
<mat-list>
<mat-list-item *ngFor="let item of list">
<mat-list-item *ngFor="let item of list$ | async">
{{item.iName}} - {{item.lName}}
</mat-list-item>
</mat-list>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None
</mat-list>`
})
export class SolutionForkJoinComponent {
list$: Observable<JoinedItem[]> = forkJoin([
list$: Observable<BlogPost[]> = forkJoin([
this.listService.httpGetLists(),
this.listService.httpGetItems()
]).pipe(
......
import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core';
import {Observable} from "rxjs";
import {ForkJoinListService} from "./forkJoin-list.service";
import {JoinedItem} from "shared";
import {BlogPost} from "shared";
@Component({
selector: 'solution-forkJoin',
template: `<h3>forkJoin</h3>
<div *ngIf="list$ | async as list">
template: `
<h3>forkJoin</h3>
<mat-list>
<mat-list-item *ngFor="let item of list">
<mat-list-item *ngFor="let item of list$ | async">
{{item.iName}} - {{item.lName}}
</mat-list-item>
</mat-list>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None
})
export class StartForkJoinComponent {
list$: Observable<JoinedItem>;
// this.listService.httpGetLists(),
// this.listService.httpGetItems()
// mergeListsAndItems(lists, items)
list$: Observable<BlogPost>;
constructor(private listService: ForkJoinListService) {
}
......
import {NgModule} from '@angular/core';
import {CommonModule} from "@angular/common";
import {RouterModule} from "@angular/router";
import {ROUTES} from "./http-service-v1.routes";
import {MatButtonModule} from "@angular/material/button";
import {MatListModule} from "@angular/material/list";
import {HttpClientModule} from "@angular/common/http";
import {StartHttpServiceV1Component} from "combining-streams/lib/exercises/http-service-v1/start.http-service-v1.component";
import {SolutionHttpServiceV1Component} from "combining-streams/lib/exercises/http-service-v1/solution.http-service-v1.component";
import { CommonModule } from '@angular/common';
import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatListModule } from '@angular/material/list';
import { RouterModule } from '@angular/router';
import { SolutionHttpServiceV1Component } from 'combining-streams/lib/exercises/http-service-v1/solution.http-service-v1.component';
import { StartHttpServiceV1Component } from 'combining-streams/lib/exercises/http-service-v1/start.http-service-v1.component';
import { ROUTES } from './http-service-v1.routes';
@NgModule({
declarations: [
......
import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core';
import {forkJoin, Observable} from "rxjs";
import {map} from "rxjs/operators";
import {JoinedItem, mergeListsAndItems} from "shared";
import {combineLatestListService} from "combining-streams/lib/exercises/combineLatest/combineLatest-list.service";
import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core';
import { forkJoin, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { BlogBasicService, BlogPost, mergeListsAndItems } from 'shared';
@Component({
selector: 'solution-custom-http-service-v1',
template: `<h3>(Solution) custom-http-service-v1</h3>
<button mat-raised-button color="primary" (click)="listService.addItem({iName: 'new item', lId: 1})">AddItem</button>
<button mat-raised-button color="primary" (click)="listService.addPost({title: 'new post'}); refetch();">Add Post</button>
<div *ngIf="list$ | async as list">
<div *ngIf="blog$ | async as blog">
<mat-list>
<mat-list-item *ngFor="let item of list">
{{item.iName}} - {{item.lName}}
<mat-list-item *ngFor="let post of blog">
<span mat-line>{{post.title}}</span>
<span mat-line>Comments: {{post.commentCount}}</span>
</mat-list-item>
</mat-list>
</div>
......@@ -24,17 +24,24 @@ import {combineLatestListService} from "combining-streams/lib/exercises/combineL
})
export class SolutionHttpServiceV1Component {
list$: Observable<JoinedItem[]> = forkJoin([
this.listService.lists$,
this.listService.items$
blog$: Observable<BlogPost[]> = this.getBlogList();
constructor(public listService: BlogBasicService) {
}
refetch() {
this.blog$ = this.getBlogList();
}
private getBlogList(): Observable<BlogPost[]> {
return forkJoin([
this.listService.httpGetPosts(),
this.listService.httpGetComments()
])
.pipe(
map(([list, items]) => mergeListsAndItems(list, items))
map(([posts, comments]) => mergeListsAndItems(posts, comments))
);
constructor(public listService: combineLatestListService) {
this.listService.refetchLists();
this.listService.refetchItems();
}
}
import {Injectable} from '@angular/core';
import {BehaviorSubject, combineLatest, Observable, of} from 'rxjs';
import {catchError, map} from 'rxjs/operators';
import {Item, JoinedItem, List, mergeListsAndItems, upsertEntities} from "shared";
import {StartHttpV1Service} from "combining-streams/lib/exercises/http-service-v1/start.http-v1.service";
import {HttpClient} from "@angular/common/http";
interface ListServiceState {
lists: List[];
items: Item[];
}
@Injectable({
providedIn: 'root'
})
export class SolutionHttpV1Service {
private readonly baseUrl = 'api';
private readonly itemUrl = [this.baseUrl, 'item'].join('/');
private readonly listUrl = [this.baseUrl, 'list'].join('/');
private readonly state$ = new BehaviorSubject<ListServiceState>({
lists: [] as List[],
items: [] as Item[]
});
lists$ = this.state$.pipe(map(s => s.lists));
items$ = this.state$.pipe(map(s => s.items));
constructor(private http: HttpClient) {
}
refetchLists() {
this.httpGetLists()
.subscribe(lists => {
console.log('upsert:', upsertEntities(this.state$.getValue().lists, lists, 'id'));
this.state$.next({
...this.state$.getValue(),
lists: upsertEntities(this.state$.getValue().lists, lists, 'id')
});
});
}
httpGetLists(): Observable<List[]> {
return this.http.get<List[]>(this.listUrl).pipe(
catchError(() => of([] as List[]))
);
}
httpPostItems(item: Pick<Item, 'iName' | 'lId'>): Observable<Item[]> {
return this.http.post<Item[]>(this.itemUrl, item);
}
addItems(item: Pick<Item, 'iName' | 'lId'>) {
this.httpPostItems(item)
.subscribe((v) => {
console.log(v);
this.refetchItems()
}, console.log);
}
refetchItems() {
this.httpGetItems()
.subscribe(items => {
this.state$.next({
...this.state$.getValue(),
items: upsertEntities(this.state$.getValue().items, items, 'id')
});
});
}
httpGetItems(): Observable<Item[]> {
return this.http.get<Item[]>(this.itemUrl).pipe(
catchError(() => of([] as Item[]))
);
}
}
import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core';
import {combineLatest, forkJoin, Observable} from "rxjs";