2018.12.10

入門Laravelチュートリアル (6) ToDoアプリのタスク作成機能を作る


この連載記事では、Laravel を使用した Web アプリケーションの開発方法を紹介します。実際に(お決まりの?)ToDo アプリを開発する手順を通して Web 開発のエッセンスを学んでいただけるように書いていきます。取り扱う Laravel のバージョンは現時点で最新の 5.7 です。

👇 電子書籍版も公開しています。

第6章では、タスクの新規作成機能を実装します。

タスク作成ページ

ルーティング

まずはルーティングの設定を行います。タスク作成機能の URL 設計は以下の通りでした。

URL メソッド 処理
/folders/{フォルダID}/tasks/create GET タスク作成ページを表示する。
/folders/{フォルダID}/tasks/create POST タスク作成処理を実行する。

routes/web.php に以下の2行を追記します。

web.php
Route::get('/folders/{id}/tasks/create', 'TaskController@showCreateForm')->name('tasks.create');
Route::post('/folders/{id}/tasks/create', 'TaskController@create');

パターンは前章で実装したフォルダ作成機能と一緒ですね。

フォームを表示する

入力フォームを表示するルートを実装します。

コントローラー

app/Http/Controllers/TaskController.phpshowCreateForm メソッドを追加します。

TaskController.php
/**
 * GET /folders/{id}/tasks/create
 */
public function showCreateForm(int $id)
{
    return view('tasks/create', [
        'folder_id' => $id
    ]);
}

テンプレートで form 要素の action 属性としてタスク作成 URL(/folders/{id}/tasks/create)を作るためにフォルダの ID が必要なので、コントローラーメソッドの引数で受け取って view 関数でテンプレートに渡しています。

テンプレート

resources/views/tasks/create.blade.php を以下の内容で作成してください。

create.blade.php
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>ToDo App</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
  <link rel="stylesheet" href="https://npmcdn.com/flatpickr/dist/themes/material_blue.css">
  <link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<header>
  <nav class="my-navbar">
    <a class="my-navbar-brand" href="/">ToDo App</a>
  </nav>
</header>
<main>
  <div class="container">
    <div class="row">
      <div class="col col-md-offset-3 col-md-6">
        <nav class="panel panel-default">
          <div class="panel-heading">タスクを追加する</div>
          <div class="panel-body">
            @if($errors->any())
              <div class="alert alert-danger">
                @foreach($errors->all() as $message)
                  <p>{{ $message }}</p>
                @endforeach
              </div>
            @endif
            <form action="{{ route('tasks.create', ['id' => $folder_id]) }}" method="POST">
              @csrf
              <div class="form-group">
                <label for="title">タイトル</label>
                <input type="text" class="form-control" name="title" id="title" value="{{ old('title') }}" />
              </div>
              <div class="form-group">
                <label for="due_date">期限</label>
                <input type="text" class="form-control" name="due_date" id="due_date" value="{{ old('due_date') }}" />
              </div>
              <div class="text-right">
                <button type="submit" class="btn btn-primary">送信</button>
              </div>
            </form>
          </div>
        </nav>
      </div>
    </div>
  </div>
</main>

<script src="https://npmcdn.com/flatpickr/dist/flatpickr.min.js"></script>
<script src="https://npmcdn.com/flatpickr/dist/l10n/ja.js"></script>
<script>
  flatpickr(document.getElementById('due_date'), {
    locale: 'ja',
    dateFormat: "Y/m/d",
    minDate: new Date()
  });
</script>
</body>
</html>

HTML の部分は新しい知識はありません。前章までに扱った知識で理解できるでしょう。

  • form の action 属性には route 関数
  • @csrf
  • 入力エラーメッセージの表示欄
  • 入力エラーで戻ってきたときのために value 属性に old 関数

JavaScript ライブラリ

この章で初めて登場するのが JavaScript です。日付(期限日)の入力欄で flatpickr というライブラリを使います。このライブラリによって以下のようなキレイな日付選択機能が実装できます。

