23
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2018-08-06

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

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

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

をやっていきます。

この記事のソースコード

https://github.com/seteen/AngularGuides/tree/入門その06-2

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

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

マウスのホバーの取得

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

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

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.id1 なら、 /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

ここまでのコミット

詳細画面、編集画面で、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の要素の代表的なものは、 paramsqueryParams です。

変数 役割
params Observable Routeで設定しているパラメータを取得する
queryParams Observable 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 を取得するには、 paramssubscribe して、取れる値から params['id'] のように取得します。

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

TIPS: なぜ Observable を返すのか
this.route.paramsObservable<Params> を返します。これは、直訳すると観測可能なParamsを返すということになります。
Observable (観測可能) というのはどういう意味かというと、例えば URL のパラメータが別の場所で変更されたら、その値が取得できる、ということになります。
例えばここで this.route.paramsObservable を返さない場合、 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

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

ここまでのコミット

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

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

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

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

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

ここまでのコミット

まとめ

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

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

Angular入門 未経験から1ヶ月でサービス作れるようにする その7. Angularのフォーム2 (Reactive forms)

入門記事一覧

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

23
8
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
23
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?