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