flatpickr

flatpickr を使用するために、4つのファイルを読み込んでいます。

<!-- デフォルトのスタイルシート -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<!-- ブルーテーマの追加スタイルシート -->
<link rel="stylesheet" href="https://npmcdn.com/flatpickr/dist/themes/material_blue.css">
<!-- flatpickrスクリプト -->
<script src="https://npmcdn.com/flatpickr/dist/flatpickr.min.js"></script>
<!-- 日本語化のための追加スクリプト -->
<script src="https://npmcdn.com/flatpickr/dist/l10n/ja.js"></script>

スタイルシートは head 内で、スクリプトは body の一番最後で読み込みます。さらに flatpickr の機能を有効化します。第一引数に flatpickr で日付選択を行わせたい要素を指定し、第二引数にオプションを渡します。

flatpickr(document.getElementById('due_date'), {
  locale: 'ja',
  dateFormat: "Y/m/d",
  minDate: new Date()
});

ここでは言語設定(曜日を月火水…と日本語表記するため)と日付表記のフォーマット、また本日日付よろ若い日付(過去)を入力できないようにオプションで指定しました。

ここまでできたらブラウザでタスク作成ページ(/folders/1/tasks/create)を確認してみましょう。フォームは表示されているでしょうか。日付選択はできていますか。

レイアウトでテンプレートを整理する

ここでテンプレートを整理するために、Blade のレイアウト機能を紹介します。今までタスク一覧ページ、フォルダ作成ページ、タスク作成ページと3枚のテンプレートファイルを作成しましたが、重複する箇所がいくつかありましたね。head 要素や header 要素です。このような重複はレイアウトファイルにまとめることができます。

レイアウトファイル

まずはレイアウト resources/views/layout.blade.php を以下の内容で作成してください。

layout.blade.php
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>ToDo App</title>
  @yield('styles')
  <link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<header>
  <nav class="my-navbar">
    <a class="my-navbar-brand" href="/">ToDo App</a>
  </nav>
</header>
<main>
  @yield('content')
</main>
@yield('scripts')
</body>
</html>

レイアウトとは枠組み、骨組みのような意味合いでしょうか。ページごとに変わらない部分だけを記述します。ページごとに変化する部分は @yield で穴埋めにしています。いろいろ説明するよりページのテンプレートを見たほうが分かりやすいと思いますので、まずは先ほど作成したタスク作成ページのテンプレート resources/views/tasks/create.blade.php を以下の通りに編集してください。

タスク作成ページテンプレート

tasks/create.blade.php
@extends('layout')

@section('styles')
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
  <link rel="stylesheet" href="https://npmcdn.com/flatpickr/dist/themes/material_blue.css">
@endsection

@section('content')
  <div class="container">
    <div class="row">
      <div class="col col-md-offset-3 col-md-6">
        <nav class="panel panel-default">
          <div class="panel-heading">タスクを追加する</div>
          <div class="panel-body">
            @if($errors->any())
              <div class="alert alert-danger">
                @foreach($errors->all() as $message)
                  <p>{{ $message }}</p>
                @endforeach
              </div>
            @endif
            <form action="{{ route('tasks.create', ['id' => $folder_id]) }}" method="POST">
              @csrf
              <div class="form-group">
                <label for="title">タイトル</label>
                <input type="text" class="form-control" name="title" id="title" value="{{ old('title') }}" />
              </div>
              <div class="form-group">
                <label for="due_date">期限</label>
                <input type="text" class="form-control" name="due_date" id="due_date" value="{{ old('due_date') }}" />
              </div>
              <div class="text-right">
                <button type="submit" class="btn btn-primary">送信</button>
              </div>
            </form>
          </div>
        </nav>
      </div>
    </div>
  </div>
@endsection

@section('scripts')
  <script src="https://npmcdn.com/flatpickr/dist/flatpickr.min.js"></script>
  <script src="https://npmcdn.com/flatpickr/dist/l10n/ja.js"></script>
  <script>
    flatpickr(document.getElementById('due_date'), {
      locale: 'ja',
      dateFormat: "Y/m/d",
      minDate: new Date()
    });
  </script>
