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 combines isCardInvalid and paymentStatus using a disablePaymentButton(isCardInvalid, paymentStatus) function.
  • Convert this.paymentStatus to a multicast Subject.

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/6.2.1/rxjs.umd.js"></script>
<script type="typescript">
const {Subject} = rxjs;
const {map} = rxjs.operators;

const numbers = new Subject();

const square = map( (x)=> {
    console.log("mapping");
    return x*x;
} );

var 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(multicast(new Subject()), refCount())

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/6.2.1/rxjs.umd.js"></script>
<script type="typescript">
const {Subject} = rxjs;
const {map, multicast, refCount} = rxjs.operators;

const numbers = new Subject();

const square = map( (x)=> {
    console.log("mapping");
    return x*x;
} );

const squares = numbers.pipe(square)
    .pipe(multicast(new Subject()), refCount());

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.

Solution

<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/6.2.1/rxjs.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/core-js/2.5.7/core.js"/></script>
<script src="https://unpkg.com/@angular/core@6.0.5/bundles/core.umd.js"/></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/zone.js/0.8.26/zone.min.js"></script>
<script src="https://unpkg.com/@angular/common@6.0.5/bundles/common.umd.js"></script>
<script src="https://unpkg.com/@angular/compiler@6.0.5/bundles/compiler.umd.js"></script>
<script src="https://unpkg.com/@angular/platform-browser@6.0.5/bundles/platform-browser.umd.js"></script>
<script src="https://unpkg.com/@angular/platform-browser-dynamic@6.0.5/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, multicast, refCount } = 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 = function(name) {
    return tap(value => console.log(name, value))
}

function showOnlyWhenBlurredOnce(errorObservable, blurredObservable) {

    const errorEvents = errorObservable.pipe(
        map((error) => {
            return {type: error ? "invalid" : "valid"}
        })
    );

    const focusEvents = blurredObservable.pipe(
        map((isBlurred) => {
            return {type: isBlurred ? "blurred" : "focused"};
        })
    );

    var 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
    });

    var 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, function(cardError, expiryError, cvcError) {
        return !!(cardError || expiryError || cvcError)
    })
}

function combineCard(cardNumber, expiry, cvc) {
    return combineLatest(cardNumber, expiry, cvc, function(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(function(resolve) {
                setTimeout(function() {
                    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, function(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<Array>();
    userExpiryBlurred = new Subject<Boolean>();

    userCVC = new BehaviorSubject<String>();
    userCVCBlurred = new Subject<Boolean>();

    paySubmitted = new Subject();

    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(multicast(new Subject()), refCount());

        this.disablePaymentButton = disablePaymentButton(this.isCardInvalid, this.paymentStatus);
    }
    pay(event) {
        event.preventDefault();
        this.paySubmitted.next(true);
    }
}

// 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>