LuftGarden - 熊本のWebサイト・ホームページ制作スタジオ

BLOG技術ブログ

Laravel + Vue.js で出席管理Webアプリを作成する – Part.3

はじめに

Part.2 ではLaravelでAPIの作成までを行いました。
今回からは、Vue.jsを使ってビューの部分を作成(ブラウザから出欠状況を確認できるように)します。

過去の記事は以下から参照してください。
Laravel + Vue.js で出席管理Webアプリを作成する – Part.1
Laravel + Vue.js で出席管理Webアプリを作成する – Part.2

ルーティング設定

Web画面のルーティング設定はroutes/web.phpに記述する必要があります。以下のように、ホーム画面(出席一覧画面)のルーティング設定を行いましょう。

routes/web.php
Route::get('/', 'AttendancesController@index')->name('home');

Controllerの設定

今回はAPI向けのコントローラではないため、Api/とディレクトリを切らずにController名のみ指定して作成します。

$ php artisan make:controller AttendancesController

app/Http/Controllers/AttendancesController.phpが生成されるので、以下のように編集します。

app/Http/Controllers/AttendancesController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class AttendancesController extends Controller
{
    public function index()
    {
        return view('attendances.index');
    }
}

ホーム画面のView作成

パッケージのインストールとアセットのコンパイル

開発環境にssh接続した状態で、npm installでパッケージのインストールを行っておきます。

また、 Laravel Mix(後述)を使用してアセットのコンパイルを行っておきます(CSSやJavaScriptの修正をするたびにコンパイルするのは面倒なので、watchタスクとして実行しておきましょう)。

$ cd ~/Homestead
$ vagrant ssh

$ cd ~/code/LaravelSample
$ npm install

# Docker上やLaravel Homestead環境の場合、
# watch ではなく watch-poll を使用しないと変更を検出できないようです
$ npm run watch-poll

マスターレイアウトのViewを作成

Laravel artisanではViewを作成する手段は提供されていないため、自分で作成する必要があります。
マスターレイアウトとしてresources/views/配下にmaster.blade.phpを手動作成し、以下のように編集してください。

resources/views/master.blade.php
<!DOCTYPE html>
<html lang="{{ app()->getLocale() }}">

<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>LaravelSample - 出席管理</title>
    <meta name="csrf-token" content="{{ csrf_token() }}"> <!-- *1 -->
    <link rel="stylesheet" type="text/css" href="{{ mix('css/app.css') }}"> <!-- *2 -->
</head>

<body>
<header class="global-header">
    <section class="header-text">
        <h1><a href="{{ route('home') }}">出席管理</a></h1> <!-- *3 -->

        <div class="tag-line">出席とるよ!!</div>
    </section>
</header>

<main id="app" class="container">
    @yield('content')
</main>

<script src="{{ mix('js/app.js') }}"></script> <!-- *2 -->
</body>

</html>
*1 出席データの取得は  Vue.js +  axiosで行うので、基本的にAjax通信となります。Ajax通信にCSRF保護を加えるためには、metaタグにトークンを埋め込んでおく手法が 公式ドキュメントで推奨されています。
*2  Laravel Mix(webpackのラッパー)を使ってCSSやJavaScriptファイルを読み込んでいます。設定ファイルはnode_modules/laravel-mix/setup/webpack.config.jsに配置されています。また、読み込むアセットを追加・変更したい場合はプロジェクトルートのwebpack.mix.jsを編集すればOKです。
*3 app/web.phpで設定したルーティングの名前を指定することで、URLを吐き出しています。

ホーム画面のViewを作成

resources/views/attendances/配下にindex.blade.phpを手動作成し、以下のように編集してください。

resources/views/attendances/index.blade.php
@extends('master')

@section('content')
    <nav class="navbar navbar-findcond">
        <div class="collapse navbar-collapse" id="navbar">
            <ul class="nav navbar-nav navbar-right">
                <li class="active">
                    <a href="#">出席登録画面へ</a>
                </li>
            </ul>
        </div>
    </nav>
    
    <div class="row">
        <div class="col-md-12">
            <attendances></attendances> <!-- *1 -->
        </div>
    </div>
@endsection
*1  vue-loaderによって Vue Componentを呼び出す記述です。コンポーネントが未定義なので、現段階では何も展開されません(後述)。