@endsection

このテンプレートファイルは以下の構造から成り立っています。

@extends('layout')

@section('styles')
  <!-- ... -->
@endsection

@section('content')
  <!-- ... -->
@endsection

@section('scripts')
  <!-- ... -->
@endsection

冒頭の @extends('layout') で先ほど作成した resources/views/layout.blade.php をレイアウトファイルとして使用することを宣言しています。@extends の引数には resources/views からの相対パスでファイル名(.blade.php は除く)を記述します。

続いて @section@endsection のブロックが3つあります。それぞれのブロックには名前がついていますね。stylescontentscripts です。これはレイアウトファイルの @yield の名前に対応しています。つまり @yield('abc') の部分が @section('abc')@endsection で置き換わって HTML が作成されるということです。

タスク一覧ページとフォルダ作成ページのテンプレートもレイアウトを使う形に書き換えます。

タスク一覧ページテンプレート

tasks/index.blade.php
@extends('layout')

@section('content')
  <div class="container">
    <div class="row">
      <div class="col col-md-4">
        <nav class="panel panel-default">
          <div class="panel-heading">フォルダ</div>
          <div class="panel-body">
            <a href="{{ route('folders.create') }}" class="btn btn-default btn-block">
              フォルダを追加する
            </a>
          </div>
          <div class="list-group">
            @foreach($folders as $folder)
              <a
                  href="{{ route('tasks.index', ['id' => $folder->id]) }}"
                  class="list-group-item {{ $current_folder_id === $folder->id ? 'active' : '' }}"
              >
                {{ $folder->title }}
              </a>
            @endforeach
          </div>
        </nav>
      </div>
      <div class="column col-md-8">
        <div class="panel panel-default">
          <div class="panel-heading">タスク</div>
          <div class="panel-body">
            <div class="text-right">
              <a href="#" class="btn btn-default btn-block">
                タスクを追加する
              </a>
            </div>
          </div>
          <table class="table">
            <thead>
            <tr>
              <th>タイトル</th>
              <th>状態</th>
              <th>期限</th>
              <th></th>
            </tr>
            </thead>
            <tbody>
            @foreach($tasks as $task)
              <tr>
                <td>{{ $task->title }}</td>
                <td>
                  <span class="label {{ $task->status_class }}">{{ $task->status_label }}</span>
                </td>
                <td>{{ $task->formatted_due_date }}</td>
                <td><a href="#">編集</a></td>
              </tr>
            @endforeach
            </tbody>
          </table>
        </div>
      </div>
    </div>
  </div>
@endsection

フォルダ作成ページテンプレート

folders/create.blade.php
@extends('layout')

@section('content')
  <div class="container">
    <div class="row">
      <div class="col col-md-offset-3 col-md-6">
        <nav class="panel panel-default">
          <div class="panel-heading">フォルダを追加する</div>
          <div class="panel-body">
            @if($errors->any())
              <div class="alert alert-danger">
                @foreach($errors->all() as $message)
                  <p>{{ $message }}</p>
                @endforeach
              </div>
            @endif
            <form action="{{ route('folders.create') }}" method="post">
              @csrf
              <div class="form-group">
                <label for="title">フォルダ名</label>
                <input type="text" class="form-control" name="title" id="title" value="{{ old('title') }}" />
              </div>
              <div class="text-right">
                <button type="submit" class="btn btn-primary">送信</button>
              </div>
            </form>
          </div>
        </nav>
      </div>
    </div>
  </div>
@endsection

タスク一覧ページとフォルダ作成ページには追加のスタイルシートやスクリプトはありません。そういう場合は @section 自体を省略すれば OK です。

ここまでできたらブラウザで見た目が変わっていないことを確認しましょう。

タスクを保存する

タスク作成機能を実装します。

バリデーション

まずはバリデーションのための FormRequest クラスから作成します。

