【Angular】ドロップダウン項目の追加と変更

# TL;DR

Reactive Formsを使うと幸せになれた。

# 実現したいこと

- ドロップダウンにサーバAPI経由でDBから取得した内容を表示する
- 同様に、サーバAPI経由でドロップダウン項目の追加、変更をおこなう
- 追加後は追加された項目を、変更後は変更された項目を選択状態にする

![AngularDropdown.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187787/ea2c2756-ad58-22d0-4e5a-0a6f5c049a8c.gif)


# 環境

### node.js

```bash
root@user:/app/my-app# node --version
v12.7.0
```

### Angular

```json:package.json
"dependencies": {
    "@angular/animations": "~8.0.0",
    "@angular/common": "~8.0.0",
    "@angular/compiler": "~8.0.0",
    "@angular/core": "~8.0.0",
    "@angular/forms": "~8.0.0",
    "@angular/platform-browser": "~8.0.0",
    "@angular/platform-browser-dynamic": "~8.0.0",
    "@angular/router": "~8.0.0"
}
```

# ソースコード

### モジュール

ReactiveFormsModuleをインポートする。

```ts:app.module.ts
import { ReactiveFormsModule } from '@angular/forms';

@NgModule({
  imports: [
    // other imports ...
    ReactiveFormsModule
  ]
export class AppModule {}
```

### コンポーネント

```ts:app.component.ts
import { Component, OnInit } from '@angular/core';
import { Hero, HeroService } from '../hero.service';
import { FormControl } from '@angular/forms';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  heroes: Hero[]; // ドロップダウン表示リスト
  selectedHeroControl = new FormControl();
  
  // オブジェクト配列を扱う場合はコンペア関数を実装する必要がある
  compareHero(hero1: Hero, hero2: Hero): boolean {
    return hero1.id === hero2.id;
  }

  constructor(private heroService: HeroService) {}
  
  async ngOnInit() {
    this.heroes = await this.heroService.getHeroes().toPromise();
    // ドロップダウンの初期値を設定
    this.selectedHeroControl = new FormControl(this.heroes[0]);
  }

  async onClickAddHero(name: string) {
    const addHero = await this.heroService
      .addHero({ name } as Hero)
      .toPromise();
    this.heroes = await this.heroService.getHeroes().toPromise();
    // 追加したHeroを選択状態にする
    this.selectedHeroControl.setValue(addHero);
  }

  async onClickUpdateHero(name: string) {
    const updateHero = new Hero(this.selectedHeroControl.value.id, name);
    const updatedHero = await this.heroService
      .updateHero(updateHero)
      .toPromise();
    this.heroes = await this.heroService.getHeroes().toPromise();
    // 変更したHeroを選択状態にする
    this.selectedHeroControl.setValue(updatedHero);
  }
}
```

### テンプレート

```html:app.component.html
<select [formControl]="selectedHeroControl" [compareWith]="compareHero">
  <option [ngValue]="hero" *ngFor="let hero of heroes">{{ hero.name }}</option>
</select>
<div>
  <label
    >Hero name:
    <input type="text" #heroName />
  </label>
  <button (click)="onClickAddHero(heroName.value)">追加</button>
  <button (click)="onClickUpdateHero(heroName.value)">更新</button>
</div>
```

### サービス

詳細は割愛。

```ts:hero.service.ts
import { Injectable } from '@angular/core';
import { Observable} from 'rxjs';

export class Hero {
  id: number;
  name: string;

  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }
}

@Injectable({
  providedIn: 'root'
})
export class HeroService {
  getHeroes(): Observable<Hero[]> {
    // サーバAPIをCallし、取得した一覧を返す
  }
  
  addHero(hero: Hero): Observable<Hero> {
    // サーバAPIをCallし、追加したオブジェクトを返す
  }

  updateHero(hero: Hero): Observable<Hero> {
    // サーバAPIをCallし、更新したオブジェクトを返す
  }
}
```

# 補足説明

### コンペア関数

Angularはオプションを一意に特定するためにオブジェクトIDを使っている。変更時に、現在表示している項目と、変更後にサーバAPIから再取得した項目追加した項目のオブジェクトIDは異なるため、一致かどうかの比較アルゴリズムを実装する必要がある。

```ts:app.component.ts
export class AppComponent {
  compareHero(hero1: Hero, hero2: Hero): boolean {
    return hero1.id === hero2.id;
  }
}
```

```html:app.component.html
<select [compareWith]="compareHero">
```

### ドロップダウンの変更を検知する方法

FromControl.valueChangesを使う。

```ts:app.component.ts
  async ngOnInit() {
    this.selectedHeroControl.valueChanges.subscribe(hero => {
      console.log(`Selected Hero: ${hero.name}`);
    });
  }
}
```

# 参考

- [Reactive Forms](https://angular.io/guide/reactive-forms)
- [SelectControlValueAccessor](https://angular.io/api/forms/SelectControlValueAccessor)