CSSの追加

resources/assets/sass/app.scssファイルに、以下のスタイルを追加しておいてください。

resources/assets/sass/app.scss
// Fonts
@import url("https://fonts.googleapis.com/css?family=Raleway:300,400,600");

// Variables
@import "variables";

// Bootstrap
@import "~bootstrap-sass/assets/stylesheets/bootstrap";


body {
  background-color: #fff;
}

main {
  max-width: 750px;
  word-wrap: break-word;
}

.global-header {
    background: #222 no-repeat center center;
    background-size: cover;
    margin-bottom: 30px;
  
    .header-text {
        position: relative;
        padding-bottom: 1px;
        height: 100%;
        background-color: rgba(0, 0, 0, 0.4);
        color: rgba(255, 255, 255, 0.9);
  
      h1 {
        margin: 0 auto;
        padding-top: 30px;
        text-align: center;
        font-weight: 200;
  
        a {
            color: rgba(255, 255, 255, 1.0);
            text-decoration: none;
        }
      }
    }
  
    .tag-line {
        margin: 10px auto;
        padding: 0 10px;
        max-width: 430px;
        color: rgba(255, 255, 255, 0.7);
        text-align: center;
        font-weight: 300;
    }
}

.article-list h2 a {
    text-decoration: none;
    transition: color 0.1s linear;
}

.post-meta {
    margin: 5px 0;
}

.post-date {
    color: rgba(0, 0, 0, 0.4);
}

.post-tag a {
    padding: 0 10px;
    border: 1px solid rgba(0, 0, 0, 0.3);
    border-radius: 12px;
    color: rgba(0, 0, 0, 0.3);
    text-decoration: none;
    transition: color,border-color,background-color 0.1s linear;
}

.post-tag a:hover {
    background-color: rgba(0, 0, 0, 1.0);
    color: rgba(255, 255, 255, 1.0);
}

.not-found h1 {
    color: rgba(0, 0, 0, 0.2);
    font-weight: 200;
    font-size: 80px;
}

.not-found {
    text-align: center;
}

.author-info {
    padding: 25px 0;
    border-top: 1px solid rgba(0, 0, 0, 0.2);
    border-bottom: 1px solid rgba(0, 0, 0, 0.2);
}

.author-name {
    margin: 0 0 5px 0;
    font-size: 30px;
}

.author-bio {
    color: rgba(0, 0, 0, 0.5);
}

.author-avatar img {
    max-width: 100px;
    max-height: 100px;
    width: 100px;
    height: 100px;
    border-radius: 50px;
}

.author-contact {
    position: relative;
    display: inline-block;
    padding-top: 10px;
    height: 100px;
    text-align: center;
}

.author-contact a {
    display: inline-block;
    padding: 7px 16px;
    border: 1px solid rgba(0, 0, 0, 0.2);
    border-radius: 3px;
    color: rgba(0, 0, 0, 0.7);
    text-decoration: none;
    font-weight: 300;
    font-size: 13px;
    transition: background-color,border-color,color 0.1s linear;
}

.author-contact a:hover {
    border: 1px solid rgba(51, 122, 182, 1.0);
    background-color: rgba(51, 122, 183, 1.0);
    color: rgba(255, 255, 255, 1.0);
}

.pagination
{
    width: 720px;
    text-align: center;
}

.older-posts,.newer-posts
{
    display: inline!important;
    border: 1px solid #ddd;
    border-radius: 15px;
    text-decoration: none;
    transition: border .3s ease;
    padding: 5px 14px;
}

.page-number
{
    display: inline-block;
    min-width: 100px;
    padding: 2px 0;
}

.newer-posts
{
   float: left;
}

.older-posts
{
    float: right;
}

.older-posts:hover,.newer-posts:hover
{
    color: #889093;
    border-color: #98a0a4;
}

/* Override some of Bootstrap's styles */

pre {
    font-size: 14px;
}

pre code {
    overflow: auto;
    white-space: pre;
    word-wrap: normal;
}

.pager li > a {
   transition: background-color 0.1s linear;
}

blockquote {
   font-style:italic;
}

article section img {
    max-width: 100%;
    height: auto;
    margin: 13px auto;
}

スタイルが反映されたらhttp://homestead.test/にアクセスし、以下のような状態で表示されていることを確認してください。

