3

If my typeahead gets an empty search result, any subsequent query with a norrowed down search query should be prevented. E.g. if the search for 'red' returns empty, a search for 'redcar' makes no sense.

I tried using pairwise() and scan() operator. Code snippet:

import { tap, switchMap, filter, pairwise, scan, map } from 'rxjs/operators';

this.searchForm.get('search').valueChanges
  .pipe(
    switchMap( queryString => this.backend.search(queryString))
  )
  .subscribe()

Update Given a simplified scenario: There is only the term 'apple' in the backend. The user is typing the search string (the request is not aborted by the switchMap()):

  1. 'a' -------> backend call returns 'apple'
  2. 'ap' ------> backend call returns 'apple'
  3. 'app' -----> backend call returns 'apple'
  4. 'appl' ----> backend call returns 'apple'
  5. 'apple' ---> backend call returns 'apple'
  6. 'apple p' -----> backend call returns EMPTY
  7. 'apple pi' ----> backend call returns EMPTY
  8. 'apple pie' ---> backend call returns EMPTY

The backend calls for 7. and 8. are unnecessary, because 6. already returns EMPTY. Therfore any subsequent call could be omitted. In my opinion some memoization is needed.

I would like to prevent unnecessary backend calls (http). Is there any way to achieve this in rxjs?

  • 1
    skipWhile(val => val === 'red') https://www.learnrxjs.io/operators/filtering/skipwhile.html –  Jun 25 '19 at 14:18
  • Thanks for the update. Do you only want to block calls when you return _empty_? Because the way it's phrased, not only are 7,8 unnecessary, but 2 (perhaps 3) through 5 as well. – msanford Jun 25 '19 at 19:12
  • I'll just leave this here, while it _does not do exactly_ what you are looking for, this is normally the way people _achieve a similar functionality,_ and might be worth looking at https://stackoverflow.com/questions/32051273/angular-and-debounce – msanford Jun 25 '19 at 19:55

3 Answers3

2

You can use the filter operator:

this.searchForm.get('search').valueChanges.pipe(
  filter(query => query)
  switchMap(query => this.backend.search(queryString))
)

You can try out this mechanism here: RxJS-Editor

Code-share did not work so you get the code here:

const { of } = Rx;
const { filter } = RxOperators;

of('foo1', 'foo2', undefined, undefined, 'foo3').pipe(
  filter(value => value)  
)
msanford
  • 11,803
  • 11
  • 66
  • 93
Jonathan Stellwag
  • 3,843
  • 4
  • 25
  • 50
2

Sounds like you want to keep all failed searches and check whether current search would fail also if HTTP is called. I cant think of any elegant way of having this in one stream, but with two streams:

_failedStreams = new Subject();
failedStreams$ = _failedStreams.asObservable().pipe(
  scan((acc, curr) => [...acc, curr], []),
  startWith('')
);

this.searchForm.get('search').valueChanges
  .pipe(
    withLatestFrom(failedStreams$),
    switchMap([queryString, failedQueries] => {
      return iif(() => failedQueries.find(failed => failed.startsWith(queryString)) ?
        of('Not found') :
        callBackend(queryString);
      )
    }
  )
  .subscribe()

callBackend(queryString) {
  this.backend.search(queryString)).pipe(
    .catchError(err => if(error.status===404) {
      this._failedStreams.next(queryString);
      // do something with error stream, for ex:
      throwError(error.status)
    }
  )
}

Code is not tested, but you get the idea

Julius Dzidzevičius
  • 10,775
  • 11
  • 36
  • 81
2

This is an interesting use-case and one of a very few situations where mergeScan is useful.

Basically, you want to remember the previous search term and the previous remote call result and based on their combination you'll decide whether you should make another remote call or just return EMPTY.

import { of, EMPTY, Subject, forkJoin } from 'rxjs'; 
import { mergeScan, tap, filter, map } from 'rxjs/operators';

const source$ = new Subject();
// Returns ['apple'] only when the entire search string is contained inside the word "apple".
// 'apple'.indexOf('app') returns 0
// 'apple'.indexOf('apple ap') returns -1
const makeRemoteCall = (str: string) =>
  of('apple'.indexOf(str) === 0 ? ['apple'] : []).pipe(
    tap(results => console.log(`remote returns`, results)),
  );

source$
  .pipe(
    tap(value => console.log(`searching "${value}""`)),
    mergeScan(([acc, previousValue], value: string) => {
      // console.log(acc, previousValue, value);
      return (acc === null || acc.length > 0 || previousValue.length > value.length)
        ? forkJoin([makeRemoteCall(value), of(value)]) // Make remote call and remember the previous search term
        : EMPTY;
    }, [null, '']),
    map(acc => acc[0]), // Get only the array of responses without the previous search term
    filter(results => results.length > 0), // Ignore responses that didn't find any results
  )
  .subscribe(results => console.log('results', results));

source$.next('a');
source$.next('ap');
source$.next('app');
source$.next('appl');
source$.next('apple');
source$.next('apple ');
source$.next('apple p');
source$.next('apple pi');
source$.next('apple pie');

setTimeout(() => source$.next('app'), 3000);
setTimeout(() => source$.next('appl'), 4000);

Live demo: https://stackblitz.com/edit/rxjs-do457

Notice that after searching for "apple " there are no more remote calls. Also, after 3s when you try searching a different term "'app'" it does make a remote call again.

martin
  • 93,354
  • 25
  • 191
  • 226