$ php artisan make:request CreateTask

雛形 app/Http/Requests/CreateTask.php が作成されるので、以下の内容に編集してください。

CreateTask.php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class CreateTask 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 [
            'title' => 'required|max:100',
            'due_date' => 'required|date|after_or_equal:today',
        ];
    }

    public function attributes()
    {
        return [
            'title' => 'タイトル',
            'due_date' => '期限日',
        ];
    }

    public function messages()
    {
        return [
            'due_date.after_or_equal' => ':attribute には今日以降の日付を入力してください。',
        ];
    }
}

title のバリデーションルールはフォルダ作成のときと同様ですね。due_date のルールには date(日付を表す値であること)と after_or_equal(特定の日付と同じまたはそれ以降の日付であること)を使用しています。after_or_equal の引数として today を指定することにより今日を含んだ未来日だけを許容します(タスクの期限日が過去だとおかしいですよね)。

また CreateTask では messages メソッドも実装しています。このメソッドは FormRequest クラス単位でエラーメッセージするために定義します。

[
    // キーでメッセージが表示されるべきルールを指定する。
    // ドット区切りで左側が項目、右側がルールを意味する。
    'due_date.after_or_equal' => ':attribute には今日以降の日付を入力してください。',
]

due_dateafter_or_equal ルールに違反した場合は、値に指定されたメッセージを出力するという意味です。一般的なルールについては validation.php に記述しますが、messages メソッドでは個別の FormRequest クラスの内部でのみ有効なメッセージを定義できます。

ちなみにここで after_or_equal ルールのメッセージ定義に validation.php ではなく messages メソッドを使用した理由は...

'after_or_equal' => 'The :attribute must be a date after or equal to :date.',

上記のルールのうち :date には after_or_equal の引数、つまり today が入るのですが、これをうまく「今日」などの日本語にできなかったからです。

jp/validation.php には date ルールの翻訳を追加します。

validation.php
'date'                 => ':attribute には日付を入力してください。',

コントローラー

コントローラーを実装します。
まずは TaskController の冒頭で CreateTask コントローラーをインポートします。

TaskController.php
use App\Http\Requests\CreateTask;

次に create メソッドを追加してください。

TaskController.php
public function create(int $id, CreateTask $request)
{
    $current_folder = Folder::find($id);

    $task = new Task();
    $task->title = $request->title;
    $task->due_date = $request->due_date;

    $current_folder->tasks()->save($task);

    return redirect()->route('tasks.index', [
        'id' => $current_folder->id,
    ]);
}

ここでのポイントは、リレーションを活かしたデータの保存方法です。

$current_folder->tasks()->save($task);

上の記述により、$current_folder に紐づくタスクを作成しています。

テンプレート

最後にタスク一覧ページ resources/views/tasks/index.blade.php のリンクを編集します。

index.blade.php
<a href="{{ route('tasks.create', ['id' => $current_folder_id]) }}" class="btn btn-default btn-block">
  タスクを追加する
</a>

ここまでできたらブラウザから確認しましょう。うまくタスクデータを作成できましたか?

期限日のバリデーションをテストする

タスクの作成機能は実装できました!ただ、実装したものの動作を確認できていない箇所があります。それは期限日のバリデーションです。期限日の入力欄は JavaScript で制御しているため、自由なデータを入力できないので、不正なデータをきちんと弾けているかの確認もできません。

そこで、画面から確認できない機能についてはテストコードで正しさを確かめます。

準備

まずはテストを書くまえの準備として、テストのときは開発用のデータベースとは別に、メモリ上の(つまりテストコードが実行し終わると消える)データベースを使う設定を行います。これによりテストを実行する間だけ存在すればいいデータが開発の邪魔になりません。

config/database.phpconnections に以下の設定を追加してください。

database.php
'connections' => [

    // ★ この設定を追加
    'sqlite_testing' => [
        'driver' => 'sqlite',
        'database' => ':memory:',
        'prefix' => '',
    ],

    // 中略
]

