Disable pay button page

Learn how to combine the latest values of two RxJS observables with the combineLatest operator.

Video

Who has time to read? This video covers the content on this page. Watch fullscreen.

The problem

In this section, we will:

  • disable the Pay button until the card, expiry, and cvc are valid.

How to solve this problem

  • Create a this.isCardInvalid property publishes true if either this.cardError this.expiryError, or this.cvcError are truthy.
  • Create an isCardInvalid that can be passed the this.cardError this.expiryError, or this.cvcError observables and returns the this.isCardInvalid observable.

What you need to know

  • The combineLatest static method combines several values into a single value:

    <script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/6.2.1/rxjs.umd.js"></script>
    <script type="typescript">
    const {of, zip, timer, from, combineLatest} = rxjs;
    
    function sequentially(value, dueTime, period){
        return zip( from(value), timer(dueTime, period),
            value => value
        )
    }
    
    const first = sequentially(["Justin", "Ramiya"], 0, 1000);
    const last = sequentially(["Shah", "Meyer"], 500, 1000);
    
    // first: ---Justin---RamiyaX
    // last:  ------Shah__---Meyer_X
    const fullName = combineLatest(first, last,
        (first, last) => { return first +" "+ last; }
    );
    
    fullName.subscribe(console.log);
    // fullName: ---Justin Shah
    //             -Ramiya Shah
    //             -Ramiya MeyerX
    </script>
    
  • [property]="value" can set an element property or attribute from another value:

    <button [disabled]="value">
    

The 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 } = rxjs;
const { map, tap, scan } = 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)
    })
}

@Component({
    selector: 'my-app',
    template: `
        <form>

            <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">
                PAY
            </button>
        </form>
        UserCardNumber: {{ (userCardNumber | async) }} <br/>
        CardNumber: {{ (cardNumber | async) }} <br/>
    `
})
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>();

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

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