【Angular】検索一覧画面をコンポーネントを分割して作ってみる

## この記事の概要

コンテナ(スマート)コンポーネントとプレゼンテーショナルコンポーネントの概念に沿ってコンポーネントを分割した検索一覧画面を作成します。

[コンテナ(スマート)コンポーネント、プレゼンテーショナルコンポーネントについて](https://blog.angular-university.io/angular-component-design-how-to-avoid-custom-event-bubbling-and-extraneous-properties-in-the-local-component-tree/)

フォームはテンプレート駆動で作成します。

### イメージ

<img width="487" alt="スクリーンショット 2020-02-22 17.37.58.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/566220/cb51ff51-ef4c-1966-ba03-b9033efe6347.png">

## バージョン

```ts
"@angular-devkit/schematics": "^9.0.1",
"@angular/animations": "~9.0.0",
"@angular/cdk": "^9.0.0",
"@angular/common": "~9.0.0",
"@angular/compiler": "~9.0.0",
"@angular/core": "~9.0.0",
"@angular/forms": "~9.0.0",
"@angular/localize": "^9.0.0",
"@angular/platform-browser": "~9.0.0",
"@angular/platform-browser-dynamic": "~9.0.0",
"@angular/router": "~9.0.0",
"@angular-devkit/build-angular": "~0.900.1",
"@angular/cli": "~9.0.1",
"@angular/compiler-cli": "~9.0.0",
"@angular/language-service": "~9.0.0",
"typescript": "^3.7.5"
```

## フォルダ構成(抜粋)

```
src
┗app
　┣features
　┃ ┗products
　┃ 　┣components
　┃ 　┃ ┗search-products-form
　┃ 　┃　 ┗search-products-form.component.html/scss/spec.ts/ts
　┃ 　┣pages
　┃ 　┃ ┗product-list
　┃ 　┃　 ┣product-list.component.html/scss/spec.ts/ts
　┃ 　┃　 ┗product-list.smart.component.ts
　┃ 　┣product.component.ts
　┃ 　┣products-routing.module.ts
　┃ 　┗products.module.ts
　┣app-routing.module.ts
　┣app.component.html/scss/spec.ts/ts
　┗app.module.ts
```

## 実装

### モデル
```ts:src/app/shared/models/product.ts
/**
 * 商品のモデル
 */
export class Product {
  /** ID */
  id: number;
  /** 品名 */
  name: string;
  /** 入荷日 */
  arrivalAt: Date;
  /** 作成者ID */
  createUserId: number;
  /** 作成日 */
  createdAt: string;
  /** 更新者ID */
  updateUserId: number;
  /** 更新日 */
  updatedAt: string;
  /** 削除フラグ */
  isDeleted: boolean;
}
```

```ts:src/app/shared/models/search-conditions/products-sc.ts
/**
 * 商品検索条件のモデル
 */
export interface ProductsSC {
  /** ID */
  id: number;
  /** 商品名 */
  name: string;
  /** 入荷日 */
  arrivalAt: {
    /** from */
    from: Date;
    /** to */
    to: Date;
  };
}
```

### サービス

```ts:src/app/core/http/products-http.service.ts
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Product } from '@shared/models/product.model';
import { ProductsSC } from '@shared/models/search-conditions/products-sc.model';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

const URL = '/products';

/**
 * 商品API通信サービス.
 */
@Injectable({
  providedIn: 'root'
})
export class ProductsHttpService {

  constructor(private http: HttpClient) { }

  search(conditions: ProductsSC): Observable<Product[]> {
    const API_URL = `${environment.apiBaseUrl}${URL}`;

    return this.http
      .post<Product[]>(API_URL, conditions)
      .pipe(
        map(res => res['payload'])
      );
  }
}
```

### モジュール

```ts:src/app/features/products/products.module.ts
import { NgModule } from '@angular/core';
import { SharedModule } from '@shared/shared.module';
import { SearchProductsFormComponent } from './components/search-products-form/search-products-form.component';
import { PreProductEditComponent } from './pages/pre-product-edit/pre-product-edit.component';
import { ProductListComponent } from './pages/product-list/product-list.component';
import { ProductListSmartComponent } from './pages/product-list/product-list.smart.component';
import { ProductComponent } from './product.component';
import { ProductsRoutingModule } from './products-routing.module';

const COMPONENTS = [
  ProductComponent,
  ProductListComponent,
  ProductListSmartComponent,
  SearchProductsFormComponent
];

@NgModule({
  imports: [
    SharedModule,
    ProductsRoutingModule
  ],
  declarations: COMPONENTS,
  exports: COMPONENTS
})
export class ProductsModule { }
```

### ルーティング

遅延ロードで実装します。
[詳細](https://qiita.com/pg_yamaton/items/83561e302f52bc6e20cf)

```ts:src/app/features/products/products-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { ProductListSmartComponent } from './pages/product-list/product-list.smart.component';
import { ProductComponent } from './product.component';

const routes: Routes = [
  {
    path: '',
    component: ProductComponent,
    children: [
      {
        path: 'list',
        component: ProductListSmartComponent,
      }
    ]
  }
];

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule]
})
export class ProductsRoutingModule { }
```

### コンポーネント

遅延モジュールのルーティング用のコンポーネント

```ts:src/app/features/products/product.conponent.ts
import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'app-product',
  template: `
    <router-outlet></router-outlet>
  `
})
export class ProductComponent implements OnInit {
  constructor() { }
  ngOnInit() { }
}
```

### コンテナコンポーネント

```ts:src/app/features/products/pages/product-list/product-list.smart.conponent.ts
// .smart.conponent.tsの「.smart」は任意です

import { Component, OnInit } from '@angular/core';
import { ProductsHttpService } from '@core/http/products-http.service';
import { Product } from '@shared/models/product.model';
import { ProductsSC } from '@shared/models/search-conditions/products-sc.model';
import { Observable } from 'rxjs';

/**
 * コンテナ(スマート)コンポーネント
 */
@Component({
  selector: 'app-product-list-smart',
  template: `
    <!-- search-products-formコンポーネントから検索ボタン押下の@Outputイベントを受け取ります。引数にフォームの値を取得します。 -->
    <app-search-products-form (search)="searchProducts($event)"></app-search-products-form>
    <!-- product-listコンポーネントへ商品の検索結果のリストを渡します。asyncパイプを利用ます。 -->
    <app-product-list [products]="products$|async"></app-product-list>
  `
})
export class ProductListSmartComponent implements OnInit {
  products$: Observable<Product[]>

  constructor(private productHttpService: ProductsHttpService) { }

  ngOnInit() { }

  /**
   * 検索処理.
   * プレゼンテーショナルコンポーネントからの@Outputイベントで呼ばれます.
   */
  searchProducts(searchFormValue: ProductsSC): void {
    // asyncパイプを使うのでsubscribeは不要です。Observableのまま変数に格納します。
    this.products$ = this.productHttpService.search(searchFormValue);
  }
}
```

### プレゼンテーショナルコンポーネント

```ts:src/app/features/products/pages/product-list/product-list.conponent.ts
import { Component, Input, OnInit } from '@angular/core';
import { Product } from '@shared/models/product.model';

/**
 * 商品一覧のプレゼンテーショナルコンポーネント.
 */
@Component({
  selector: 'app-product-list',
  templateUrl: './product-list.component.html',
  styleUrls: ['./product-list.component.scss']
})
export class ProductListComponent implements OnInit {
  // コンテナコンポーネントから商品リストを受け取ります.
  @Input()
  products: Product[];

  constructor() { }
  ngOnInit() { }
}
```

```html:src/app/features/products/pages/product-list/product-list.conponent.html
<ng-container *ngFor="let data of products">
  <div class="d-flex">
    <div>{{data.id}}｜</div>
    <div>{{data.name}}｜</div>
    <div>
      {{data.arrivalAt | date: "yyyy/MM/dd"}}
    </div>
  </div>
</ng-container>
```

```ts:src/app/features/products/conponents/search-products-form/search-products-form.conponent.ts

import { Component, EventEmitter, OnInit, Output } from '@angular/core';
import { NgForm } from '@angular/forms';
import { ProductsSC } from '@shared/models/search-conditions/products-sc.model';

@Component({
  selector: 'app-search-products-form',
  templateUrl: './search-products-form.component.html',
  styleUrls: ['./search-products-form.component.scss']
})
export class SearchProductsFormComponent implements OnInit {
  @Output()
  private search = new EventEmitter<ProductsSC>();

  constructor() { }

  ngOnInit() { }

  /**
   * 検索ボタン押下時の処理.
   * コンテナコンポーネントへイベントを通知します。
   */
  onSearch(searchForm: NgForm): void {
    // スプレッドでformの値を展開します。
    this.search.emit({...searchForm.value});
  }
}
```

```html:src/app/features/products/conponents/search-products-form/search-products-form.conponent.html

<form
  #searchForm="ngForm"
  (ngSubmit)="onSearch(searchForm)">
  <div>
    <label>ID:</label>
    <input
      type="text"
      name="id"
      ngModel>
  </div>
  <div>
    <label>商品名:</label>
    <input
      type="text"
      name="name"
      ngModel>
  </div>
  <fieldset ngModelGroup="arrivalAt">
    <div>
      <label>入荷日:</label>
      <input
        type="text"
        name="from"
        ngModel>
    </div>
    <div>
      <label>〜</label>
      <input
        type="text"
        name="to"
        ngModel>
    </div>
  </fieldset>
  <button type="submit">検索</button>
</form>
```

## コンテナコンポーネントでformを持つ場合


```ts:src/app/features/products/pages/product-list/product-list.smart.conponent.ts

import { Component, OnInit } from '@angular/core';
import { NgForm } from '@angular/forms';
import { ProductsHttpService } from '@core/http/products-http.service';
import { Product } from '@shared/models/product.model';
import { Observable } from 'rxjs';

/**
 * コンテナ(スマート)コンポーネント
 */
@Component({
  selector: 'app-product-list-smart',
  template: `
    <!-- プレゼンテーショナルコンポーネントではなく自分のコンポーネントで検索ボタンのイベントを定義します。 -->
    <form #searchForm="ngForm" (ngSubmit)="onSearchProducts(searchForm)">
      <app-search-products-form></app-search-products-form>
      <button type="submit">検索</button>
    </form>
    <app-product-list [products]="products$|async"></app-product-list>
  `
})
export class ProductListSmartComponent implements OnInit {
  products$: Observable<Product[]>

  constructor(private productHttpService: ProductsHttpService) { }

  ngOnInit() { }

  /**
   * 検索処理.
   */
  onSearchProducts(searchForm: NgForm): void {
    this.products$ = this.productHttpService.search({...searchForm.value});
  }
}
```





```ts:src/app/features/products/conponents/search-products-form/search-products-form.conponent.ts

import { Component, OnInit } from '@angular/core';
import { ControlContainer, NgForm } from '@angular/forms';

@Component({
  selector: 'app-search-products-form',
  templateUrl: './search-products-form.component.html',
  styleUrls: ['./search-products-form.component.scss'],
  // htmlのfieldsetで定義したngModelGroupディレクティブが、NgFormプロバイダーを見つける為に下記を定義します。
  viewProviders: [{provide: ControlContainer. useExisting: NgForm}]
})
export class SearchProductsFormComponent implements OnInit {
  constructor() { }
  ngOnInit() { }
}
```

```html:src/app/features/products/conponents/search-products-form/search-products-form.conponent.html

<div>
  <label>ID:</label>
  <input
    type="text"
    name="id"
    ngModel>
</div>
<div>
  <label>商品名:</label>
  <input
    type="text"
    name="name"
    ngModel>
</div>
<fieldset ngModelGroup="arrivalAt">
  <div>
    <label>入荷日:</label>
    <input
      type="text"
      name="from"
      ngModel>
  </div>
  <div>
    <label>〜</label>
    <input
      type="text"
      name="to"
      ngModel>
  </div>
</fieldset>
```

参考
[viewProviders](https://angular.jp/guide/hierarchical-dependency-injection#viewproviders-%E9%85%8D%E5%88%97%E3%82%92%E4%BD%BF%E7%94%A8%E3%81%99%E3%82%8B)
[useExisting](https://angular.jp/guide/dependency-injection-in-action#%E3%82%A8%E3%82%A4%E3%83%AA%E3%82%A2%E3%82%B9%E3%83%97%E3%83%AD%E3%83%90%E3%82%A4%E3%83%80%E3%83%BC-useexisting)
https://medium.com/@a.yurich.zuev/angular-nested-template-driven-form-4a3de2042475