Laravel ではテスティングライブラリとして PHPUnit をサポートしています。設定ファイル phpunit.xmlDB_CONNECTION の設定を追加します。

phpunit.xml
<php>
    <env name="APP_ENV" value="testing"/>
    <env name="DB_CONNECTION" value="sqlite_testing"/> <!-- ★ この行を追加-->
    <!-- 中略 -->
</php>

これで準備完了です。コマンドラインからテストコードの雛形を生成します。

$ php artisan make:test TaskTest

tests/Feature/TaskTest.php が作成されたはずです。

テストケースを記述する

TaskTest.php にバリデーションエラーが発生するケースについてのテストを記述します。

TaskTest.php
<?php

namespace Tests\Feature;

use App\Http\Requests\CreateTask;
use Carbon\Carbon;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;

class TaskTest extends TestCase
{
    // テストケースごとにデータベースをリフレッシュしてマイグレーションを再実行する
    use RefreshDatabase;

    /**
     * 各テストメソッドの実行前に呼ばれる
     */
    public function setUp()
    {
        parent::setUp();

        // テストケース実行前にフォルダデータを作成する
        $this->seed('FoldersTableSeeder');
    }

    /**
     * 期限日が日付ではない場合はバリデーションエラー
     * @test
     */
    public function due_date_should_be_date()
    {
        $response = $this->post('/folders/1/tasks/create', [
            'title' => 'Sample task',
            'due_date' => 123, // 不正なデータ(数値)
        ]);

        $response->assertSessionHasErrors([
            'due_date' => '期限日 には日付を入力してください。',
        ]);
    }

    /**
     * 期限日が過去日付の場合はバリデーションエラー
     * @test
     */
    public function due_date_should_not_be_past()
    {
        $response = $this->post('/folders/1/tasks/create', [
            'title' => 'Sample task',
            'due_date' => Carbon::yesterday()->format('Y/m/d'), // 不正なデータ(昨日の日付)
        ]);

        $response->assertSessionHasErrors([
            'due_date' => '期限日 には今日以降の日付を入力してください。',
        ]);
    }
}

タスクの作成にはそもそもフォルダデータが必要なので、各テストメソッドの実行前に呼ばれる setUp メソッドで、3章で作成したシーダーを実行しています。

due_date_should_be_date メソッドは due_date が日付ではないときに入力エラーとなることを確かめるメソッドです。due_date_should_not_be_past メソッドは due_date が過去日付であるときに入力エラーとなることを確かめるメソッドです。

それぞれのテストメソッドには冒頭のコメントで @test と記述しています。テストメソッドとして認識されるために必要なので注意しましょう。

テストメソッドの内容を簡単に説明すると、まず TestCase クラスに用意されている post メソッドでタスク作成ルートにアクセスしています。

// 第一引数 … アクセスする URL
// 第二引数 … 入力値
$response = $this->post('/folders/1/tasks/create', [
    'title' => 'Sample task',
    'due_date' => 123, // 不正なデータ(数値)
]);

このように、TestCase クラスの機能を使えば画面へのアクセスをコードで再現できます。入力値にはそれぞれ数値と昨日日付という、エラーになるべきデータを指定しています。そしてレスポンスを受け取った $response 変数に対して、assertSessionHasErrors メソッドでエラーメッセージがあることを確かめています。

$response->assertSessionHasErrors([
    'due_date' => '期限日 には日付を入力してください。',
]);

ではコマンドラインでテストを実行してみましょう。

$ ./vendor/bin/phpunit ./tests/Feature/TaskTest.php --testdox

このように、どちらのテストにもチェックマークが表示されれば OK です。

PHPUnit

これで不正な値を送信したときにバリデーションがうまくエラーを返すことが確認できました 🎉

☄ ☄ ☄

第6章はこれでおしまいです。
ここまでのソースコードはリポジトリ(chapter06 ブランチ)を参照してください。

次の章ではタスクを編集する機能を実装します。

連載記事