Commit 9a27759d authored by Julian's avatar Julian
Browse files

improve combineLatest + start withlatestFrom

parent cd158540
......@@ -89,7 +89,8 @@ We need to replace `forkJoin` with an operator that matches the new requirements
`combineLatest` is perfectly suited for this case.
Use it and see if the list of BlogPosts renders now.
Try adding a new `Post` using the "Add Post" button. ??
Additionally, use the provided `addPost()` method and implement a way to add new `BlogPost` to the list.
If done correctly, the list of `BlogPost` should update instantly after adding a new entity.
......
import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core';
import {combineLatest, Observable} from 'rxjs';
import {map} from 'rxjs/operators';
import {BlogPost, toBlogPosts} from 'shared';
import {CombineLatestBlogService} from "combining-streams/lib/exercises/combineLatest/combine-latest-blog.service";
import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core';
import { CombineLatestBlogService } from './combine-latest-blog.service';
import { combineLatest, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { BlogPost, toBlogPosts } from 'shared';
@Component({
selector: 'solution-combineLatest',
template: `
<h1>(Solution) combineLatest</h1>
<mat-form-field>
<label>Name</label>
<input matInput name="post" [(ngModel)]="post"/>
</mat-form-field>
<button mat-raised-button color="primary" (click)="addPost()">Add Post</button>
<div *ngIf="blog$ | async as list">
......@@ -24,6 +28,8 @@ import {CombineLatestBlogService} from "combining-streams/lib/exercises/combineL
encapsulation: ViewEncapsulation.None
})
export class SolutionCombineLatestComponent {
post = 'my new post';
blog$: Observable<BlogPost[]> = combineLatest([
this.blogPostService.posts$,
this.blogPostService.comments$
......@@ -37,7 +43,7 @@ export class SolutionCombineLatestComponent {
}
addPost() {
this.blogPostService.addPost({title: 'New post'});
this.blogPostService.addPost({ title: this.post });
}
}
......@@ -8,7 +8,11 @@ import {CombineLatestBlogService} from "./combine-latest-blog.service";
selector: 'combineLatest',
template: `
<h1>combineLatest</h1>
<button mat-raised-button color="primary" (click)="addPost()">Add Post</button>
<mat-form-field>
<label>Name</label>
<input matInput name="post" [(ngModel)]="post"/>
</mat-form-field>
<button mat-raised-button color="primary">Add Post</button>
<div *ngIf="blog$ | async as list">
<mat-list>
......@@ -21,6 +25,8 @@ import {CombineLatestBlogService} from "./combine-latest-blog.service";
`
})
export class StartCombineLatestComponent {
post = 'my new post';
blog$: Observable<BlogPost[]> = forkJoin([
this.blogPostService.httpGetPosts(),
this.blogPostService.httpGetComments()
......@@ -33,8 +39,5 @@ export class StartCombineLatestComponent {
}
addPost() {
this.blogPostService.addPost({title: 'new post'});
}
}
......@@ -14,30 +14,6 @@ You can find the methods `httpGetComments` and `httpGetPosts` in the `ForkJoinBl
After retrieving the 2 results from the `forkJoin` creation function,
we use the `map` operator to calculate the new list of `BlogPost` with `toBlogPosts`.
**Interfaces**
```Typescript
// entity
interface Post {
id: string;
title: string;
content: string;
}
// entity
interface Comment {
id: string;
postId: string;
text: string;
}
// derivation
interface BlogPost {
id: string;
title: string;
comments: Comment[];
commentCount: number;
}
```
This `Component` will be the starting point for you. All needed dependencies are already included.
**Component**
......
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import {BehaviorSubject, Observable, of} from 'rxjs';
import {catchError, map} from 'rxjs/operators';
import {Comment, Post, upsertEntities} from 'shared';
interface BlogServiceState {
posts: Post[];
comments: Comment[];
}
@Injectable({
providedIn: 'root'
})
export class BlogService {
private readonly baseUrl = 'api';
private readonly commentUrl = [this.baseUrl, 'comment'].join('/');
private readonly postUrl = [this.baseUrl, 'post'].join('/');
private readonly state$ = new BehaviorSubject<BlogServiceState>({
posts: [] as Post[],
comments: [] as Comment[]
});
readonly posts$ = this.state$.pipe(map(s => s.posts));
readonly comments$ = this.state$.pipe(map(s => s.comments));
constructor(private http: HttpClient) {
this.fetchPosts();
this.fetchComments();
}
fetchPosts() {
this.httpGetPosts()
.subscribe(posts => {
this.state$.next({
...this.state$.getValue(),
posts: upsertEntities(this.state$.getValue().posts, posts, 'id')
});
});
}
fetchComments() {
this.httpGetComments()
.subscribe(comments => {
this.state$.next({
...this.state$.getValue(),
comments: upsertEntities(this.state$.getValue().comments, comments, 'id')
});
});
}
addPost(post: Pick<Post, 'title'>) {
this.httpPostPost(post)
.subscribe((newPost) => {
console.log('saved ', newPost , 'to the server');
this.state$.next({
...this.state$.getValue(),
posts: upsertEntities(this.state$.getValue().posts, [newPost], 'id')
})
}, console.log);
}
httpGetPosts(): Observable<Post[]> {
return this.http.get<Post[]>(this.postUrl).pipe(
catchError(() => of([] as Post[]))
);
}
httpPostComment(item: Pick<Comment, 'text' | 'postId'>): Observable<Comment[]> {
return this.http.post<Comment[]>(this.commentUrl, item);
}
httpPostPost(post: Pick<Post, 'title'>): Observable<Post> {
return this.http.post<Post>(this.postUrl, post);
}
httpGetComments(): Observable<Comment[]> {
return this.http.get<Comment[]>(this.commentUrl).pipe(
catchError(() => of([] as Comment[]))
);
}
}
# Opt-in updates - Exercise
## Intro
Now that we've learned how to efficiently handle data coming from multiple HTTP Endpoints we can move on and
tackle more complex scenarios. Our current solution displays data whenever it arrives (_instant updates_).
However, there will be situations where you want to _stage_ incoming data before actually render them.
Imagine a message feed like _twitter_ where new arriving _tweets_ first inform the user about updates before actually updating
the feed.
In this scenario, users are able to _opt-in_ for updates.
_current solution: instant updates visualized_
![](./assets/images/Reactive-architecture-and-ux-patterns_angular_instant-updates_michael-hladky.png)
_desired solution: opt-in updates visualized_
![](./assets/images/Reactive-architecture-and-ux-patterns_angular_opt-in-updates_michael-hladky.png)
For this exercise we have to extend our `Component` with extra functionalities.
```Typescript
@Component()
export class StartWithLatestFromComponent {
optInListClick$ = new Subject(); // performs the opt-in update on button click
numNewItems$: Observable<number>; // the derived number of new items available for display
feed$: Observable<BlogPost[]>; // the new feed to display. use blog$ and optIntListClick$ to calculate
}
```
## Exercise
Utilize the `withLatestFrom` combination operator and implement a _staging_ area for new arriving `BlogPost`.
Use the new introduced properties `optInListClick$`, `numNewItems$` and `feed$` to implement your solution. You should
end up replacing the view binding of `blog$` by `feed$`. Use `blog$` and `optInListClick$` to calculate the new feed.
# Opt-in updates - Solution
Staging new arriving data with `withLatestFrom`:
**Component**
```Typescript
// solution.withLatestFrom.component.ts
blog$ = combineLatest([
this.blogService.posts$.pipe(filter(l => !!l.length)),
this.blogService.comments$.pipe(filter(l => !!l.length))
]).pipe(
map(([list, items]) => toBlogPosts(list, items)),
shareReplay(1)
);
feed$: Observable<BlogPost[]> = concat(
this.blog$.pipe(take(1)),
this.optInListClick$.pipe(
withLatestFrom(this.blog$),
map(([_, items]) => items)
),
shareReplay(1)
);
numNewItems$: Observable<number> = combineLatest([
this.blog$,
this.feed$
])
.pipe(
map(([a, b]) => Math.abs(a.length - b.length))
);
```
Great! ... we need some text here :)
# Opt-in updates - Theory
The previous example showcased how to combine multiple ongoing `Observables` to one result. However, there are situations
where it is necessary to access the latest value from an `Observable` after a specific event occurs.
`withLatestFrom` combines the last _emitted_ value of the a provided source `Observable` to an active stream of data.
In this example we want to get the latest value of the `timer$` whenever a user clicks on the document.
```Typescript
import { fromEvent, interval } from 'rxjs';
import { withLatestFrom } from 'rxjs/operators';
const click$ = fromEvent(document, 'click'); // get the click event
const timer$ = interval(1000);
const result = click$.pipe(withLatestFrom(timer$));
result.subscribe(([clickEvent, latestTimerValue]) => console.log(latestTimerValue)); // logs the latest value of the timer
```
![](./assets/images/Reactive-architecture-and-ux-patterns_angular_combination-operators_withLatestFrom-outer-ongoing_michael-hladky.png)
![](./assets/images/Reactive-architecture-and-ux-patterns_angular_combination-operators_withLatestFrom-outer-error_michael-hladky.png)
![](./assets/images/Reactive-architecture-and-ux-patterns_angular_combination-operators_withLatestFrom-inner-error_michael-hladky.png)
......
import {Component} from '@angular/core';
import {combineLatest, concat, Observable, Subject} from 'rxjs';
import {filter, map, shareReplay, take, withLatestFrom} from 'rxjs/operators';
import {BlogBasicService, toBlogPosts} from 'shared';
import { BlogBasicService, BlogPost, toBlogPosts } from 'shared';
@Component({
selector: 'solution-opt-in-updates-basic',
......@@ -10,22 +10,21 @@ import {BlogBasicService, toBlogPosts} from 'shared';
<mat-form-field>
<label>Name</label>
<input matInput name="comment" [(ngModel)]="comment"/>
<input matInput name="post" [(ngModel)]="post"/>
</mat-form-field>
<button mat-raised-button color="primary" (click)="listService.addComment({'text': comment, 'postId': 1})">AddItem
</button>
<button mat-raised-button color="primary" (click)="addPost()">Add Post</button>
<ng-container *ngIf="(numNewItems$ | async) as numItems">
<button mat-raised-button color="accent"
*ngIf="numItems > 0"
[disabled]="numItems === 0"
(click)="optInListClick$.next($event)">
Update List ({{(
New posts: ({{(
numItems
)}})
</button>
</ng-container>
<div *ngIf="acceptedItems$ | async as blog">
<div *ngIf="feed$ | async as blog">
<mat-list>
<mat-list-item *ngFor="let post of blog">
<span mat-line>{{post.title}}</span>
......@@ -37,36 +36,40 @@ import {BlogBasicService, toBlogPosts} from 'shared';
})
export class SolutionWithLatestFromComponent {
comment = 'my new comment';
post = 'my new post';
optInListClick$ = new Subject();
blog$ = combineLatest([
this.listService.posts$.pipe(filter(l => !!l.length)),
this.listService.comments$.pipe(filter(l => !!l.length))
this.blogService.posts$.pipe(filter(l => !!l.length)),
this.blogService.comments$.pipe(filter(l => !!l.length))
]).pipe(
map(([list, items]) => toBlogPosts(list, items)),
shareReplay(1)
);
acceptedItems$ = concat(
feed$: Observable<BlogPost[]> = concat(
this.blog$.pipe(take(1)),
this.optInListClick$.pipe(
withLatestFrom(this.blog$),
map(([_, items]) => items)
)
),
shareReplay(1)
);
numNewItems$: Observable<number> = combineLatest([
this.blog$,
this.acceptedItems$
this.feed$
])
.pipe(
map(([a, b]) => Math.abs(a.length - b.length))
);
constructor(public listService: BlogBasicService) {
this.listService.fetchPosts();
this.listService.fetchComments();
constructor(public blogService: BlogBasicService) {
this.blogService.fetchPosts();
this.blogService.fetchComments();
}
addPost() {
this.blogService.addPost({ title: this.post });
}
}
import { Component } from '@angular/core';
import { combineLatest, of, Subject } from 'rxjs';
import { BlogService } from './blog.service';
import { combineLatest, Observable, of, Subject } from 'rxjs';
import { filter, map, shareReplay } from 'rxjs/operators';
import { BlogBasicService, toBlogPosts } from 'shared';
import { BlogPost, toBlogPosts } from 'shared';
@Component({
selector: 'with-latest-from',
......@@ -10,15 +11,15 @@ import { BlogBasicService, toBlogPosts } from 'shared';
<mat-form-field>
<label>Name</label>
<input matInput name="comment" [(ngModel)]="comment"/>
<input matInput name="post" [(ngModel)]="post"/>
</mat-form-field>
<button mat-raised-button color="primary" (click)="listService.addComment({'text': comment, 'postId': 1})">AddItem</button>
<button mat-raised-button color="primary" (click)="addPost()">Add Post</button>
<ng-container *ngIf="(numNewItems$ | async) as numItems">
<button mat-raised-button color="accent"
*ngIf="numItems > 0"
[disabled]="numItems === 0"
(click)="optInListClick$.next($event)">
Update List ({{(
New posts: ({{(
numItems
)}})
</button>
......@@ -36,21 +37,25 @@ import { BlogBasicService, toBlogPosts } from 'shared';
})
export class StartWithLatestFromComponent {
comment = 'my new comment';
post = 'my new post';
optInListClick$ = new Subject();
numNewItems$ = of(0);
numNewItems$: Observable<number>;
feed$: Observable<BlogPost[]>; // use optInListClick$ and blog$ to calculate new feed$
blog$ = combineLatest([
this.listService.posts$.pipe(filter(l => !!l.length)),
this.listService.comments$.pipe(filter(l => !!l.length))
this.blogService.posts$.pipe(filter(l => !!l.length)),
this.blogService.comments$.pipe(filter(l => !!l.length))
]).pipe(
map(([posts, comments]) => toBlogPosts(posts, comments)),
shareReplay(1)
);
constructor(public listService: BlogBasicService) {
this.listService.fetchPosts();
this.listService.fetchComments();
constructor(public blogService: BlogService) {
this.blogService.fetchPosts();
this.blogService.fetchComments();
}
addPost() {
this.blogService.addPost({ title: this.post });
}
}
......@@ -59,7 +59,7 @@ export class BlogBasicService {
this.httpPostPost(post)
.subscribe((v) => {
console.log(v);
// this.fetchPosts();
this.fetchPosts();
}, console.log);
}
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment