2019.01.12

Vue + Vue Router + Vuex + Laravelで写真共有アプリを作ろう (13) 写真詳細ページ


この連載記事では、フロントエンドに 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 とフロントエンドの実装を本章でまとめて説明します。

ただし、いいねとコメント投稿は後続の章で実装します。

Web API

JSON レスポンス

JSON レスポンスは一覧の配列の一つ分と同じです。

{
  "id": "abcd1234EFGH",
  "url": "https://s3-ap-northeast-1.amazonaws.com/backet-name/abcd1234EFGH.jpeg",
  "owner": {
    "name": "John Lennon"
  }
}

最終的にはいいね数やコメントなども含めますが、それらの機能は後続の章で実装しますので、本章では上記のフォーマットの JSON がレスポンスされる API 実装します。

テスト

まずテストを書きます。

$ php artisan make:test PhotoDetailApiTest

内容は一覧取得 API と基本的に同じパターンです。

PhotoDetailApiTest.php
<?php

namespace Tests\Feature;

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();
        $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,
                ],
            ]);
    }
}

assertJsonFragment メソッドで JSON のフォーマットを確かめています。

ルート定義

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

api.php
// 写真詳細
Route::get('/photos/{id}', 'PhotoController@show')->name('photo.show');

パスパラメータ id を定義しています。

コントローラー

写真詳細取得 API も認証なしでアクセスできる仕様にするので、コントローラーではまずコンストラクタの認証ミドルウェア指定の箇所で、except の引数にメソッド名 show を追加します。

PhotoController.php
public function __construct()
{
    // 認証が必要
    $this->middleware('auth')->except(['index', 'show']);
}

show メソッドを追加します。

PhotoController.php
/**
 * 写真詳細
 * @param string $id
 * @return Photo
 */
public function show(string $id)
{
    $photo = Photo::where('id', $id)->with(['owner'])->first();

    return $photo ?? abort(404);
}

index メソッドとほとんど同じですね。

  • 引数でパスパラメータ id を受け取っています。
  • 写真データが見つからなかった場合は 404 を返却しています。

テスト実行

実装ができたらテストを実行しましょう。

./vendor/bin/phpunit --testdox

これで API の実装は完了です。

フロントエンド

PhotoDetail コンポーネント

<PhotoDetail> コンポーネントを以下の通り編集してください。

PhotoDetail.vue
<template>
  <div v-if="photo" class="photo-detail">
    <figure class="photo-detail__pane photo-detail__image">
      <img :src="photo.url" alt="">
      <figcaption>Posted by {{ photo.owner.name }}</figcaption>
    </figure>
    <div class="photo-detail__pane">
      <button class="button button--like" title="Like photo">
        <i class="icon ion-md-heart"></i>12
      </button>
      <a
        :href="`/photos/${photo.id}/download`"
        class="button"
        title="Download photo"
      >
        <i class="icon ion-md-arrow-round-down"></i>Download
      </a>
      <h2 class="photo-detail__title">
        <i class="icon ion-md-chatboxes"></i>Comments
      </h2>
    </div>
  </div>
</template>

<script>
import { OK } from '../util'

export default {
  props: {
    id: {
      type: String,
      required: true
    }
  },
  data () {
    return {
      photo: null
    }
  },
  methods: {
    async fetchPhoto () {
      const response = await axios.get(`/api/photos/${this.id}`)

      if (response.status !== OK) {
        this.$store.commit('error/setCode', response.status)
        return false
      }

      this.photo = response.data
    }
  },
  watch: {
    $route: {
      async handler () {
        await this.fetchPhoto()
      },
      immediate: true
    }
  }
}
</script>

紹介すべき点はほとんど前章の写真一覧実装で説明してしまいました。
ルートパラメータの取得箇所のみ説明します。

router.js では、次のように写真詳細ページのルートを定義していました。

router.js
{
  path: '/photos/:id',
  component: PhotoDetail,
  props: true
},

path:id がパラメータとして定義されている部分です。写真IDがハマる箇所ですね。

さらに propstrue に設定していますので、この :id の値が <PhotoDetail> コンポーネントに props として渡されます。

props: {
  id: {
    type: String,
    required: true
  }
},

参考:ルートコンポーネントにプロパティを渡す

写真の幅を切り替える

写真をクリックすると、横幅いっぱいのサイズで表示されるようにします。
写真の右にあるコメント欄などは写真の下に配置します。

まず、datafullWidth を追加します。

PhotoDetail.vue
data () {
  return {
    photo: null,
    fullWidth: false
  }
},

次にテンプレートブロックを以下の通り編集します。

PhotoDetail.vue
<template>
  <div
    v-if="photo"
    class="photo-detail"
    :class="{ 'photo-detail--column': fullWidth }"
  >
    <figure
      class="photo-detail__pane photo-detail__image"
      @click="fullWidth = ! fullWidth"
    >
  • 一番上の <div> 要素に :class を追加する。
  • <figure> 要素に @click を追加する。

これで完成です。仕組みは以下の通りです。

  1. 写真(<figure>)をクリックすると fullWidth の値が truefalse に切り替わる。
  2. fullWidth の値が切り替わると <div> 要素の photo-detail--column クラスが付いたり外れたりする。

    • 具体的にいうと、photo-detail--column クラスが付いていなければ flex-directionrow になるので横並びになる。
    • photo-detail--column クラスが付いていれば flex-directioncolumn になるので縦並びになる。

ブラウザで動作を確認してみてください。

👾 👾 👾

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

次の章ではコメント投稿機能を実装します。

関連記事

連載記事(全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) -->