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 a paymentStatusObservables$ observable from payments like:

    const paymentStatusObservables$ = payments$.pipe(toPaymentStatusObservable);
    

    The toPaymentStatusObservable operator will convert the promises in payments$ to Observables that publish paymentStatus objects. This means that paymentStatusObservables$ 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:

    1. { status: "pending" }, and then
    2. { 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 by paymentStatusObservables$.

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 following thousand observable emits 1000 when promise 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 and map 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 by toPaymentStatusObservable 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 a 2 will be the first number squared and a 4 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:

    1. An observable that emits numbers, then
    2. An observable that emits letters.

    mergeAll flattens observables so that values 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>