出席管理のトップ画面のViewを作成した状態の画面キャプチャ

出席登録画面のView作成

ブラウザから出席登録ができるように画面を作成していきます。

ルーティングの追加

app/web.phpに出席登録画面用のルーティングを追加します。

app/web.php
Route::get('/', 'AttendancesController@index')->name('home');
Route::get('attend', 'AttendancesController@create')->name('attend');  // 追加

Controllerにアクションを追加

出席登録画面の表示を行うアクションを追加します。

app/Http/Controllers/AttendancesController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class AttendancesController extends Controller
{
    public function index()
    {
        return view('attendances.index');
    }

    // 追加
    public function create()
    {
        return view('attendances.attend');
    }
}

Viewの作成

resources/views/attendances/attend.blade.phpを手動作成し、以下のように編集します。

resources/views/attendances/attend.blade.php
@extends('master')

@section('content')
    <div class="row">
        <div class="col-md-12">
            <attendance-form></attendance-form> <!-- これも Vue Component -->
        </div>
    </div>
@endsection

ホーム画面の修正

出席登録画面の準備ができたので、ホーム画面のリンクを修正しておきましょう。

resources/views/attendances/index.blade.php
<a href="{{ route('attend') }}">出席登録画面へ</a>

Vue Component の作成

現在、ホーム画面と出席登録画面には何も表示されていない状態なので、Vue Component を作成して出席データを表示できるように作り込んでいきます。

ページネーション用ライブラリの追加とコンポーネント登録

まずは、Vue.js でページネーションを行うためのライブラリをインストールしておきます。

$ npm install vuejs-paginate --save

インストールできたら、resources/assets/app.jsにコンポーネントとして登録しておきます。


// 中略

Vue.component('example-component', require('./components/ExampleComponent.vue'));

// 以下3行を追加
Vue.component('paginate', require('vuejs-paginate'));
Vue.component('attendances', require('./components/Attendances.vue'));
Vue.component('attendance-form', require('./components/AttendanceForm.vue'));

ホーム画面(出席一覧)用のコンポーネントを作成

resources/assets/js/components/Attendances.vueを作成し、以下のように編集してください。

resources/assets/js/components/Attendances.vue
<template>
    <div>
        <div class="panel panel-default" v-for="attendance in attendances">
            <div class="panel-heading">
                <span class="glyphicon glyphicon-user" id="start"></span>
                 {{ attendance.name }} <label id="started">さん</label>
            </div>
            <div class="panel-body">
                <strong>本日の意気込み</strong>
                <p>{{ attendance.body }}</p>
            </div>
            <div class="panel-footer">
                <span class="glyphicon glyphicon-calendar" id="visit"></span> {{ attendance.date }} |
                <span class="glyphicon glyphicon-flag" id="comment"></span>
                <a href="#" id="comments" @click="report(attendance.id)">Report</a>
            </div>
        </div>
        <paginate
                :page-count="pageCount"
                :click-handler="fetch"
                :prev-text="'Prev'"
                :next-text="'Next'"
                :container-class="'pagination'">
        </paginate>
    </div>
</template>

<script>
    export default {
        data() {
            return {
                attendances: [],
                pageCount: 1,
                endpoint: 'api/attendances?page='
            };
        },

        created() {
            this.fetch();
        },

        methods: {
            fetch(page = 1) {
                axios.get(this.endpoint + page)
                    .then(({data}) => {
                        this.attendances = data.data;
                        this.pageCount = data.meta.last_page;
                    });
            },

            report(id) {
                if(confirm('この出席データを通報します。よろしいですか?')) {
                    axios.put('api/attendances/'+id+'/report')
                    .then(response => this.removeAttendance(id));
                }
            },

            removeAttendance(id) {
                this.attendances = _.remove(this.attendances, function (attendance) {
                    return attendance.id !== id;
                });
            }
        }
    }
</script>

Vue.js + axios で、Part.2で作成したAPIの出席一覧エンドポイントにリクエストして出席管理データを取得しています。

出席登録用のコンポーネントを作成

resources/assets/js/components/AttendanceForm.vueを作成し、以下のように編集してください。

