Angular入門 未経験から1ヶ月でサービス作れるようにする その6.5 URLパラメータの利用とここまで学んだことの実践

これまでの記事で、インストールからルーティング、リンクやクリックイベントなどAngularの基礎的な部分を学習してきました。

本記事では、これまで学習した内容を使ってプロジェクトを少し修正していきます。(追加で学習する部分もあります）

- リスト表示から、詳細画面への遷移を追加
- 詳細画面、編集画面で、URLのパラメータからどの商品かを判断するようにする
- 詳細画面から編集画面への遷移、編集画面から詳細画面への遷移を追加

をやっていきます。

## この記事のソースコード

[https://github.com/seteen/AngularGuides/tree/入門その06-2](https://github.com/seteen/AngularGuides/tree/%E5%85%A5%E9%96%80%E3%81%9D%E3%81%AE06-2)

## リスト表示から、詳細、編集画面への遷移を追加

リストにマウスをホバーすると、詳細画面、編集画面へのリンクが出てくるようにしたいと思います。

### マウスのホバーの取得

マウスのホバー状態の取得を、 `mouseenter` (マウスがホバーした) , `mouseleave` (マウスのホバーが外れた) の２つのイベントを取得することで実現してみます。

まず、この２つのイベントが発生したときに実行されるメソッドをコンポーネントに追加していきます。

``` src/app/product/product-list/product-list.component.ts
import { Component, OnInit } from '@angular/core';
import { Product } from '../../shared/models/product';
import { ProductService } from '../../shared/services/product.service';

@Component({
  selector: 'app-product-list',
  templateUrl: './product-list.component.html',
  styleUrls: ['./product-list.component.scss']
})
export class ProductListComponent implements OnInit {
  products: Product[] = null;

  constructor(
    private productService: ProductService,
  ) {}

  ngOnInit() {
    this.productService.list().subscribe((products: Product[]) => {
      this.products = products;
    });
  }

  hovered(product: Product): void { } // <= 追加
  unhovered(product: Product): void { } // <= 追加
}
```

HTML上で 各商品のリスト上で `mouseenter` , `mouseleave` のイベントを取得して、追加したメソッドを呼ぶようにしてみましょう。

``` src/app/product/product-list/product-list.component.html
<div class="container">
  <div class="title">商品一覧</div>
  <div class="product-list-container">
    <div class="waiting-for-products" *ngIf="products === null; else productList">
      商品を取得しています...
    </div>
    <ng-template #productList>
      <div class="product-list-table">
        <div class="product-line header">
          <div class="product-id">ID</div>
          <div class="product-name">名前</div>
          <div class="product-price">価格</div>
        </div>
        <div class="product-line" *ngFor="let product of products" (mouseenter)="hovered(product)" (mouseleave)="unhovered(product)">
          <div class="product-id">{{ product.id }} </div>
          <div class="product-name">{{ product.name }}</div>
          <div class="product-price">{{ product.price }}</div>
        </div>
      </div>
    </ng-template>
  </div>
</div>
```

これにより、各リストにマウスがホバーしたとき、ホバーしなくなったときに反応するイベントを取得できるようになりました。

### ホバー状態の保存

次に、ホバー状態をどう保存するかについて考えます。

マウスカーソルは一つしかないので、ホバーは一つしかできません。
そのため、 `hoveredProduct` のような変数を用意して、その変数にホバーされている要素を入れておく、という方法もできます。

このようなリストの要素は、ホバー以外にもいろいろと仕様の都合上、データとしてモデルには持っていないけど、画面上では追加してデータを持っていたい、ようなことがよくあります。

そのため、今回は少し難しくはなりますが、別の方法でホバー状態を保存してみます。

それは、この画面のためのProductを拡張したクラスを用意するというものです。

``` .ts
class ProductListElement extends Product {
  hovered: boolean;
}
```

上記のクラスは、 `Product` クラスを継承しているため、 `Product` の要素を持っていて、
かつ `hovered` というホバーされているかどうかの状態をもたせるようにしています。

> TIPS: Typescriptにおける継承
> Typescriptの継承は、Java などの一般的なオブジェクト指向言語と同様の継承です(今どきはJavaを知らない人の方が多いのかもしれない...)。
> 簡単に説明すると、インスタンス変数、インスタンスメソッドを引き継ぎつつ、新しい変数やメソッドを追加したいときに使う実装手段です。
>
> 詳しくはTypescript公式ページを参照ください
> https://www.typescriptlang.org/docs/handbook/classes.html


新しく作ったクラスを利用することで、ホバー状態を保存できます。
実際に使ってみます。

``` src/app/product/product-list/product-list.component.ts
import { Component, OnInit } from '@angular/core';
import { Product } from '../../shared/models/product';
import { ProductService } from '../../shared/services/product.service';

class ProductListElement extends Product { // <= 追加
  hovered: boolean;
}

@Component({
  selector: 'app-product-list',
  templateUrl: './product-list.component.html',
  styleUrls: ['./product-list.component.scss']
})
export class ProductListComponent implements OnInit {
  products: ProductListElement[] = null;

  constructor(
    private productService: ProductService,
  ) {}

  ngOnInit() {
    this.productService.list().subscribe((products: Product[]) => {
      this.products = products.map((product: Product) => {
        return { // <= 変更
          ... product,
          hovered: false,
        };
      });
    });
  }

  hovered(product: ProductListElement): void { product.hovered = true; } // <= 変更
  unhovered(product: ProductListElement): void { product.hovered = false; } // <= 変更
}
```

#### 解説

##### ProductListElementの定義

``` .ts
class ProductListElement extends Product {
  hovered: boolean;
}
```

今回は、クラス定義の場所をコンポーネントの上部にしました。

他のクラスから利用されるようになったら別ファイルにして `export` しますが、それほど長いコードではないので今回はコンポーネントに直接記述しています。

##### hovered, unhovered メソッド

各メソッドの引数の型を `ProductListElement` に変更しました。
また、各メソッド中で、 `hovered` 変数の値を変更しています。これにより、 ホバーされているかどうかを 各 `ProductListElement` が持つことになります。

##### ngOnInitの変更点

まず、 `map` メソッドを使っています。 `map` メソッドは一般的なメソッドなので説明は不要かと思いますが、後ほどTIPSで少し解説します。

`products.map` で呼び出しているメソッドを見てみると、少し特殊な書き方をしています。

``` .ts
this.products = products.map((product: Product) => {
  return {
    ... product,
    hovered: false,
  };
});
```

この

``` .ts
{
  ... product,
  hovered: false,
}
```

という書き方ですが、 Typescript の特殊な書き方で、 `...` は `Spread Operator` と呼ばれています（公式に名前がないので正確な名前はわかりません）。

ここでは、 `product` に対して `hovered: false` を追加しているという書き方になっています。

Spread Operator について、詳しくは公式ページを参照してください。
https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-1.html

**注意**

実はこの書き方、罠があります。

`products` は、 `Product` クラスのインスタンスでした。
しかし、 `Spread Operator` によって拡張された オブジェクトは、 `ProductListElement` クラスのインスタンスにはなっていません。
ただのオブジェクトになっています。

今回のケースでは気にしないで書いていきますが、使い所にはご注意ください。

> TIPS: map メソッド
> map メソッドは私にとってJavascriptで一番好きなメソッドです。
> もし使い方を知らない、という人はぜひこの機会に覚えましょう。
> for 文を使ったときとの比較をしてみましょう。
>
```.ts
result = []
for(let i=0; i<products.length; i++) {
  result.push({
    ...products[i],
    hovered: false,
  });
}
```
> 上記のコードは、mapを使った下記のコードと同じです。
>
```.ts
result = products.map((product: Product) => {
  return {
    ... product,
    hovered: false,
  };
});
```
> mapを使った方がきれいですよね。文字数も減りますし、謎の初期値を定義する行も必要ないです。
> 最初見たときは難しく感じるかもしれませんが、 map メソッドは、配列の各要素に対して何かしらの処理をした結果の配列を返すメソッドです。
> そのあたりを意識しながら何度か読んでいればわかるようになると思います。

### 保存したホバー状態を使って、ホバーされているときは詳細画面へ遷移するボタンが出るようにする

ホバー状態の保存ができたところで、ホバーされている要素はボタンが表示されるようにしてみましょう。
htmlを変更していきます。

``` src/app/product/product-list/product-list.component.html
<div class="container">
  <div class="title">商品一覧</div>
  <div class="product-list-container">
    <div class="waiting-for-products" *ngIf="products === null; else productList">
      商品を取得しています...
    </div>
    <ng-template #productList>
      <div class="product-list-table">
        <div class="product-line header">
          <div class="product-id">ID</div>
          <div class="product-name">名前</div>
          <div class="product-price">価格</div>
        </div>
        <div class="product-line" *ngFor="let product of products" (mouseenter)="hovered(product)" (mouseleave)="unhovered(product)">
          <div class="product-id">{{ product.id }} </div>
          <div class="product-name">{{ product.name }}</div>
          <div class="product-price">
            <span *ngIf="!product.hovered; else Unhovered">{{ product.price }}</span>
            <ng-template #Unhovered><span class="button white" [routerLink]="[product.id]">詳細</span></ng-template>
          </div>
        </div>
      </div>
    </ng-template>
  </div>
</div>
```

`*ngIf` を利用してホバーすると `詳細` と書かれたボタンが表示されるようにしました。

`*ngIf` は入門その4で 紹介したものと同じ使い方ですね。
https://qiita.com/seteen/items/445a550e27d9ff76eaec#product-listcomponenthtml


また、ボタンには `routerLink` を使いました。
これは 入門その3で紹介した要素ですね。
https://qiita.com/seteen/items/732bacf604ab8d18a84c#routerlink-%E3%81%AE%E4%BD%BF%E3%81%84%E3%81%A9%E3%81%93%E3%82%8D

今回の使い方は、少しだけ違います。 その3で紹介したときは、常に `/` から始まる絶対パスで遷移させていましたが、今回は 相対パスを指定してみました。

`[routerLink]="[product.id]"` と記載することで、商品一覧ページが `/products` なので、そこからの相対パスが `product.id` になるパスに遷移するということになります。
つまり、 `product.id` が `1` なら、 `/products/1` に遷移します。

#### スタイルを整える

最後に、見た目を整えるために `scss` を修正します。

``` src/app/product/product-list/product-list.component.scss
.container {
  margin: auto;
  padding: 32px 0;
  width: 800px;
  .title {
    padding: 8px 0;
    text-align: center;
    width: 100%;
    font-weight: 600;
    font-size: 18px;
  }

  .product-list-container {
    .product-list-table {
      border: 1px solid #D9DBDE;
      border-radius: 4px;
      overflow: hidden;

      .product-line {
        display: flex;
        align-items: center;
        padding: 16px 24px;
        height: 34px;

        &.header {
          background-color: #F5F5F5;
        }

        &:nth-child(n+2) {
          border-top: 1px solid #D9DBDE;
        }
        &:hover {
          background-color: #F5F5F5;
        }

        .product-id {
          width: 40px;
        }
        .product-name {
          width: 630px;
        }
        .button {
          width: 80px;
        }
      }
    }
  }
}
```

ここで、動作確認をしてみましょう。
`ng serve` で起動して、 `http://localhost:4200/products` にアクセスします。

![001.gif](https://qiita-image-store.s3.amazonaws.com/0/67424/2f08db09-89a8-5ba6-f490-e4fed110bc52.gif)


#### ここまでのコミット

https://github.com/seteen/AngularGuides/commit/8c2d9f6bbfc3b9f59b420ab7fadfb77d201d5aed

## 詳細画面、編集画面で、URLのパラメータからどの商品かを判断するようにする

現状の詳細画面、編集画面は固定の `product` の情報を表示しています。
URLからIDを取得してその `product` の情報を表示するようにしてみましょう。

Angularでは、 URLの情報を取得するのに `ActivatedRoute` を利用します。

さっそくコードを修正していきます。

``` src/app/product/product-detail/product-detail.component.ts
import { Component, OnInit } from '@angular/core';
import { Product } from '../../shared/models/product';
import { ProductService } from '../../shared/services/product.service';
import { ActivatedRoute, Params } from '@angular/router';

@Component({
  selector: 'app-product-detail',
  templateUrl: './product-detail.component.html',
  styleUrls: ['./product-detail.component.scss']
})
export class ProductDetailComponent implements OnInit {
  product: Product;

  constructor(
    private route: ActivatedRoute, // <= 追加
    private productService: ProductService,
  ) {}

  ngOnInit() {
    this.route.params.subscribe((params: Params) => { // <= 変更
      this.productService.get(params['id']).subscribe((product: Product) => {
        this.product = product;
      });
    });
  }
}
```

#### 解説
##### ActivatedRouteの使い方

ActivatedRoute から取得できるURLの要素の代表的なものは、 `params` と `queryParams` です。

| 変数 | 型 | 役割 |
|:--|:--|:--|
| params | Observable<Params> | Routeで設定しているパラメータを取得する |
| queryParams | Observable<Params> | Routeで設定していないURLクエリを取得する |


`params` はRouteで設定したパラメータを取得するのに利用します。
Routeで設定したパラメータって何？と思ったかもしれませんが、

実は 入門その3 で作成したルーティングのファイルで、すでにRouteにパラメータを設定していました。
https://qiita.com/seteen/items/732bacf604ab8d18a84c#%E3%83%AB%E3%83%BC%E3%83%86%E3%82%A3%E3%83%B3%E3%82%B0%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%81%AE%E4%BD%9C%E6%88%90

具体的には

``` .ts
{ path: 'products/:id', component: ProductDetailComponent },
```

の記述で指定している `:id` がこのパラメータにあたります。
URLからこの `:id` を取得するのに利用するときに利用するのが `params` です。

では、 `queryParams` はどういうものかというと、例えば `http://localhost:4200/products/1?test=1&hoge=12` のようなURLのクエリパラメータである `test=1` , `hoge=12` を取得するときに利用します。

##### 今回のソースコードでの利用部分の解説

``` .ts
this.route.params.subscribe((params: Params) => {
  this.productService.get(params['id']).subscribe((product: Product) => {
    this.product = product;
  });
});
```

Routeで設定されている `:id` を取得するには、 `params` を `subscribe` して、取れる値から `params['id']` のように取得します。

このコードでは、その後 `productService` からこの `params['id']` を利用して `product` を取得しています。

> TIPS: なぜ Observable を返すのか
> `this.route.params` は `Observable<Params>` を返します。これは、直訳すると観測可能なParamsを返すということになります。
> `Observable` (観測可能) というのはどういう意味かというと、例えば `URL` のパラメータが別の場所で変更されたら、その値が取得できる、ということになります。
> 例えばここで `this.route.params` が `Observable` を返さない場合、 `URL` の値が別の場所で変更されたとしても、本来表示したいもの（URLで指定されている `product` ）が表示されません。
>
> そのため、 `this.route.params` は （変更が）観測可能な Params を返すことで、変更を検知してたとえ `URL` が変更されても表示したいものを維持できます。( `this.productService.get(params['id'])` が再度実行されるため )
>
> 一度覚えると便利です。


### 編集画面の変更

同様に、編集画面も変更します。全く同じ変更内容です。

``` src/app/product/product-edit/product-edit.component.ts
import { Component, OnInit } from '@angular/core';
import { Product } from '../../shared/models/product';
import { ProductService } from '../../shared/services/product.service';
import { ActivatedRoute, Params, Router } from '@angular/router';

@Component({
  selector: 'app-product-edit',
  templateUrl: './product-edit.component.html',
  styleUrls: ['./product-edit.component.scss']
})
export class ProductEditComponent implements OnInit {
  product: Product;

  constructor(
    private route: ActivatedRoute, // <= 追加
    private router: Router,
    private productService: ProductService,
  ) {}

  ngOnInit() {
    this.route.params.subscribe((params: Params) => { // <= 変更
      this.productService.get(params['id']).subscribe((product: Product) => {
        this.product = product;
      });
    });
  }

  saveProduct(): void {
    console.log(this.product);
    this.router.navigate(['/products']);
  }
}
```

ここで、動作確認をしてみましょう。

![002.gif](https://qiita-image-store.s3.amazonaws.com/0/67424/99d36394-d817-cc26-663f-ac444420bd9f.gif)


URLと商品の内容が連動していますね。

#### ここまでのコミット
https://github.com/seteen/AngularGuides/commit/87ffea092b227c0e73efa2d834f4d4527421ea63

## 詳細画面から編集画面への遷移、編集画面から詳細画面への遷移を追加

ここからは、ここまで学んだ技術を利用してスタイルの変更などを行っていきます。

`product-detail.component` の変更↓

``` src/app/product/product-detail/product-detail.component.html
<div class="container">
  <div class="title">商品詳細</div>
  <div class="product-detail-container">
    <div class="param-line">
      <div class="label">ID</div>
      <div class="value">{{ product.id }}</div>
    </div>
    <div class="param-line">
      <div class="label">名前</div>
      <div class="value">{{ product.name }}</div>
    </div>
    <div class="param-line">
      <div class="label">価格</div>
      <div class="value">{{ product.price }}</div>
    </div>
    <div class="param-line">
      <div class="label">説明</div>
      <div class="value">{{ product.description }}</div>
    </div>
  </div>
  <div class="footer">
    <div class="button white" routerLink="/products">商品一覧</div>
    <div class="button black" routerLink="edit">編集</div>
  </div>
</div>
```

``` src/app/product/product-detail/product-detail.component.scss
.container {
  margin: auto;
  padding: 32px 0;
  width: 800px;

  .title {
    padding: 8px 0;
    text-align: center;
    width: 100%;
    font-weight: 600;
    font-size: 18px;
  }

  .product-detail-container {
    padding-left: 48px;
    border: 1px solid #D9DBDE;
    border-radius: 4px;
    background-color: #FFFFFF;

    .param-line {
      display: flex;
      padding: 16px 8px;

      &:nth-child(n+2) {
        border-top: 1px solid #D9DBDE;
      }

      .label {
        width: 100px;
      }
    }
  }

  .footer {
    display: flex;
    justify-content: center;
    margin-top: 20px;

    .button {
      margin: 0 20px;
    }
  }
}
```

`product-edit.component` の変更↓

``` src/app/product/product-edit/product-edit.component.html
<div class="container">
  <div class="title">商品編集</div>
  <form (ngSubmit)="saveProduct()">
    <div class="edit-form">
      <div class="edit-line">
        <label>ID</label>
        <span>{{ product.id }}</span>
      </div>
      <div class="edit-line">
        <label>名前</label>
        <input type="text" name="name" [(ngModel)]="product.name">
      </div>
      <div class="edit-line">
        <label>価格</label>
        <input type="number" name="price" [(ngModel)]="product.price">
      </div>
      <div class="edit-line">
        <label>説明</label>
        <input type="text" name="description" [(ngModel)]="product.description">
      </div>
    </div>
    <div class="footer">
      <span class="button white" [routerLink]="['/products', product.id]">キャンセル</span>
      <button class="button black">保存</button>
    </div>
  </form>
</div>
```

``` src/app/product/product-edit/product-edit.component.scss
.container {
  margin: auto;
  padding: 32px 0;
  width: 800px;
  .title {
    padding: 8px 0;
    text-align: center;
    width: 100%;
    font-weight: 600;
    font-size: 18px;
  }

  .edit-form {
    padding: 16px 48px;
    border: 1px solid #D9DBDE;
    border-radius: 4px;
    background-color: #FFFFFF;

    .edit-line {
      display: flex;
      align-items: center;
      padding: 16px 0;
      width: 100%;

      label {
        width: 15%;
        font-size: 16px;
        font-weight: 600;
      }

      input {
        border: 1px solid #BDBDBD;
        border-radius: 4px;
        padding: 0 8px;
        width: 560px;
        height: 40px;
        font-size: 14px;
      }
    }
  }

  .footer {
    display: flex;
    justify-content: center;
    padding: 24px 0;

    .button {
      margin: 0 20px;
    }
  }
}
```

#### 解説

`product-detail.component` については、 商品一覧へのリンクを、
`product-edit.component` については、キャンセルボタンを追加しました。


動作確認をしてみましょう。

![003.gif](https://qiita-image-store.s3.amazonaws.com/0/67424/f33dd552-43d4-cddb-d8c5-1afc4781b01c.gif)

ちゃんと画面の行き来ができて、少しWebサービスっぽくなってきましたね。

> TIPS: 編集画面の挙動の注意点
> 編集画面のキャンセルボタンですが、現状 input 要素を変更したらキャンセルボタンを押しても変更されたままになっています。
> 入門その6で少し解説しましたが、双方向バインディングになっているため、メモリ上で勝手に変更されているので、キャンセル処理には、本来はもとに戻す処理を入れる必要があります。
> https://qiita.com/seteen/items/4bc7dbd9b52ca20de63e#saveproduct-%E3%81%AE-%E5%86%85%E5%AE%B9
> 本入門では、この部分は最終的にAPI通信になっていくので、その処理は行っていません。

#### ここまでのコミット
https://github.com/seteen/AngularGuides/commit/680d7e1452a4575dfb304b126f5491df3196c08d

## まとめ

今回は、これまでに学んだ内容を元にページを改善していきました。また、新しく `ActivatedRoute` なども学びました。
そろそろAngularの力がついてきたと実感できてきたのではないでしょうか。

次回は、 `[(ngModel)]` を利用しないフォームの別の作り方を解説していきます。

[Angular入門 未経験から1ヶ月でサービス作れるようにする その7. Angularのフォーム2 (Reactive forms)](https://qiita.com/seteen/items/6cf6772aa2b0bfe858b2)

## 入門記事一覧
「Angular入門 未経験から1ヶ月でサービス作れるようにする」は、記事数が多いため、[まとめ記事](https://qiita.com/seteen/items/43908e33e08a39612a07) を作っています。
https://qiita.com/seteen/items/43908e33e08a39612a07