RxJS - Using pairwise to confirm and revert input field
Asked Answered
O

1

3

So I'm a little new to observables, I'm struggling with a scenario, and I thought it might be a good candidate for an SO question. Here we go...

The scenario is this: I have a drop-down field; when it is changed, I want to

  1. check a condition based on the previous and new values of the field
  2. request from the user a confirmation if the condition passes, and ...
  3. then revert the value of the field if the user did not confirm.

Here's the code with comments:

  ngOnInit(): void {
    
    // I am waiting for my view-model to load, then initializing my FormGroup using that view model data.
    // NOTE: My view model is for "Contact" (this code is for contact list)
    this.addSubcription(this.vm$.subscribe((vm) => this.initFormGroup(vm)));
    
    const field:string = 'customerEmployerId'; // the field's name
    // I create the observable that should listen to changes in the field, and return them in pairs
    const employerValueChanges$ = this.formInit$.pipe(
      switchMap(form=> form.get(field).valueChanges.pipe(
        startWith(form.get(field).value)
      )),
      pairwise()
    );

    // I combine the changes observable with my other observables to access data from each
    let employerCheckSub = combineLatest([
      employerValueChanges$, // the value-changes obs
      this.vm$, // the view-model data
      this.customers$ // a list of customers from a CustomerService
    ]).subscribe(
      ([
        [oldid,newid], // values from value-changes obs
        contact, // the contact info / data
        customers // the list of customers
      ])=> {
        
        // check the previously and newly selected employer values
        // request confirmation if contact was listed as the primary contact for the previously selected employer
         if(oldid > 0 && newid !== oldid){
           const employer = customers.find(c=> c.customerId === oldid && c.contactId === contact.contactId);
           if(employer === null) return;
           if(!confirm('Warning: changing this contact\'s employer will also remove them '+
           'as the primary contact for that customer. Are you should you want to continue?')){ 
             // user clicked cancel, so revert back to the previous value without emitting event
             this.contactEditForm.get(field).setValue(oldid, {emitEvent:false});
           }
         }
      });

    this.addSubcription(employerCheckSub);
  }

The problem is that when I revert the value without emitting an event, the pairwise observable emits an incorrect "previous" value on the next value change. My hope is that there is an RxJS operator or two out there that I am missing and would work perfectly here. Does anyone have a trick to resolve this that they can share?


UPDATE WITH WORKING CODE:

First off, special thanks to Andrej's answer. His use of the scan operator was definitely the way to go. There was only one little fix that I needed which was to also set the crt (or current in the code below) value as well as the prev values in the accumulator. And voila! Here is my final working version:

/**
   * Requests confirmation when attempting to change a contact's employer if that contact is also
   * designated as the employer's primary contact.
   */
  private addEmployerChangeConfirmation() {
    // NOTE: In this scenario, "customers" are synonymous with "employers"; i.e., our customers are employers of these contacts.
    const field: string = 'customerEmployerId'; // the field's name
    const valueChanges$ = this.formInit$.pipe(
      switchMap((form) => form.get(field).valueChanges)
    );

    let employerCheckSub = combineLatest([
      // the value-changes obs
      valueChanges$,
      // the id needed from the view model
      this.vm$.pipe(
        filter((vm) => vm !== null),
        map((vm) => vm.contactId)
      ),
      // the customer/employer list
      this.customers$,
    ])
      .pipe(
        // once the user approves, I don't bother re-confirming if they change back in same session
        // NOTE: I use a "$$" naming convention to indicate internal subjects that lack a corresponding public-facing observable.
        takeUntil(this.employerChangeApproved$$),
        scan(
          (acc, [current, contactId, customers], i) => ({
            prevOfPrev: acc.prev,
            ///////////////////////////////////////////////////////////////////////////////////////////////////
            // NOTE: This was an interesting issue. Apparently the seed value is resolved immediately.
            // So, there is no way I found to seed a value from another obs.
            // Instead, I just check if this is the first run, and if so I use the resolved data for prev value.
            // I know the data is resolved because an upstream obs provides it.
            ///////////////////////////////////////////////////////////////////////////////////////////////////
            prev: i === 0 ? this.contactData.customerEmployerId : acc.current, // <-- setting seed manually on first emission
            current,
            contactId,
            customers,
          }),
          {
            prevOfPrev: null,
            prev: null,
            current: this.contactData?.customerEmployerId,
            contactId: this.contactData?.contactId,
            customers: [],
          }
        ),
        // only continue if condition passes
        filter((data) =>
          this.checkIfChangeWillRemoveAsPrimaryContact(
            data.prev,
            data.current,
            data.contactId,
            data.customers
          )
        ),
        // we only want to revert if user clicks cancel on confirmation box.
        // NOTE: If they approve change, this also triggers the "employerChangeApproved$$" subject.
        filter((data) => !this.confirmRemoveAsPrimaryContact())
      )
      // and now we actually subscribe to perform the action
      .subscribe((data) => {
        data.current = data.prev;
        data.prev = data.prevOfPrev;
        this.contactEditForm
          .get(field)
          .setValue(data.current, { emitEvent: false });
      }); 

    this.addSubcription(employerCheckSub);
  }
Oenomel answered 7/7, 2020 at 1:54 Comment(1)
I think this is solvable with RxJS, but I am thinking maybe a simpler solution could be fitting here. Can't you just have a boolean on your component that you set when the user change his value. Then when the value get's switched back, you check the boolean before you do anything.Gorlovka
H
2

Here would be my approach:

form.valuesChanges.pipe(
  scan(
    (acc, item) => ({
      // Needed in case we need to revert
      prevOfPrev: acc[prev],

      prev: acc[crt],
      crt: item,
    }), 
    { prevOfPrev: null, prev: null, crt: null }
  ),

  // 'check a condition based on the previous and new values of the field'
  filter(v => condition(v.prev, v.crt)),

  // 'request from the user a confirmation if the condition passes'
  switchMap(
    v => confirmationFromUser().pipe(
      // 'then revert the value of the field if the user did not confirm'
      tap(confirmed => !confirmed && (v[prev] = v[prevOfPrev])),
    )
  ),

  // Go further only if the user confirmed
  filter(v => !!v),
)
Hardshell answered 7/7, 2020 at 7:42 Comment(2)
Thank you so much this worked well. This use of the scan operator was the way to go. There was one little change to make it work: I had to update the crt value as well as the prev values of the accumulator.Oenomel
Thanks. I updated my question to include the final working code if you care to check it out.Oenomel

© 2022 - 2024 — McMap. All rights reserved.