resources/assets/js/components/AttendanceForm.vue
<template>
    <div>
        <div class="alert alert-success" v-if="saved">出席登録が完了しました!</div>

        <div class="well well-sm" id="attendance-form">
            <form class="form-horizontal" method="post" @submit.prevent="onSubmit">
                <fieldset>
                    <legend class="text-center">本日の勉強会へ出席登録</legend>

                    <div class="form-group">
                        <label class="col-md-3 control-label" for="name">名前</label>
                        <div class="col-md-9" :class="{'has-error': errors.name}">
                            <input id="name"
                                   v-model="attendance.name"
                                   type="text"
                                   placeholder="あなたのお名前"
                                   class="form-control">
                            <span v-if="errors.name" class="help-block text-danger">{{ errors.name[0] }}</span>
                        </div>
                    </div>

                    <div class="form-group">
                        <label class="col-md-3 control-label" for="email">メールアドレス</label>
                        <div class="col-md-9" :class="{'has-error': errors.email}">
                            <input id="email"
                                   v-model="attendance.email"
                                   type="text"
                                   placeholder="あなたのメールアドレス"
                                   class="form-control">
                            <span v-if="errors.email" class="help-block text-danger">{{ errors.email[0] }}</span>
                        </div>
                    </div>

                    <div class="form-group">
                        <label class="col-md-3 control-label" for="body">意気込み</label>
                        <div class="col-md-9" :class="{'has-error': errors.body}">
                            <textarea class="form-control"
                                      id="body"
                                      v-model="attendance.body"
                                      placeholder="本日の意気込み"
                                      rows="5"></textarea>
                            <span v-if="errors.body" class="help-block text-danger">{{ errors.body[0] }}</span>
                        </div>
                    </div>

                    <div class="form-group">
                        <div class="col-md-12 text-right">
                            <button type="submit" class="btn btn-primary btn-lg">出席する</button>
                        </div>
                    </div>
                </fieldset>
            </form>
        </div>
    </div>
</template>

<script>
    export default {
        data() {
            return {
                errors: [],
                saved: false,
                attendance: {
                    name: null,
                    email: null,
                    body: null,
                }
            };
        },

        methods: {
            onSubmit() {
                this.saved = false;

                axios.post('api/attendances', this.attendance)
                    .then(({data}) => this.setSuccessMessage())
                    .catch(({response}) => this.setErrors(response));
            },

            setErrors(response) {
                this.errors = response.data.errors;
            },

            setSuccessMessage() {
                this.reset();
                this.saved = true;
            },

            reset() {
                this.errors = [];
                this.attendance = {name: null, email: null, body: null};
            }
        }
    }
</script>

Vue.js + axios を使用し、Part.2で作成したAPIの出席登録エンドポイントにリクエストしてデータを登録しています。

動作確認

resources/assets/js/app.jsにコンポーネントを追加したので、コンパイルしていない場合はやっておきましょう(開発環境へのssh接続を忘れないようにしてください)。

$ npm run dev

ホーム画面(出席一覧)の動作確認

http://homestead.test/にアクセスし、出席一覧の表示とページネーションが機能していることを確認してください。

出席一覧表示できるようにした画面キャプチャ

出席登録の動作確認

http://homestead.test/attendにアクセスし、出席登録ができることを確認してください。

出席登録画面のキャプチャ

出席登録成功時の画面キャプチャ

出席登録成功後の出席一覧画面キャプチャ

おわりに

これで全行程が終了となります。お疲れさまでした!

Vue.js 関連はだいぶ端折っていますが、当記事の内容は 公式ドキュメント このへんを読めば理解可能な範囲と思いますので、ぜひ挑戦してみてください。

今回参加した勉強会について

 オトナのプログラミング勉強会」は、熊本で開催しているプログラミングの勉強会です。 基本的に月2回(第1水曜日、第2水曜日)開催となります(2017/12/25現在)。

熊本のコワーキングスペース「 未来会議室」共催のオープンでやっている勉強会ですので、ご参加は完全無料です。
(プログラムを書く人を増やす、繋げることが目的なので、誰でも参加できるというスタンスです)

やはり手を動かす勉強会に参加するのは良い刺激になりますね。私自身も Vue.js 慣れてないのでこれから頑張って勉強していきたいところです。

熊本にいらっしゃる方はぜひ参加してみてくださいー。

PAGETOP