Angular formArray と仲良くなる

はじめに

Angular のチュートリアルを終えて、簡単なアプリケーションの Showcase のようなものを Firebase 上に運用しています。
今回は Angular formArray と仲良くなるべく3つほどTipsを書きました。

fir-angular-showcase.web.app

Angular form validations

Angular formArray サンプル

サンプルアプリケーションのソースコードGithub で公開しています。
何点かつまづいた点を中心に解説していきます。

github.com

Angular formArray 内の autocomplete を filter したい

formControl 内の Filter autocomplete については公式に stackblitz 含め example があります。

Angular Material

しかし、動的に追加される formArray に対して filter を当てるのは少し工夫が必要でした。
mat-option*ngFor="let product of filteredOptions[index] | async" のように filteredOptions が formArray の何番目であるかを認識させています。

<div *ngFor="let c of products.controls; let index=index; let last=last;" [formGroupName]="index">

  <mat-form-field>
    <input #stateInput matInput type="text" placeholder="Product" aria-label="Number" formControlName="product" [matAutocomplete]="auto">
    <button mat-button *ngIf="stateInput.value" matSuffix mat-icon-button aria-label="Clear" (click)="delSelectedProduct(index)">
      <mat-icon>close</mat-icon>
    </button>
          
    <mat-autocomplete #auto="matAutocomplete" [displayWith]="displayFn">
      <mat-option (onSelectionChange)="stateInput.value != undefined" *ngFor="let product of filteredOptions[index] | async" [value]="product">
        {{product.productName}}
      </mat-option>
    </mat-autocomplete>
  </mat-form-field>

</div>

filteredOptionsObservable<Product[]>[] = []; のように定義しています。
managedFilter というメソッドで formArray の index を受け取れるようにしておき入力された文字列を Product.productName に対して filter を試みます。
_filter というメソッドで実際に filter を実施しますが、Angular material の autocomplete で扱いやすいように Product[] のような Interface の配列として return するようにします。
template との紐付けは 要素が足される addInput() メソッドの中で this.managedFilter(this.products.length - 1); のようにしているのと、初期化時に this.managedFilter(0); としています。ここはなんかあまりかっこよくないですね。どうすれば良いのか。。

export interface Product {
  productName: string;
  productCode: string;
  price: number;
  maxQuantity: number;
}

~~ 省略 ~~

filteredOptions: Observable<Product[]>[] = [];

ngOnInit() {
    ~~ 省略 ~~
    this.managedFilter(0);
}

managedFilter(index: number) {
    const arrayControl = this.productFormGroup.get('products') as FormArray;
    this.filteredOptions[index] = arrayControl.at(index).get('product').valueChanges
      .pipe(
      startWith<string | Product>(''),
      map(value => typeof value === 'string' ? value : value.productName),
      map(productName => productName ? this._filter(productName) : this.sampleProducts.slice())
      );
}

private _filter(value: string): Product[] {
    const filterValue = value.toLowerCase();
    return this.sampleProducts.filter(option => option.productName.toLowerCase().includes(filterValue));
}

addInput() {
    this.products.push(this.formBuilder.group({
      product: this.formBuilder.control('', []),
      productNumber: this.formBuilder.control(1, [Validators.min(1), CustomValidator.integer]),
    }, { validators: CustomValidator.maxQuantity }));

    this.managedFilter(this.products.length - 1);
}

以上でAngular formArray 内の autocomplete を filter することができます がなんとか動いている状態を作り出せます。

Angular formArray 内の重複した key をまとめたい

formArray で要素を追加できる UI において、重複された key が選択される場合があります。
例えばこのサンプルでいうと以下のように Apple が2行に渡って選択されている状態です。

Angular form array duplicate keys

パッと思いつく制御として以下2つがありました。

  • 重複はエラーとする
  • 重複した key を集計して個数カウントおよびエラー実装を行う

今回は後者の集計を実装してみました。
まず key である product に入力があるものだけを arr という投げやりな変数に詰めています。

空のマップ const m = new Map(); に対して product を key に productNumber を集計し forEach で product と productNumber を詰め直しています。

export interface Products {
  product: Product;
  productNumber: number;
}
export interface Order {
  products: Products[];
}

~~ 省略 ~~

calculate(order?: Order) {
  const arr = [];
  for (const p of order.products) {
    if (p.product) {
      arr.push(p);
    }
  }

  const m = new Map();
  this.productsList = [];

  arr.reduce((aggr, current) => {
    aggr.set(current.product, aggr.has(current.product) ? aggr.get(current.product) + current.productNumber : current.productNumber);
    return aggr;
  }, m).forEach(function(value, key) {
    this.push({product: key, productNumber: value});
  }, this.productsList);

}

map / reduce 便利ですね。
これでAngular formArray 内の重複した key をまとめることができました。

Angular formArray にデータが追加される度に Angular material の dataTable にデータを足したい

formArray として追加され、重複した key を集計した後に dataTable に pushします。
Angular material datatable はどことなく扱いづらい印象がありますが、かなり簡単でした。

MatTableDataSource を import し FormGroup.valueChanges の度に productsList を更新するだけです。

import { MatTableDataSource } from '@angular/material';

  data = new MatTableDataSource(this.productsList);

  calculate(order?: Order) {
    this.data = new MatTableDataSource(this.productsList);
  }

また、material datatable は template 内で簡単な演算ができてしまうので、小計の計算をやってみました。
もちろん ts 側で calcSubTotal() のようなメソッドを作成して template から呼び出すこともできそうです。

<ng-container matColumnDef="Subtotal">
  <th mat-header-cell *matHeaderCellDef> Subtotal </th>
  <td mat-cell *matCellDef="let data">{{ data.product.price * data.productNumber || 0 | number}}</td>
  <td mat-footer-cell *matFooterCellDef>{{getTotal() || 0 | number}}</td>
</ng-container>

まとめ

Angular formArray と仲良くなるべく、サンプルアプリケーションを作成してみました。
ソースコードを公開しています。githubはこちら。

github.com