Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to stop Material dropdown autocomplete selection from triggering another search query in Angular 8/9?

Summary: I have a working as-you-type field dropdown search. When I choose from the dropdown, which happily sets the field to the object I've received in the search, but unhappily registers THAT change and sends the whole object off to be searched.

HTML:

<form [formGroup]="myForm" novalidate>
  <mat-form-field>
    <input matInput 
    placeholder="SKU / Item Number" 
    [matAutocomplete]="auto" 
    formControlName='itemName'>
  </mat-form-field> 
  <mat-autocomplete #auto="matAutocomplete" [displayWith]="parseDropDownSelection">
      <mat-option 
            *ngFor="let row of searchQueryResult" 
            [value]="row" 
            (onSelectionChange)="onItemSelection($event)">
        <span>{{ row.Name }}</span>
      </mat-option>
  </mat-autocomplete>   
</form>

Setup:

import {FieldSearchServiceItem}     from './../services/field-search.service';

constructor(
    private dialog: MatDialog,
    private formBuilder: FormBuilder,
    private http: HttpClient,
    private appServiceItem: FieldSearchServiceItem,    
    )   {}

ngOnInit()

ngOnInit(){
    this.myForm = this.formBuilder.group
        ({
        itemName: '',
        });

this.myForm
  .get('itemName')
  .valueChanges
  .pipe(
    debounceTime(200),
    switchMap(value => this.appServiceItem.search({name: value}, 1))
    )
  .subscribe(serviceResult => this.searchQueryResult = serviceResult.qResult);
}

The service:

@Injectable()
export class FieldSearchServiceItem 
  {
  constructor(private http: HttpClient) {}
  search(filter: {name: string} = {name: ''}, page = 1): Observable<apiQueryResponseForItem> 
    {
    var queryResponse;
    return this.http.get<apiQueryResponseForItem>(`example.com/search/item/${filter.name}`)
    .pipe(
      tap((response: apiQueryResponseForItem) => 
        {
        response.qResult = response.qResult
          .map(unit => new ItemUnit(unit.Id, unit.Name))
        return response;
        })
      );
    }
  }

The class defs:

export class ItemUnit
    {
    constructor
        (
        public Id:number,
        public Name:string,
        )   {}
    }

export interface apiQueryResponseForItem
    {
    qSuccess: boolean;
    qResult: ItemUnit[];        
    }

I've seen other answers where the solution has been to use the emitEvent:false when setting the value, like so:

this.myForm.get('itemName').patchValue('theDataThatCameBackFromSeach', {emitEvent:false})

That makes sense... but I get the feeling that solution doesn't match up with this observable/injectable/Material approach... mainly because I'm not using a call that does a .setValue() or .patchValue(), I'm guessing there's a binding somewhere in the Material stuff that's handling that.

The server ending up seeing calls like this:

http://example.com/search/item/a                   (when the letter a is typed)
http://example.com/search/item/[object%20Object]   (after clicking the dropdown, JS tries to search 'object' after clumsily falling back to string representation )

My onItemSelection() is currently uninvolved, it's working but does nothing other than dump to the console. The .qResult that's coming back from my searchQueryResult service contains {Id:number,Name:string}. How can I have the field-setting action of the autocomplete still do its work of setting the field, but not creating a change event when it does that, while still honoring the onItemSelection() so I can get my other processing done?

like image 538
Anders8 Avatar asked May 26 '20 13:05

Anders8


1 Answers

I've faced the same issue some time ago, and this is the final solution that I've been using for several inputs in my system.

The steps are:

  1. Realize that there are actually two events here. Something Typed and Value Selected.
  2. Create two observables for the the two events and handle them separately.
class Component {
  ngOnInit() {

    /*       SEPARATE THE EVENTS      */

    // If it's an object, it's been selected.
    // I also don't allow selecting `null` but it's up to you.
    const itemSelected$ = this.myForm.get('itemName').valueChanges.pipe(
      filter(val => typeof val === 'object' && val !== null),
    );
    // If it's a string, it's been typed into the input
    const itemTyped$ = this.myForm.get('itemName').valueChanges.pipe(
      filter(val => typeof val === 'string'),
    );

    /*       HANDLE ITEM SELECTED     */

    itemSelected$.subscribe(item => {
      // If you want, you can also handle "fake" items here.
      // I use this trick to show a placeholder like "Create New Item" in the dropdown
      if (item.id === 0 && item.name === 'Create New Item') {
        this.createNewItem().subscribe(
          newItem => this.myForm.get('itemName').setValue(newItem),
        );
        return;
      }
      // I use this in a custom input component (with ControlValueAccessor)
      // So this is where I notify the parent
      this.onChange(item);
    });

    /*       HANDLE ITEM TYPED        */

    const searchQueryResult$ = itemTyped$.pipe(
      debounce(200),
      tap(value => {/* you could handle starting a loading spinner or smth here */}),
      switchMap(name => this.appServiceItem.search({name}, 1)),
    );

    // now you can either use searchQueryResult$ with async pipe:
    // this.filteredResults$ = searchQueryResult$;
    // or subscribe to it and update a field in your component class:
    searchQueryResult$.subscribe(result => this.searchQueryResult = result);
    // If you subscribe, don't forget to clean up subscriptions onDestroy
  }
}

I've taken the liberty to add a few suggestions and tricks, but you get the general idea - create two separate mutually exclusive observables and handle them separately.

like image 92
hlfrmn Avatar answered Oct 31 '22 21:10

hlfrmn