4
votes

I need to create a search input that filters a list of sub-accounts within each parent account. Currently, typing in either input filters all accounts instead of only the associated account.

Live Examples (StackBlitz)
Basic (no FormArray)
With FormArray

Requirements

  1. The number of accounts and sub-accounts is unknown (1...*)
  2. Each account requires it's own search input (FormControl) in the HTML
  3. Typing in input A should filter the list for account A only.
    Typing in input B should filter the list for account B only.

Questions

  1. How can I ensure that each FormControl only filters the account in the current *ngFor context?

  2. How can I independently watch an unknown number of FormControls for value changes? I realize I can watch the FormArray, but I'm hoping there's a better way.

Ideally, the solution should:

  1. Use Reactive Forms
  2. Emit an Observable when the value changes
  3. Allow FormControls to be added/removed from the form dynamically
4
Shrink your question to the paragraph.Roman C
Unfortunately, you are unlikely to get any sold answers because you haven't included your code in the question body. Links to 3rd party sites where your code might be running are not a replacement for this.Claies
that being said, looking at your examples, you only have a single searchTerm property but you have multiple arrays you are trying to filter. You will need to find a way to make each search box unique (probably using ind from the parent repeater), and have a unique searchTerm per sub-repeater.Claies
Thanks for the tip! I'll give it a try on Monday. I decided Stackbliz would be better since it was a lot of code. I was down-voted for the question being too long, but I'll keep that in mind!Stevethemacguy
Claies led me in the right direction. I'm using the following (for now): stackblitz.com/edit/angular-form-array-basic-solutionStevethemacguy

4 Answers

1
votes

In the previous answer, the filter function will be called on every change detection (basically any event in the browser unrelated to this component too), which might be bad. A more Angular and performant way is to utilize the power of Observable:

<input #search/>
<div *ngIf="items$ | async as items">
   <div *ngFor="let item of items">{{item.name}}</div>
</div>

items:any[];
items$:Observable<any>;
@ViewChild('search') search:Elementref;
ngOnInit() {
   const searchProp = 'name';
   this.items$ = Observable.fromEvent(this.search.nativeElement, 'keyup').pipe(
      startWith(''),
      debounceTime(500), // let user type for half a sec
      distinctUntilChanged(), // don't run unless changed
      map(evt => this.items.filter(
         item => !evt.target.value || 
         item[searchProp].toLowerCase().indexOf(evt.target.value) >-1 
         )
      )
}

Written on tablet from memory, if it doesn't work by copy-pasting, use ide hints to look for errors. :)

This way you could even pipe the API call into the same observable, removing the need to subscribe & unsubscribe yourself, as async pipe handles all that.

Edit: to adapt this to work with 2 inputs, you can merge .fromEvent() from both, and in the filter call, change behavior according to evt.target.id. Sorry for incomplete example, writing code on tablet is horrible :D

RXJS merge: https://www.learnrxjs.io/operators/combination/merge.html

1
votes

This is my approach to solve your problem.

First you iterate through your outer accounts and create for every account a dedicated formControl and store them in a FormGroup. As an ID reference I used the account number. With this in place I declared a function called getSearchCtrl(accountNumber) to retrive the right formControl.

Use [formControlName]="account.accountNumber" to connect your template with your formControls provided in your FormGroup.

Reference the correct formControl with getSearchCtrl(account.accountNumber) to your filter pipe and pass the value.

<div *ngFor="let subAccount of account.subAccounts | filter : 'accountNumber': getSearchCtrl(account.accountNumber).value; let i=index"> <span>{{subAccount.accountNumber}}</span> </div>

I also edited your stackblitz app: https://stackblitz.com/edit/angular-reactive-filter-4trpka?file=app%2Fapp.component.html

I hope this solution will help you.

import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, FormControl } from '@angular/forms';
@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
  searchForm: FormGroup;
  searchTerm = '';
  loaded = false;

  // Test Data. The real HTTP request returns one or more accounts.
  // Each account has one or more sub-accounts.
  accounts = [/* test data see stackblitz*/]

  public getSearchCtrl(accountNumber) {
    return this.searchForm.get(accountNumber)
  }

  constructor() {
    const group: any = {}
    this.accounts.forEach(a => {
      group[a.accountNumber] = new FormControl('')
    });
    this.searchForm = new FormGroup(group);
    this.loaded = true;
  }

  ngOnInit() {
    this.searchForm.valueChanges.subscribe(value => {
      console.log(value);
    });
  }
}
<ng-container *ngIf="loaded; else loading">

  <div [formGroup]="searchForm">
	  <div *ngFor="let account of accounts; let ind=index" class="act">
		  <label for="search">Find an account...</label>
		  <input id="search" [formControlName]="account.accountNumber" />
		  <div *ngFor="let subAccount of account.subAccounts | filter : 'accountNumber': getSearchCtrl(account.accountNumber).value; let i=index">
			  <span>{{subAccount.accountNumber}}</span>
		  </div>
	  </div>
  </div>
