Show paying page
Learn how to flatten an observable that emits observables. Learn how to order observables.
The problem
In this section, we will:
- Change the button’s text to Paying while the
payments
promise is pending and to Pay while waiting to pay or payment has completed.
How to solve this problem
Create a
this.paymentStatus$
observable that emits paymentStatus objects:{ status: "waiting" }
- when the form is waiting to be submitted.{ status: "pending" }
- when the promise is pending.{ status: "resolved", value }
- when the promise has resolved. Includes the resolved value.
To create
this.paymentStatus$
we first will need to create apaymentStatusObservables$
observable from payments like:const paymentStatusObservables$ = payments$.pipe(toPaymentStatusObservable);
The
toPaymentStatusObservable
operator will convert the promises inpayments$
to Observables that publish paymentStatus objects. This means thatpaymentStatusObservables$
is an Observable of Observables of paymentStatus objects like:Observable<Observable<PaymentStatus>>
.For example, when a payment promise is published from
payments$
,paymentStatusObservables$
will publish an Observable that publishes:{ status: "pending" }
, and then{ status: "resolved", value }
.
Then, when a new payment promise is published from
payments$
again,paymentStatusObservables$
will publish a new Observable that publishes similar paymentStatus objects.Finally,
this.paymentStatus$
will be a result of merging (or flattening) the observables emitted bypaymentStatusObservables$
.
What you need to know
This is a tricky problem. A promise has state (if it’s pending or resolved). We need
to convert this state to observables. The pattern is to map the promises to an observable of
observables and then flatten that observable with mergeAll
.
from - converts a
Promise
to an observable. The followingthousand
observable emits1000
whenpromise
resolves:<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/7.4.0/rxjs.umd.min.js"></script> <script type="typescript"> const { from } = rxjs; const promise = new Promise((resolve) => { setTimeout(() => { resolve(1000); }, 2000); }); const thousand = from(promise); thousand.subscribe(console.log); // thousand: 1000X </script>
HINT:
from
andmap
can be used to convert the payment promises to an observable that emits{ status: "resolved", value }
.
concat concatenates streams so events are produced in order.
<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/7.4.0/rxjs.umd.min.js"></script> <script type="typescript"> const { of, zip, timer, from, concat } = rxjs; function sequentially(value, dueTime, period) { return zip( from(value), timer(dueTime, period), value => value ); } const first = sequentially(["A", "B"], 0, 1000); const second = sequentially(["x", "y"], 500, 1000); // first: -A---BX // second: ---x---y_X const letters = concat(first, second); letters.subscribe(console.log); // letters: -A---B-x-yX </script>
HINT:
concat
can be used to make an observable emit a{ status: "pending" }
paymentStatus object before emitting the{ status: "resolved", value }
paymentStatus object.
startWith returns an Observable that emits the items you specify as arguments before it begins to emit items emitted by the source Observable.
The following uses
startWith
to add"A"
before the"X"
and"Y"
values are emitted.<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/7.4.0/rxjs.umd.min.js"></script> <script type="typescript"> const { of, zip, timer, from } = rxjs; const { startWith } = rxjs.operators; function sequentially(value, dueTime, period) { return zip( from(value), timer(dueTime, period), value => value ); } const xAndY = sequentially(["X", "Y"], 0, 1000); // xAndY: ---X---YX const letters = xAndY.pipe(startWith("A")); letters.subscribe(console.log); // letters: A-X---Y </script>
HINT:
startWith
is used bytoPaymentStatusObservable
to make sure a payment status of "waiting" is published first.
of converts a value (or values) to a observable.
of(10, 20, 30).subscribe((next) => console.log('next:', next)); // result: // 'next: 10' // 'next: 20' // 'next: 30'
HINT:
of
can be used to convert plain paymentStatus objects into an observable that emits the paymentStatus object.
The static pipe function can be used to combine operators. The following makes a
squareStartingWith2
operator that ensures a2
will be the first number squared and a4
the first value emitted:<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/7.4.0/rxjs.umd.min.js"></script> <script type="typescript"> const { from, pipe, Subject } = rxjs; const { map, startWith } = rxjs.operators; const squareStartingWith2 = pipe( startWith(2), map(x => x * x) ); const number = new Subject<number>(); const square = number.pipe(squareStartingWith2); square.subscribe(console.log); //-> logs 4 number.next(3); //-> logs 9 </script>
HINT:
pipe
can be used to combine:
- a
map
operator that will take a payment promise and map that to an Observable of payment status objects.- a
startWith
operator that ensures an Observable that emits{ status: "waiting" }
is emitted first.
mergeAll takes an observable that emits inner observables and emits what the inner observables emits.
In the following example,
observables
emits:- An observable that emits numbers, then
- An observable that emits letters.
mergeAll
flattensobservables
so thatvalues
emits the numbers and letters directly.<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/7.4.0/rxjs.umd.min.js"></script> <script type="typescript"> const { of } = rxjs; const { mergeAll } = rxjs.operators; const numbers = of(1, 2, 3); const letters = of("a", "b", "c"); const observables = of(numbers, letters); // observables: [1-2-3]-[a-b-c]X const values = observables.pipe(mergeAll()); values.subscribe(console.log); // values: 1-2-3-a-b-cX </script>
- Read a value from an observable’s last emitted value with the
conditional operator (
?.
) like:{{ (paymentStatus$ | async)?.status }}
- Use the ternary operator (
condition ? truthy : falsy
) in Angular like:{{ status === "pending" ? "Paying" : "Pay" }}
The solution
Click to see the solution
<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/7.4.0/rxjs.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/core-js/3.19.0/core.js"></script>
<script src="https://unpkg.com/@angular/core@12.2.16/bundles/core.umd.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/zone.js/0.11.4/zone.min.js"></script>
<script src="https://unpkg.com/@angular/common@12.2.16/bundles/common.umd.js"></script>
<script src="https://unpkg.com/@angular/compiler@12.2.16/bundles/compiler.umd.js"></script>
<script src="https://unpkg.com/@angular/platform-browser@12.2.16/bundles/platform-browser.umd.js"></script>
<script src="https://unpkg.com/@angular/platform-browser-dynamic@12.2.16/bundles/platform-browser-dynamic.umd.js"></script>
<my-app></my-app>
<script type="typescript">
// app.js
const { Component, VERSION } = ng.core;
const { BehaviorSubject, Subject, merge, combineLatest, of, from, concat, pipe } = rxjs;
const { map, tap, scan, withLatestFrom, mergeAll, startWith } = rxjs.operators;
const cleanCardNumber = map((card) => {
if (card) {
return card.replace(/[\s-]/g, "");
}
});
const validateCard = map((card) => {
if (!card) {
return "There is no card";
}
if (card.length !== 16) {
return "There should be 16 characters in a card";
}
});
const log = (name) => {
return tap((value) => console.log(name, value));
};
function showOnlyWhenBlurredOnce(error$, blurred$) {
const errorEvents$ = error$.pipe(
map((error) => {
return { type: error ? "invalid" : "valid" };
})
);
const focusEvents$ = blurred$.pipe(
map((isBlurred) => {
return { type: isBlurred ? "blurred" : "focused" };
})
);
const events$ = merge(errorEvents$, focusEvents$);
const eventsToState = scan(
(previous, event) => {
switch (event.type) {
case "valid":
return { ...previous, isValid: true, showCardError: false };
case "invalid":
return {
...previous,
isValid: false,
showCardError: previous.hasBeenBlurred,
};
case "blurred":
return {
...previous,
hasBeenBlurred: true,
showCardError: !previous.isValid,
};
default:
return previous;
}
},
{
hasBeenBlurred: false,
showCardError: false,
isValid: false,
}
);
const state$ = events$.pipe(eventsToState);
return state$.pipe(map((state) => state.showCardError));
}
const expiryParts = map((expiry) => {
if (expiry) {
return expiry.split("-");
}
});
const validateExpiry = map((expiry) => {
if (!expiry) {
return "There is no expiry. Format MM-YY";
}
if (
expiry.length !== 2 ||
expiry[0].length !== 2 ||
expiry[1].length !== 2
) {
return "Expiry must be formatted like MM-YY";
}
});
const validateCVC = map((cvc) => {
if (!cvc) {
return "There is no CVC code";
}
if (cvc.length !== 3) {
return "The CVC must be at least 3 numbers";
}
if (isNaN(parseInt(cvc))) {
return "The CVC must be numbers";
}
});
function isCardInvalid(cardError$, expiryError$, cvcError$) {
return combineLatest([cardError$, expiryError$, cvcError$]).pipe(
map(([cardError, expiryError, cvcError]) => {
return !!(cardError || expiryError || cvcError);
})
);
}
function combineCard(cardNumber$, expiry$, cvc$) {
return combineLatest([cardNumber$, expiry$, cvc$]).pipe(
map(([cardNumber, expiry, cvc]) => {
return {
cardNumber,
expiry,
cvc,
};
})
);
}
function paymentPromises(paySubmitted$, card$) {
return paySubmitted$.pipe(withLatestFrom(card$)).pipe(
map(([paySubmitted, card]) => {
console.log("Asking for token with", card);
return new Promise((resolve) => {
setTimeout(() => {
resolve(1000);
}, 2000);
});
})
);
}
const toPaymentStatusObservable = pipe(
map((promise) => {
if (promise) {
// Observable<PaymentStatus>
return concat(
of({
status: "pending",
}),
from(promise).pipe(
map((value) => {
console.log("resolved promise!");
return {
status: "resolved",
value: value,
};
})
)
);
} else {
// Observable<PaymentStatus>
return of({
status: "waiting",
});
}
}),
startWith(of({
status: "waiting",
}))
);
@Component({
selector: 'my-app',
template: `
<form (submit)="pay($event)">
<div class="message" *ngIf="showCardError$ | async">{{ cardError$ | async }}</div>
<div class="message" *ngIf="showExpiryError$ | async">{{ expiryError$ | async }}</div>
<div class="message" *ngIf="showCVCError$ | async">{{ cvcError$ | async }}</div>
<input
type="text"
name="cardNumber"
placeholder="Card Number"
(input)="userCardNumber$.next($event.target.value)"
(blur)="userCardNumberBlurred$.next(true)"
[class.is-error]="showCardError$ | async"
/>
<input
type="text"
name="expiry"
placeholder="MM-YY"
(input)="userExpiry$.next($event.target.value)"
(blur)="userExpiryBlurred$.next(true)"
[class.is-error]="showExpiryError$ | async"
/>
<input
type="text"
name="cvc"
placeholder="CVC"
(input)="userCVC$.next($event.target.value)"
(blur)="userCVCBlurred$.next(true)"
[class.is-error]="showCVCError$ | async"
/>
<button [disabled]="isCardInvalid$ | async">
{{ ((paymentStatus$ | async)?.status === "pending") ? "Paying" : "Pay" }}
</button>
</form>
UserCardNumber: {{ userCardNumber$ | async }} <br />
CardNumber: {{ cardNumber$ | async }} <br />
`
})
class AppComponent {
userCardNumber$ = new BehaviorSubject<string>();
userCardNumberBlurred$ = new Subject<boolean>();
userExpiry$ = new BehaviorSubject<[]>();
userExpiryBlurred$ = new Subject<boolean>();
userCVC$ = new BehaviorSubject<string>();
userCVCBlurred$ = new Subject<boolean>();
paySubmitted$ = new Subject<void>();
constructor() {
this.cardNumber$ = this.userCardNumber$
.pipe(cleanCardNumber)
.pipe(log("cardNumber"));
this.cardError$ = this.cardNumber$.pipe(validateCard);
this.showCardError$ = showOnlyWhenBlurredOnce(this.cardError$, this.userCardNumberBlurred$);
this.expiry$ = this.userExpiry$.pipe(expiryParts);
this.expiryError$ = this.expiry$.pipe(validateExpiry);
this.showExpiryError$ = showOnlyWhenBlurredOnce(this.expiryError$, this.userExpiryBlurred$);
this.cvc$ = this.userCVC$;
this.cvcError$ = this.cvc$.pipe(validateCVC);
this.showCVCError$ = showOnlyWhenBlurredOnce(this.cvcError$, this.userCVCBlurred$);
this.isCardInvalid$ = isCardInvalid(this.cardError$, this.expiryError$, this.cvcError$);
const card$ = combineCard(this.cardNumber$, this.expiry$, this.cvc$);
const payments$ = paymentPromises(this.paySubmitted$, card$);
const paymentStatusObservables$ = payments$.pipe(toPaymentStatusObservable);
this.paymentStatus$ = paymentStatusObservables$.pipe(mergeAll());
}
pay(event) {
event.preventDefault();
this.paySubmitted$.next();
}
}
// main.js
const { BrowserModule } = ng.platformBrowser;
const { NgModule } = ng.core;
const { CommonModule } = ng.common;
@NgModule({
imports: [
BrowserModule,
CommonModule,
],
declarations: [AppComponent],
bootstrap: [AppComponent],
providers: []
})
class AppModule {}
const { platformBrowserDynamic } = ng.platformBrowserDynamic;
platformBrowserDynamic()
.bootstrapModule(AppModule)
.catch(err => console.error(err));
</script>
<style>
@import url('https://fonts.googleapis.com/css?family=Raleway:400,500');
body {
background-color: rgba(8, 211, 67, 0.3);
padding: 2%;
font-family: 'Raleway', sans-serif;
font-size: 1em;
}
input {
display: block;
width: 100%;
box-sizing: border-box;
font-size: 1em;
font-family: 'Raleway', sans-serif;
font-weight: 500;
padding: 12px;
border: 1px solid #ccc;
outline-color: white;
transition: background-color 0.5s ease;
transition: outline-color 0.5s ease;
}
input[name='cardNumber'] {
border-bottom: 0;
}
input[name='expiry'],
input[name='cvc'] {
width: 50%;
}
input[name='expiry'] {
float: left;
border-right: 0;
}
input::placeholder {
color: #999;
font-weight: 400;
}
input:focus {
background-color: rgba(130, 245, 249, 0.1);
outline-color: #82f5f9;
}
input.is-error {
background-color: rgba(250, 55, 55, 0.1);
}
input.is-error:focus {
outline-color: #ffbdbd;
}
button {
font-size: 1em;
font-family: 'Raleway', sans-serif;
background-color: #08d343;
border: 0;
box-shadow: 0px 1px 3px 1px rgba(51, 51, 51, 0.16);
color: white;
font-weight: 500;
letter-spacing: 1px;
margin-top: 30px;
padding: 12px;
text-transform: uppercase;
width: 100%;
}
button:disabled {
opacity: 0.4;
background-color: #999999;
}
form {
background-color: white;
box-shadow: 0px 17px 22px 1px rgba(51, 51, 51, 0.16);
padding: 40px;
margin: 0 auto;
max-width: 500px;
}
.message {
margin-bottom: 20px;
color: #fa3737;
}
</style>