この連載記事では、フロントエンドに 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
JSON レスポンス
まずは JSON レスポンスのフォーマットを考えます。
写真詳細
前章で実装した写真詳細取得 API にコメントも含めるように考え直します。
{
"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"
}
},
{
"content": "Thanks for sharing.",
"author": {
"name": "Ringo Starr"
}
}
]
}
"comments"
を追加しました。
また "comments"
配列は id
の降順に並ぶものとします。comments テーブルの id
は連番なので、id
の降順は作成日の降順と意味はほとんど同じです。ただ作成日はたまたま秒単位で重複する可能性がありますが自動採番の id
であればその心配はありません。
コメント投稿
本章で実装するコメント投稿 API のレスポンスは以下のようにしましょう。
{
"content": "Thanks for sharing.",
"author": {
"name": "Ringo Starr"
}
}
写真詳細取得 API のレスポンスに追加した "comments"
の一つ分です。
また、リソースの新規作成なのでレスポンスコードは 201 CREATED を使用します。
テスト
では API の実装の前に動作確認用のテストコードを作成しましょう。
ファクトリ
コメントのテストデータを作成するファクトリを追加します。
$ php artisan make:factory CommentFactory
雛形 database/factories/CommentFactory.php
を以下の通り編集してください。
<?php
/** @var \Illuminate\Database\Eloquent\Factory $factory */
use Faker\Generator as Faker;
$factory->define(App\Comment::class, function (Faker $faker) {
return [
'content' => substr($faker->text, 0, 500),
'user_id' => fn() => factory(App\User::class)->create()->id,
];
});
写真詳細取得テストケース
写真詳細取得 API のレスポンスが変更されるので、テストも更新します。
<?php
namespace Tests\Feature;
use App\Comment;
use App\Photo;
use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;
class PhotoDetailApiTest extends TestCase
{
use RefreshDatabase;
/**
* @test
*/
public function should_正しい構造のJSONを返却する()
{
factory(Photo::class)->create()->each(function ($photo) {
$photo->comments()->saveMany(factory(Comment::class, 3)->make());
});
$photo = Photo::first();
$response = $this->json('GET', route('photo.show', [
'id' => $photo->id,
]));
$response->assertStatus(200)
->assertJsonFragment([
'id' => $photo->id,
'url' => $photo->url,
'owner' => [
'name' => $photo->owner->name,
],
'comments' => $photo->comments
->sortByDesc('id')
->map(function ($comment) {
return [
'author' => [
'name' => $comment->author->name,
],
'content' => $comment->content,
];
})
->all(),
]);
}
}
- 冒頭で
Photo
のテストデータを作成したあとにComment
のデータも作成しています。 assertJsonFragment
の引数の期待値にcomments
を追加しました。
コメント投稿テストケース
コメント投稿のテストケースを追加します。
$ php artisan make:test AddCommentApiTest
雛形 tests/Feature/AddCommentApiTest.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 AddCommentApiTest extends TestCase
{
use RefreshDatabase;
public function setUp(): void
{
parent::setUp();
// テストユーザー作成
$this->user = factory(User::class)->create();
}
/**
* @test
*/
public function should_コメントを追加できる()
{
factory(Photo::class)->create();
$photo = Photo::first();
$content = 'sample content';
$response = $this->actingAs($this->user)
->json('POST', route('photo.comment', [
'photo' => $photo->id,
]), compact('content'));
$comments = $photo->comments()->get();
$response->assertStatus(201)
// JSONフォーマットが期待通りであること
->assertJsonFragment([
"author" => [
"name" => $this->user->name,
],
"content" => $content,
]);
// DBにコメントが1件登録されていること
$this->assertEquals(1, $comments->count());
// 内容がAPIでリクエストしたものであること
$this->assertEquals($content, $comments[0]->content);
}
}
ルート定義
ルート定義は以下の通りです。
// コメント
Route::post('/photos/{photo}/comments', 'PhotoController@addComment')->name('photo.comment');
モデルクラス
Comment
コメントデータを表すモデルクラスを追加します。
$ php artisan make:model Comment
app/Comment.php
を以下の通り編集してください。
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Comment extends Model
{
/** JSONに含める属性 */
protected $visible = [
'author', 'content',
];
/**
* リレーションシップ - usersテーブル
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function author()
{
return $this->belongsTo('App\User', 'user_id', 'id', 'users');
}
}
新しい内容はないですね。
Users
とのリレーションを定義して、$visible
で JSON での表示項目を制御しています。
Photo
次は Photo
モデルに上記 Comment
モデルとのリレーションを定義します。
/**
* リレーションシップ - commentsテーブル
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function comments()
{
return $this->hasMany('App\Comment')->orderBy('id', 'desc');
}
$visible
にリレーションメソッドを含めます。
/** JSONに含める属性 */
protected $visible = [
'id', 'owner', 'url', 'comments',
];
フォームリクエスト
コメント投稿 API はユーザーが入力した値をリクエストに含んでいるので、フォームリクエストクラスを作成します。
$ php artisan make:request StoreComment
app/Http/Requests/StoreComment.php
を以下の通り編集してください。
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreComment extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'content' => 'required|max:500',
];
}
}
content
という項目名で必須かつ500文字以内のルールを定義しました。
authorize
の返却値を true
にしておくのも忘れずに。
コントローラー
コメント投稿
PhotoController
にコメント投稿機能を実現する addComment
メソッドを追加します。
use App\Comment;
use App\Http\Requests\StoreComment;
class PhotoController extends Controller
{
/* 中略 */
/**
* コメント投稿
* @param Photo $photo
* @param StoreComment $request
* @return \Illuminate\Http\Response
*/
public function addComment(Photo $photo, StoreComment $request)
{
$comment = new Comment();
$comment->content = $request->get('content');
$comment->user_id = Auth::user()->id;
$photo->comments()->save($comment);
// authorリレーションをロードするためにコメントを取得しなおす
$new_comment = Comment::where('id', $comment->id)->with('author')->first();
return response($new_comment, 201);
}
}
Laravel の基本的なコードですね。実装ができたらテストを実行しましょう。
$ ./vendor/bin/phpunit --testdox
写真詳細取得のテストケースについては失敗するはずですがまだ実装が完了していないのでそれで OK です。コメント投稿のテストケースが成功するかを確認してください。
写真詳細
続いて写真詳細取得処理を修正します。
/**
* 写真詳細
* @param string $id
* @return Photo
*/
public function show(string $id)
{
$photo = Photo::where('id', $id)
->with(['owner', 'comments.author'])->first();
return $photo ?? abort(404);
}
with
メソッドの引数に 'comments.author'
を追加しました。
with
メソッドは階層化されたリレーションもロードできます。今回は投稿者の名前も取得する必要がありますが、ここでのリレーションは以下のように表現できるでしょう。
Photo -[comments]-> Comment -[author]-> User
comments
リレーションから Comment
を取得してさらにそこから author
リレーションを辿って User
の name
も取得する必要があります。
このような場合、comments.author
のようにドット区切りでリレーション名を指定すると自動的に階層化されたリレーションを辿ってロードしてくれます。
ちなみに上記の show
メソッドでは以下のような SQL が発行されます。
SELECT * FROM `photos` WHERE `id` = "abcd1234EFGH";
SELECT * FROM `users` WHERE `id` IN (1); -- ownerリレーションを解決する
SELECT * FROM `comments` WHERE `photo_id` = "abcd1234EFGH"; -- commentsリレーションを解決する
SELECT * FROM `users` WHERE `id` IN (2, 3, 4); -- comments.authorリレーションを解決する
さて、実装ができたらテストを実行しましょう。
$ ./vendor/bin/phpunit --testdox
これで API の実装は完了です。
フロントエンド
API が実装できたので、フロントエンドを作っていきます。
コメントを投稿する
<PhotoDetail>
コンポーネントに、まずはコメント投稿フォームを追加します。
<h2 class="photo-detail__title">
<i class="icon ion-md-chatboxes"></i>Comments
</h2>
<form @submit.prevent="addComment" class="form">
<textarea class="form__item" v-model="commentContent"></textarea>
<div class="form__button">
<button type="submit" class="button button--inverse">submit comment</button>
</div>
</form>
上記の通り <form>
要素を追加してください。
続いて <textarea>
入力値を参照する commentContent
と submit
イベントのハンドラである addComment
メソッドを追加します。
data () {
return {
photo: null,
fullWidth: false,
commentContent: ''
}
},
methods: {
async fetchPhoto () {/* 中略 */},
async addComment () {
const response = await axios.post(`/api/photos/${this.id}/comments`, {
content: this.commentContent
})
this.commentContent = ''
}
}
エラー処理
エラー処理を追加します。
まずスクリプトブロック冒頭のレスポンスコードのインポートに CREATED
を追加します。
import { OK, CREATED, UNPROCESSABLE_ENTITY } from '../util'
data
にエラーメッセージを入れる commentErrors
を追加します。
data () {
return {
photo: null,
commentContent: '',
commentErrors: null
}
},
addComment
メソッドを以下の通り編集してください。
async addComment () {
const response = await axios.post(`/api/photos/${this.id}/comments`, {
content: this.commentContent
})
// バリデーションエラー
if (response.status === UNPROCESSABLE_ENTITY) {
this.commentErrors = response.data.errors
return false
}
this.commentContent = ''
// エラーメッセージをクリア
this.commentErrors = null
// その他のエラー
if (response.status !== CREATED) {
this.$store.commit('error/setCode', response.status)
return false
}
}
パターンはいままで実装してきた API 通信メソッドと一緒ですね。
最後にエラーメッセージ表示欄をフォームに追加します。
<form @submit.prevent="addComment" class="form">
<div v-if="commentErrors" class="errors">
<ul v-if="commentErrors.content">
<li v-for="msg in commentErrors.content" :key="msg">{{ msg }}</li>
</ul>
</div>
<!-- 中略 -->
</form>
テキストエリアが空のまま送信ボタンをクリックすると動作を確認できます。
ログイン時のみフォームを表示
コメント投稿機能はログイン時のみ使用可能なので、ログインしていない時はフォーム自体を非表示にしてしまいましょう。
ナビゲーションバーやフッターの表示要素を切り替えたときと同じパターンです。
まずは算出プロパティ isLogin
でストアのゲッターを参照します。
computed: {
isLogin () {
return this.$store.getters['auth/check']
}
},
<form>
要素に v-if
を追加し、isLogin
を条件とします。
<form v-if="isLogin" @submit.prevent="addComment" class="form">
コメントを一覧表示する
コメント投稿ができたので、次はコメントの一覧を表示します。
<h2>
と <form>
の間に一覧表示欄のマークアップを追加します。
<h2 class="photo-detail__title">
<i class="icon ion-md-chatboxes"></i>Comments
</h2>
<ul v-if="photo.comments.length > 0" class="photo-detail__comments">
<li
v-for="comment in photo.comments"
:key="comment.content"
class="photo-detail__commentItem"
>
<p class="photo-detail__commentBody">
{{ comment.content }}
</p>
<p class="photo-detail__commentInfo">
{{ comment.author.name }}
</p>
</li>
</ul>
<p v-else>No comments yet.</p>
<form v-if="isLogin" @submit.prevent="addComment" class="form">
<!-- 中略 -->
</form>
コメントが1件以上あるときは <ul>
要素のリストが表示され、コメントがない場合は <p>
要素のメッセージが表示されます。
最後に、コメントを投稿し終わったあとに一覧に投稿したてのコメントを表示させるため、this.photo.comments
配列にレスポンスデータを挿入します。
async addComment () {
/* 中略 */
this.photo.comments = [
response.data,
...this.photo.comments
]
}
この章はこれでおしまいです。
本章までのソースコードはリポジトリの ch-14 ブランチに置いてあります。
次の章ではいいね機能を実装します。
関連記事
連載記事(全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