</ng-container>

<ng-template #loading>LOADING</ng-template>
0
votes

Ref Angular.io - Pipe - Appendix: No FilterPipe or OrderByPipe

Angular doesn't offer such pipes because they perform poorly and prevent aggressive minification.

Based on that advice, I would replace the pipe filter with a method.

As @Claies says, you also need to store the search terms separately.
Since the number of accounts is unknown at compile time, initialize the searchTerm array with Array(this.accounts.length) and handle empty searchTerms with this.searchTerms[accountIndex] || ''.

app.component.ts

export class AppComponent {
  accounts = [
    {
      accountNumber: '12345',
      subAccounts: [ 
        { accountNumber: '123' },
        { accountNumber: '555' },
        { accountNumber: '123555' }
      ]
    },
    {
      accountNumber: '55555',
      subAccounts: [
        { accountNumber: '12555' },
        { accountNumber: '555' }
      ]
    }
  ];

  searchTerms = Array(this.accounts.length)

  filteredSubaccounts(accountNo, field) {
    const accountIndex = this.accounts.findIndex(account => account.accountNumber === accountNo);
    if (accountIndex === -1) {
      // throw error
    }
    const searchTerm = this.searchTerms[accountIndex] || '';    
    return this.accounts[accountIndex].subAccounts.filter(item => 
      item[field].toLowerCase().includes(searchTerm.toLowerCase()));
  }
}

app.component.html

<div>
    <div *ngFor="let account of accounts; let ind=index" class="act">
        <label for="search">Find an account...</label>
        <input id="search" [(ngModel)]="searchTerms[ind]" />
        <div *ngFor="let subAccount of filteredSubaccounts(account.accountNumber, 'accountNumber'); let i=index">
            <span>{{subAccount.accountNumber}}</span>
        </div>
    </div>
</div>

Ref: StackBlitz

0
votes

Another way is to create an account component to encapsulate each account and it's search. There would be no need for a pipe in this case - Example app

import { Component, Input } from '@angular/core';

@Component({
  selector: 'app-account',
  template: `
    <label for="search">Find an account...</label>
    <input id="search" [(ngModel)]="searchTerm" />
    <div *ngFor="let subAccount of subAccounts()">
      <span>{{subAccount.accountNumber}}</span>
    </div>
    <br/>
  `
})
export class AccountComponent {

  @Input() account;
  @Input() field;
  private searchTerm = '';

  subAccounts () {
    return this.account.subAccounts
      .filter(item => item[this.field].toLowerCase()
        .includes(this.searchTerm.toLowerCase())
    ); 
  }
}

The parent would be

import { Component } from '@angular/core';
@Component({
  selector: 'my-app',
  template: `
    <div>
      <div>
        <app-account *ngFor="let account of accounts;"
          class="act" 
          [account]="account" 
          [field]="'accountNumber'">
        </app-account>
      </div>
    </div>
  `,
  styleUrls: ['./app.component.css']
})
export class AppComponent {

  accounts = [
    ...
  ];
}