Disable while pending page
Learn how to convert an observable to a multicast subject.
The problem
In this section, we will:
- Disable the payment button while the promise is pending (or the card is invalid).
- Make sure that submitting the form does not produce two requests.
How to solve this problem
- Create a
this.disablePaymentButton$
observable that combinesisCardInvalid$
andpaymentStatus$
using adisablePaymentButton(isCardInvalid$, paymentStatus$)
function. - Convert
this.paymentStatus$
to a multicastSubject
.
What you need to know
Observables and their operators execute with every new subscription. This can cause problems
if that execution causes side effects. The following shows that the square
mapping
runs twice, once for every subscription on the squares
observable.
<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/7.4.0/rxjs.umd.min.js"></script>
<script type="typescript">
const { Subject } = rxjs;
const { map } = rxjs.operators;
const numbers = new Subject<number>();
const square = map((x: number) => {
console.log("mapping");
return x * x;
});
const squares = numbers.pipe(square);
squares.subscribe((value) => {
console.log("squares1", value);
});
squares.subscribe((value) => {
console.log("squares2", value);
});
numbers.next(2);
// Logs: mapping
// squares1 4
// mapping
// squares2 4
</script>
You can change this by converting the observable to a subject with:
.pipe(share())
The following shows this using this technique to run square
only once
for all subscribers of squares
:
<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/7.4.0/rxjs.umd.min.js"></script>
<script type="typescript">
const { Subject } = rxjs;
const { map, share } = rxjs.operators;
const numbers = new Subject<number>();
const square = map((x: number) => {
console.log("mapping");
return x * x;
});
const squares = numbers.pipe(square).pipe(share());
squares.subscribe((value) => {
console.log("squares1", value);
});
squares.subscribe((value) => {
console.log("squares2", value);
});
numbers.next(2);
// Logs: mapping
// squares1 4
// squares2 4
</script>
Read more about this technique on RxJS's documentation. Note that the multicast
and refCount
operators are deprecated in RxJS 7, and the share
operator used above is analogous to the functionality formerly provided by multicast(() => new Subject()), refCount()
.
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, share } = 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",
}))
);
function disablePaymentButton(isCardInvalid$, paymentStatus$) {
return combineLatest([isCardInvalid$, paymentStatus$]).pipe(
map(([isCardInvalid, paymentStatus]) => {
return (
isCardInvalid === true ||
!paymentStatus ||
paymentStatus.status === "pending"
);
})
);
}
@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]="disablePaymentButton$ | async">
{{ ((paymentStatus$ | async)?.status === "pending") ? "Paying" : "Pay" }}
</button>
</form>
`
})
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()).pipe(share());
this.disablePaymentButton$ = disablePaymentButton(this.isCardInvalid$, this.paymentStatus$);
}
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>