この連載記事では、フロントエンドに Vue.js + Vue Router + Vuex とサーバーサイドに Laravel を使用したシングルページ Web アプリケーションの開発方法を紹介します。実際に写真共有アプリを開発する手順を通して SPA 開発のエッセンスを学ぶことができます。
今回のチュートリアルで扱うツールなどのバージョンは以下の通りです。
Node | npm | Vue.js | Vue Router | Vuex | PHP | Laravel |
---|---|---|---|---|---|---|
10.15 | 6.4 | 2.6 | 3.1 | 3.1 | 7.4 | 6.9 |
この章ではいいね機能を実装します。
Web API は、いいね付与 API といいね解除 API を新規追加します。また、写真一覧取得 API と写真詳細取得 API を編集し、写真に付いたいいねについての情報をレスポンスに追加します。
いいね機能 API
レスポンス JSON
いいねの付与および解除のレスポンス JSON は以下の通り写真 ID を返却するものとします。
{
"photo_id": "abcd1234EFGH"
}
テストコード
テストコードを追加します。
$ php artisan make:test LikeApiTest
tests/Feature/LikeApiTest.php
を以下の内容で編集してください。
<?php
namespace Tests\Feature;
use App\Photo;
use App\User;
use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;
class LikeApiTest extends TestCase
{
use RefreshDatabase;
public function setUp(): void
{
parent::setUp();
$this->user = factory(User::class)->create();
factory(Photo::class)->create();
$this->photo = Photo::first();
}
/**
* @test
*/
public function should_いいねを追加できる()
{
$response = $this->actingAs($this->user)
->json('PUT', route('photo.like', [
'id' => $this->photo->id,
]));
$response->assertStatus(200)
->assertJsonFragment([
'photo_id' => $this->photo->id,
]);
$this->assertEquals(1, $this->photo->likes()->count());
}
/**
* @test
*/
public function should_2回同じ写真にいいねしても1個しかいいねがつかない()
{
$param = ['id' => $this->photo->id];
$this->actingAs($this->user)->json('PUT', route('photo.like', $param));
$this->actingAs($this->user)->json('PUT', route('photo.like', $param));
$this->assertEquals(1, $this->photo->likes()->count());
}
/**
* @test
*/
public function should_いいねを解除できる()
{
$this->photo->likes()->attach($this->user->id);
$response = $this->actingAs($this->user)
->json('DELETE', route('photo.like', [
'id' => $this->photo->id,
]));
$response->assertStatus(200)
->assertJsonFragment([
'photo_id' => $this->photo->id,
]);
$this->assertEquals(0, $this->photo->likes()->count());
}
}
いいね付与 API の HTTP メソッドは PUT(リソースの置き換え)で実装します。あるユーザーは特定の写真に対して1回だけいいねが付けられる仕様としたためです。
そのため、2回同じ写真に対していいね付与 API を実行しても結果が変わらない、1個しかいいねが付かないことも確認しています。
ルート定義
ここから API の実装に入ります。
ルート定義は以下の通りです。
// いいね
Route::put('/photos/{id}/like', 'PhotoController@like')->name('photo.like');
// いいね解除
Route::delete('/photos/{id}/like', 'PhotoController@unlike');
モデルクラス
app/Photo.php
に likes
リレーションを定義します。
/**
* リレーションシップ - usersテーブル
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function likes()
{
return $this->belongsToMany('App\User', 'likes')->withTimestamps();
}
これは likes テーブルを中間テーブルとした、photos テーブルと users テーブルの多対多の関連性を表しています。
今回は likes テーブルに当たるモデルクラスは作成しません。特に外部キーしか中身のない中間テーブルの場合はモデルクラスは作成する必要のない場合が多いでしょう。Laravel のリレーションの機能を使えば関連するモデルから間接的に中間テーブルを操作することができます。
withTimestamps
はこのリレーションメソッドを使って likes テーブルにデータを挿入したとき、created_at および updated_at カラムを更新させるための指定です。
コントローラー
PhotoController
には like
と unlike
という2つのメソッドを追加します。
まずいいね付与のための like
メソッドです。
/**
* いいね
* @param string $id
* @return array
*/
public function like(string $id)
{
$photo = Photo::where('id', $id)->with('likes')->first();
if (! $photo) {
abort(404);
}
$photo->likes()->detach(Auth::user()->id);
$photo->likes()->attach(Auth::user()->id);
return ["photo_id" => $id];
}
何回実行しても1個しかいいねが付かないように、まず特定の写真およびログインユーザーに紐づくいいねを削除して(detach
)から、新たに追加(attach
)しています。
次にいいね解除のための unlike
メソッドです。
/**
* いいね解除
* @param string $id
* @return array
*/
public function unlike(string $id)
{
$photo = Photo::where('id', $id)->with('likes')->first();
if (! $photo) {
abort(404);
}
$photo->likes()->detach(Auth::user()->id);
return ["photo_id" => $id];
}
テスト実行
これでいいね付与と解除の API は完成です。
テストを実行しましょう。
$ ./vendor/bin/phpunit --testdox
写真一覧・詳細取得 API
続いて、写真一覧 API と詳細取得 API を修正します。
レスポンス JSON
まずレスポンスの JSON フォーマットですが、2つの項目を追加します。
likes_count
:写真に付いたいいねの総数。liked_by_user
:リクエストしたユーザーがその写真にいいねしているか。ログインしていない状態で呼ばれた場合はfalse
を返す。
写真一覧(data 項目のみ)
{
"data": [
{
"id": "abcd1234EFGH",
"url": "https://s3-ap-northeast-1.amazonaws.com/backet-name/abcd1234EFGH.jpeg",
"owner": {
"name": "John Lennon"
},
"likes_count": 12,
"liked_by_user": true
}
]
}
写真詳細
{
"id": "abcd1234EFGH",
"url": "https://s3-ap-northeast-1.amazonaws.com/backet-name/abcd1234EFGH.jpeg",
"owner": {
"name": "John Lennon"
},
"comments": [
{
"content": "Nice picture!",
"author": {
"name": "George Harrison"
}
}
],
"likes_count": 12,
"liked_by_user": true
}
テストコード
レスポンスの形式が変わるので、テストコードも修正します。
PhotoListApiTest
および PhotoDetailApiTest
のテストメソッド内で、 assertJsonFragment
の引数の期待値配列に以下の2項目を追加してください。
'liked_by_user' => false,
'likes_count' => 0,
モデルクラス
Photo
モデルに likes_count
と liked_by_user
という2つのアクセサを追加します。
まずは likes_count
アクセサです。
app/Photo.php
に getLikesCountAttribute
メソッドを追加してください。
/**
* アクセサ - likes_count
* @return int
*/
public function getLikesCountAttribute()
{
return $this->likes->count();
}
次に liked_by_user
アクセサです。
getLikedByUserAttribute
メソッドを追加します。
use Illuminate\Support\Facades\Auth; // ★ 追記
/**
* アクセサ - liked_by_user
* @return boolean
*/
public function getLikedByUserAttribute()
{
if (Auth::guest()) {
return false;
}
return $this->likes->contains(function ($user) {
return $user->id === Auth::user()->id;
});
}
Laravel のコレクションメソッド contains
を使って、ログインユーザーのIDと合致するいいねが含まれるか調べています。
likes
リレーションから取得できるのはユーザーモデル(のコレクション)だという点に注意しましょう。
例によって $appends
と $visible
に上記2点のアクセサを追加します。
/** JSONに含めるアクセサ */
protected $appends = [
'url', 'likes_count', 'liked_by_user',
];
/** JSONに含める属性 */
protected $visible = [
'id', 'owner', 'url', 'comments',
'likes_count', 'liked_by_user',
];
コントローラー
コントローラーメソッドでの対応内容は写真一覧も写真詳細も同じです。with
の引数の配列に likes
を追加して、likes
リレーションがロードされるようにします。
写真一覧
public function index()
{
$photos = Photo::with(['owner', 'likes'])
->orderBy(Photo::CREATED_AT, 'desc')->paginate();
return $photos;
}
写真詳細
public function show(string $id)
{
$photo = Photo::where('id', $id)
->with(['owner', 'comments.author', 'likes'])->first();
return $photo ?? abort(404);
}
テスト実行
これで写真一覧 API と詳細取得 API も完成です。
テストを実行しましょう。
$ ./vendor/bin/phpunit --testdox
API が揃ったのでフロントエンドを実装していきましょう。
写真一覧ページ
まず写真一覧ページです。
以下のような仕組みでいいね機能を実現しようと思います。
(1)
<Photo>
にあるいいねボタンがクリックされたとき、<Photo>
から <PhotoList>
にいいねボタンがクリックされたことを通知するイベントを発行する。
(2)
<PhotoList>
はイベントを受け取って以下の処理を行う。
- ユーザーがログイン状態でなければ、ログインを促すアラートを表示する。
- ユーザーがクリックした写真にいいね済みであればいいね解除処理を行う。
- ユーザーがクリックした写真にいいねしていなければいいね付与処理を行う。
「ユーザーがクリックした写真にいいね済み」かどうかを、レスポンスの JSON に追加した liked_by_user
で判定しようというわけです。
また、いいねボタンは以下のようにいいねする前と後で見た目が変わります。
(左:いいねする前 / 右:いいねした後)
ログインしていなければすべて左の見た目になります。
Photo コンポーネント
まず <Photo>
コンポーネントのいいねボタンを以下のように編集してください。
<button
class="photo__action photo__action--like"
:class="{ 'photo__action--liked': item.liked_by_user }"
title="Like photo"
@click.prevent="like"
>
<i class="icon ion-md-heart"></i>{{ item.likes_count }}
</button>
<button>
要素に :class
と @click
を追加します。:class
は上述の通りいいね済みの場合に見た目を変えるためです。また、アイコンの横にいいね数を表示します。
次に methods
に like
メソッドを追加します。
like () {
this.$emit('like', {
id: this.item.id,
liked: this.item.liked_by_user
})
}
クリックされた写真のIDといいね済みかどうかをデータとしてイベント発行先に渡します。
PhotoList コンポーネント
<PhotoList>
コンポーネントでは、まず <Photo>
から発行された like
イベントを受け取る記述を追加しましょう。onLikeClick
メソッドをハンドラとします。
<Photo
class="grid__item"
v-for="photo in photos"
:key="photo.id"
:item="photo"
@like="onLikeClick"
/>
そして methods
に onLikeClick
メソッドを追加します。冒頭に書いた通りのロジックです。
onLikeClick ({ id, liked }) {
if (! this.$store.getters['auth/check']) {
alert('いいね機能を使うにはログインしてください。')
return false
}
if (liked) {
this.unlike(id)
} else {
this.like(id)
}
},
methods
にさらに like
メソッドと unlike
メソッドを追加します。
まず like
メソッドです。
async like (id) {
const response = await axios.put(`/api/photos/${id}/like`)
if (response.status !== OK) {
this.$store.commit('error/setCode', response.status)
return false
}
this.photos = this.photos.map(photo => {
if (photo.id === response.data.photo_id) {
photo.likes_count += 1
photo.liked_by_user = true
}
return photo
})
},
いいね付与 API への通信が完了したあと、ページ上の写真の見た目(いいね数とボタンの色)を変えるため、this.photo
のデータを更新しています。
いいね数を一つ増やして、いいねしたかどうかを表す liked_by_user
を true
に更新しています(これによってボタンの見た目が変わります)。
次に unlike
メソッドです。
async unlike (id) {
const response = await axios.delete(`/api/photos/${id}/like`)
if (response.status !== OK) {
this.$store.commit('error/setCode', response.status)
return false
}
this.photos = this.photos.map(photo => {
if (photo.id === response.data.photo_id) {
photo.likes_count -= 1
photo.liked_by_user = false
}
return photo
})
}
like
メソッドとやっていることはほぼ同じです。unlike
ではいいね数を一つ減らして、いいねしたかどうかを表す liked_by_user
を false
に更新しています。
写真詳細ページ
写真詳細ページも写真一覧ページと同じパターンです。ただし <PhotoList>
→ <Photo>
のような階層構造がないので、$emit
は必要なく、<PhotoDetail>
コンポーネントのみを編集します。
PhotoDetail コンポーネント
まずいいねボタンを編集します。
<button
class="button button--like"
:class="{ 'button--liked': photo.liked_by_user }"
title="Like photo"
@click="onLikeClick"
>
<i class="icon ion-md-heart"></i>{{ photo.likes_count }}
</button>
編集箇所は先ほどと一緒です。編集内容は異なるので注意してくださいね。
methods
に onLikeClick
メソッド、like
メソッド、unlike
メソッドを追加します。
onLikeClick () {
if (! this.isLogin) {
alert('いいね機能を使うにはログインしてください。')
return false
}
if (this.photo.liked_by_user) {
this.unlike()
} else {
this.like()
}
},
こちらも写真一覧と同じパターンですね。
async like () {
const response = await axios.put(`/api/photos/${this.id}/like`)
if (response.status !== OK) {
this.$store.commit('error/setCode', response.status)
return false
}
this.photo.likes_count = this.photo.likes_count + 1
this.photo.liked_by_user = true
},
async unlike () {
const response = await axios.delete(`/api/photos/${this.id}/like`)
if (response.status !== OK) {
this.$store.commit('error/setCode', response.status)
return false
}
this.photo.likes_count = this.photo.likes_count - 1
this.photo.liked_by_user = false
}
これでいいね機能は完成しました
ブラウザでいいねを付けたり外したりしてみましょう。
この章はこれでおしまいです。
本章までのソースコードはリポジトリの ch-15 ブランチに置いてあります。
次の章がこのチュートリアルの最後の章です。
最後の仕上げにエラーハンドリングを追加します。
関連記事
連載記事(全16回)
Vue + Vue Router + Vuex + Laravelで写真共有アプリを作ろう
- (1) イントロダクション
- (2) アプリケーションの設計
- (3) SPA開発環境とVue Router
- (4) 認証API
- (5) 認証ページ
- (6) 認証機能とVuex
- (7) 認証機能とVuex Part.2
- (8) エラーハンドリング
- (9) 写真投稿API
- (10) 写真投稿フォーム
- (11) 写真一覧取得API
- (12) 写真一覧ページ
- (13) 写真詳細ページ
- (14) コメント投稿機能
- (15) いいね機能
- (16) エラーハンドリング Part.2