2019.01.12

Vue + Vue Router + Vuex + Laravelで写真共有アプリを作ろう (14) コメント投稿機能


この連載記事では、フロントエンドに Vue.js + Vue Router + Vuex とサーバーサイドに Laravel を使用したシングルページ Web アプリケーションの開発方法を紹介します。実際に写真共有アプリを開発する手順を通して SPA 開発のエッセンスを学んでいただけるように書いていきます。

今回のチュートリアルで扱うツールなどのバージョンは以下の通りです。

Node npm Vue.js Vue Router Vuex PHP Laravel
10.7.0 6.4.1 2.5.21 3.0.2 3.0.1 7.2.10 5.7.19

この章では写真詳細ページにコメント投稿機能を実装します。

写真閲覧ページ

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 を以下の通り編集してください。

CommentFactory.php
<?php

use Faker\Generator as Faker;

$factory->define(App\Comment::class, function (Faker $faker) {
    return [
        'content' => substr($faker->text, 0, 500),
        'user_id' => function () {
            return factory(App\User::class)->create()->id;
        },
    ];
});

写真詳細取得テストケース

写真詳細取得 API のレスポンスが変更されるので、テストも更新します。

PhotoDetailApiTest.php
<?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 を以下の通り編集してください。

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()
    {
        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);
    }
}

ルート定義

ルート定義は以下の通りです。

api.php
// コメント
Route::post('/photos/{photo}/comments', 'PhotoController@addComment')->name('photo.comment');

モデルクラス

Comment

コメントデータを表すモデルクラスを追加します。

$ php artisan make:model Comment

app/Comment.php を以下の通り編集してください。

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 モデルとのリレーションを定義します。

Photo.php
/**
 * リレーションシップ - commentsテーブル
 * @return \Illuminate\Database\Eloquent\Relations\HasMany
 */
public function comments()
{
    return $this->hasMany('App\Comment')->orderBy('id', 'desc');
}

$visible にリレーションメソッドを含めます。

Photo.php
/** JSONに含める属性 */
protected $visible = [
    'id', 'owner', 'url', 'comments',
];

フォームリクエスト

コメント投稿 API はユーザーが入力した値をリクエストに含んでいるので、フォームリクエストクラスを作成します。

$ php artisan make:request StoreComment

app/Http/Requests/StoreComment.php を以下の通り編集してください。

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 メソッドを追加します。

PhotoController.php
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 です。コメント投稿のテストケースが成功するかを確認してください。

写真詳細

続いて写真詳細取得処理を修正します。

PhotoController.php
/**
 * 写真詳細
 * @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 リレーションを辿って Username も取得する必要があります。

このような場合、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> コンポーネントに、まずはコメント投稿フォームを追加します。

PhotoDetail.vue
<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> 入力値を参照する commentContentsubmit イベントのハンドラである addComment メソッドを追加します。

PhotoDetail.vue
data () {
  return {
    photo: null,
    commentContent: ''
  }
},
PhotoDetail.vue
methods: {
  async fetchPhoto () {/* 中略 */},
  async addComment () {
    const response = await axios.post(`/api/photos/${this.id}/comments`, {
      content: this.commentContent
    })

    this.commentContent = ''
  }
}

エラー処理

エラー処理を追加します。
まずスクリプトブロック冒頭のレスポンスコードのインポートに CREATED を追加します。

PhotoDetail.vue
import { OK, CREATED, UNPROCESSABLE_ENTITY } from '../util'

data にエラーメッセージを入れる commentErrors を追加します。

PhotoDetail.vue
data () {
  return {
    photo: null,
    commentContent: '',
    commentErrors: null
  }
},

addComment メソッドを以下の通り編集してください。

PhotoDetail.vue
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 通信メソッドと一緒ですね。
最後にエラーメッセージ表示欄をフォームに追加します。

PhotoDetail.vue
<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 でストアのゲッターを参照します。

PhotoDetail.vue
computed: {
  isLogin () {
    return this.$store.getters['auth/check']
  }
},

<form> 要素に v-if を追加し、isLogin を条件とします。

PhotoDetail.vue
<form v-if="isLogin" @submit.prevent="addComment" class="form">

コメントを一覧表示する

コメント投稿ができたので、次はコメントの一覧を表示します。

<h2><form> の間に一覧表示欄のマークアップを追加します。

PhotoDetail.vue
<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 配列にレスポンスデータを挿入します。

PhotoDetail.vue
async addComment () {
  /* 中略 */

  this.$set(this.photo, 'comments', [
    response.data,
    ...this.photo.comments
  ])
}

コメントの更新には $set メソッドを使っています。

オブジェクトの data 変数を更新する場合、一部のキーだけ更新したいときに以下のように記述しても Vue は変更を検知できません。これは実装上の制約のようです。

this.item.name = 'vue'

変更を検知できないということはテンプレートや算出プロパティに変更を反映できないということです。代わりに以下のように $set を使用する必要があります。

this.$set(this.item, 'name', 'vue')

参考:Vue.set( target, key, value )

試しに上記のコードを以下のコードに書き換えてみてください。

this.photo.comments = [
  response.data,
  ...this.photo.comments
]

コメントが投稿されても一覧の表示に反映されないはずです。

👾 👾 👾

この章はこれでおしまいです。
本章までのソースコードはリポジトリの chapter-14 ブランチに置いてあります。

次の章ではいいね機能を実装します。

関連記事

連載記事(全16回)

その他


<!-- Font Awesome Free 5.0.13 by @fontawesome - https://fontawesome.com --><!-- License - https://fontawesome.com/license (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) -->