これまでの記事で、インストールからルーティング、リンクやクリックイベントなどAngularの基礎的な部分を学習してきました。
本記事では、これまで学習した内容を使ってプロジェクトを少し修正していきます。(追加で学習する部分もあります)
- リスト表示から、詳細画面への遷移を追加
- 詳細画面、編集画面で、URLのパラメータからどの商品かを判断するようにする
- 詳細画面から編集画面への遷移、編集画面から詳細画面への遷移を追加
をやっていきます。
この記事のソースコード
https://github.com/seteen/AngularGuides/tree/入門その06-2
リスト表示から、詳細、編集画面への遷移を追加
リストにマウスをホバーすると、詳細画面、編集画面へのリンクが出てくるようにしたいと思います。
マウスのホバーの取得
マウスのホバー状態の取得を、 mouseenter
(マウスがホバーした) , mouseleave
(マウスのホバーが外れた) の2つのイベントを取得することで実現してみます。
まず、この2つのイベントが発生したときに実行されるメソッドをコンポーネントに追加していきます。
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
のイベントを取得して、追加したメソッドを呼ぶようにしてみましょう。
<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を拡張したクラスを用意するというものです。
class ProductListElement extends Product {
hovered: boolean;
}
上記のクラスは、 Product
クラスを継承しているため、 Product
の要素を持っていて、
かつ hovered
というホバーされているかどうかの状態をもたせるようにしています。
TIPS: Typescriptにおける継承
Typescriptの継承は、Java などの一般的なオブジェクト指向言語と同様の継承です(今どきはJavaを知らない人の方が多いのかもしれない...)。
簡単に説明すると、インスタンス変数、インスタンスメソッドを引き継ぎつつ、新しい変数やメソッドを追加したいときに使う実装手段です。詳しくはTypescript公式ページを参照ください
https://www.typescriptlang.org/docs/handbook/classes.html
新しく作ったクラスを利用することで、ホバー状態を保存できます。
実際に使ってみます。
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の定義
class ProductListElement extends Product {
hovered: boolean;
}
今回は、クラス定義の場所をコンポーネントの上部にしました。
他のクラスから利用されるようになったら別ファイルにして export
しますが、それほど長いコードではないので今回はコンポーネントに直接記述しています。
hovered, unhovered メソッド
各メソッドの引数の型を ProductListElement
に変更しました。
また、各メソッド中で、 hovered
変数の値を変更しています。これにより、 ホバーされているかどうかを 各 ProductListElement
が持つことになります。
ngOnInitの変更点
まず、 map
メソッドを使っています。 map
メソッドは一般的なメソッドなので説明は不要かと思いますが、後ほどTIPSで少し解説します。
products.map
で呼び出しているメソッドを見てみると、少し特殊な書き方をしています。
this.products = products.map((product: Product) => {
return {
... product,
hovered: false,
};
});
この
{
... 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 文を使ったときとの比較をしてみましょう。
result = []
for(let i=0; i<products.length; i++) {
result.push({
...products[i],
hovered: false,
});
}
上記のコードは、mapを使った下記のコードと同じです。
result = products.map((product: Product) => {
return {
... product,
hovered: false,
};
});
mapを使った方がきれいですよね。文字数も減りますし、謎の初期値を定義する行も必要ないです。
最初見たときは難しく感じるかもしれませんが、 map メソッドは、配列の各要素に対して何かしらの処理をした結果の配列を返すメソッドです。
そのあたりを意識しながら何度か読んでいればわかるようになると思います。
保存したホバー状態を使って、ホバーされているときは詳細画面へ遷移するボタンが出るようにする
ホバー状態の保存ができたところで、ホバーされている要素はボタンが表示されるようにしてみましょう。
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
を修正します。
.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
にアクセスします。
ここまでのコミット
詳細画面、編集画面で、URLのパラメータからどの商品かを判断するようにする
現状の詳細画面、編集画面は固定の product
の情報を表示しています。
URLからIDを取得してその product
の情報を表示するようにしてみましょう。
Angularでは、 URLの情報を取得するのに ActivatedRoute
を利用します。
さっそくコードを修正していきます。
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 | 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
具体的には
{ path: 'products/:id', component: ProductDetailComponent },
の記述で指定している :id
がこのパラメータにあたります。
URLからこの :id
を取得するのに利用するときに利用するのが params
です。
では、 queryParams
はどういうものかというと、例えば http://localhost:4200/products/1?test=1&hoge=12
のようなURLのクエリパラメータである test=1
, hoge=12
を取得するときに利用します。
今回のソースコードでの利用部分の解説
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'])
が再度実行されるため )一度覚えると便利です。
編集画面の変更
同様に、編集画面も変更します。全く同じ変更内容です。
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']);
}
}
ここで、動作確認をしてみましょう。
URLと商品の内容が連動していますね。
ここまでのコミット
詳細画面から編集画面への遷移、編集画面から詳細画面への遷移を追加
ここからは、ここまで学んだ技術を利用してスタイルの変更などを行っていきます。
product-detail.component
の変更↓
<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>
.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
の変更↓
<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>
.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
については、キャンセルボタンを追加しました。
動作確認をしてみましょう。
ちゃんと画面の行き来ができて、少し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