2018.12.10

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


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

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

第7章では、タスクの編集機能を実装します。

タスク編集ページ

ルーティング

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

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

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

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

もうおなじみのパターンですね。

フォームを表示する

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

コントローラー

app/Http/Controllers/TaskController.phpshowEditForm を追加します。

TaskController.php
/**
 * GET /folders/{id}/tasks/{task_id}/edit
 */
public function showEditForm(int $id, int $task_id)
{
    $task = Task::find($task_id);

    return view('tasks/edit', [
        'task' => $task,
    ]);
}

編集対象のタスクデータを取得してテンプレートに渡しています。編集画面では、画面が表示された時にその時点でのタスクの各項目の値が入力欄にすでに入っているべきでしょう。テンプレートでフォームを作成するときに各 input 要素の value に値を入れるためにタスクを渡します。

テンプレート

resources/views/tasks/edit.blade.php を作成し、以下の内容を記述してください。

edit.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.edit', ['id' => $task->folder_id, 'task_id' => $task->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') ?? $task->title }}" />
              </div>
              <div class="form-group">
                <label for="status">状態</label>
                <select name="status" id="status" class="form-control">
                  @foreach(\App\Task::STATUS as $key => $val)
                    <option
                        value="{{ $key }}"
                        {{ $key == old('status', $task->status) ? 'selected' : '' }}
                    >
                      {{ $val['label'] }}
                    </option>
                  @endforeach
                </select>
              </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') ?? $task->formatted_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

ここでのポイントは各入力項目の組み立て箇所でしょう。

タイトル

<input type="text" class="form-control" name="title" id="title"
    value="{{ old('title', $task->title) }}" />

入力欄の value に old('title', $task->title) を指定しています。old 関数は直前の入力値を取得すると説明しましたが、第二引数を指定するとそれがデフォルト値になります。つまり「直前の入力値」がない場合は $task->title が出力されます。「直前の入力値」がない場合というのは、ページを最初に表示したときですね。

このようにして、編集ページを開いたときはタスクを作成したときのタイトルが入力欄に入っていて、値を変更して送信したが入力エラーになって戻ってきたときは変更後の値が入っているという挙動を実現しています。

状態

<select name="status" id="status" class="form-control">
  @foreach(\App\Task::STATUS as $key => $val)
    <option
        value="{{ $key }}"
        {{ $key == old('status', $task->status) ? 'selected' : '' }}
    >
      {{ $val['label'] }}
    </option>
  @endforeach
</select>

状態の入力欄はセレクトボックスにするためタイトルとは異なった記述になりますが、実現したい挙動は上で説明した内容と同じです。

まず、Task モデルで定義した配列定数 STATUS@foreach でループして option 要素を出力しています。option 要素の value に配列のキー(1, 2, 3)を、タグで囲んだ表示文字列には 'label' の値を出力します。

Task.php
const STATUS = [
    1 => [ 'label' => '未着手', 'class' => 'label-danger' ],
    2 => [ 'label' => '着手中', 'class' => 'label-info' ],
    3 => [ 'label' => '完了', 'class' => '' ],
];

そして選択状態を実現するのが以下のコードです。

{{ $key == old('status', $task->status) ? 'selected' : '' }}

セレクトボックスは、selected 属性の置かれた option 要素が初期表示で選択状態となります。そこでループしたキーと old('status', $task->status)(直前の入力値またはデータベースに登録済みの値)を比べて、一致する場合に option タグの中に 'selected' を出力しています。

これによってセレクトボックスでも以下の挙動を実現しています。

編集ページを開いたときはタスクを作成したときのタイトルが入力欄に入っていて、値を変更して送信したが入力エラーになって戻ってきたときは変更後の値が入っているという挙動

期限日

<input type="text" class="form-control" name="due_date" id="due_date"
    value="{{ old('due_date', $task->formatted_due_date) }}" />

期限日はタイトル入力欄と同じ記述内容です。

ここまでできたらブラウザでフォームがきちんと表示されているか一度確認してみましょう。

テンプレートを部品化する

タスク作成ページとタスク編集ページは似ていますが、明らかに重複している箇所があります。flatpickr のスタイルとスクリプトを読み込む箇所です。その部分を別のテンプレートとして部品化して重複を排除します。

share ディレクトリに共有パーツを入れることにします。share の下にさらに flatpickr ディレクトリを作成し、styles.blade.phpscripts.blade.php を作成します。

$ mkdir -p ./resources/views/share/flatpickr
$ touch ./resources/views/share/flatpickr/styles.blade.php
$ touch ./resources/views/share/flatpickr/scripts.blade.php

styles.blade.php の内容は以下の通りです。

styles.blade.php
<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">

scripts.blade.php の内容は以下の通りです。

scripts.blade.php
<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>

作成した共有部品を読み込むように edit.blade.php を編集します。

edit.blade.php
@extends('layout')

@section('styles')
  @include('share.flatpickr.styles')
@endsection

@section('content')
  <!-- 変更なし -->
@endsection

@section('scripts')
  @include('share.flatpickr.scripts')
@endsection

@include で共通部品を読み込んでいます。

タスク作成ページの create.blade.php も同様に編集しましょう。

タスクを編集する

タスクの編集処理を実装します。

バリデーション

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

$ php artisan make:request EditTask

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

EditTask.php
<?php

namespace App\Http\Requests;

use App\Task;
use Illuminate\Validation\Rule;

class EditTask extends CreateTask
{
    public function rules()
    {
        $rule = parent::rules();

        $status_rule = Rule::in(array_keys(Task::STATUS));

        return $rule + [
            'status' => 'required|' . $status_rule,
        ];
    }

    public function attributes()
    {
        $attributes = parent::attributes();

        return $attributes + [
            'status' => '状態',
        ];
    }

    public function messages()
    {
        $messages = parent::messages();

        $status_labels = array_map(function($item) {
            return $item['label'];
        }, Task::STATUS);

        $status_labels = implode('、', $status_labels);

        return $messages + [
            'status.in' => ':attribute には ' . $status_labels. ' のいずれかを指定してください。',
        ];
    }
}

EditTask クラスは CreateTask クラスを継承しています。タスクの作成と編集では状態欄の有無が異なるだけでタイトルと期限日は同一なので重複を避けるために継承を用いました。

rules

状態欄には入力値が許可リストに含まれているか検証する in ルールを使用します。

許可リストは array_keys(Task::STATUS) で配列として取得できるので、Rule クラスの in メソッドを使ってルールの文字列を作成しています。

$status_rule = Rule::in(array_keys(Task::STATUS));
// -> 'in(1, 2, 3)' を出力する

結果として出力されるルールは以下のようになります。

'status' => 'required|in(1, 2, 3)',

親クラス CreateTaskrules メソッドの結果と合体したルールリストを返却します。

attributes

親クラス CreateTaskattributes メソッドの結果と合体した属性名リストを返却します。

messages

ここでは Task::STATUS から status.in ルールのメッセージを作成しています。Task::STATUS の各要素から label キーの値のみ取り出して作った配列をさらに句読点でくっつけて文字列を作成しています。最終的に「状態 には 未着手、着手中、完了 のいずれかを指定してください。」というメッセージが出来上がります。

コントローラー

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

TaskController.php
public function edit(int $id, int $task_id, EditTask $request)
{
    // 1
    $task = Task::find($task_id);

    // 2
    $task->title = $request->title;
    $task->status = $request->status;
    $task->due_date = $request->due_date;
    $task->save();

    // 3
    return redirect()->route('tasks.index', [
        'id' => $task->folder_id,
    ]);
}
  1. まずリクエストされた ID でタスクデータを取得します。これが編集対象となります。
  2. 編集対象のタスクデータにん入力値を詰めて save します。
  3. 最後に編集対象のタスクが属するタスク一覧画面へリダイレクトしています。

テンプレート

最後にタスク一覧テーブルの編集リンクの href を記述します。

index.blade.html
<a href="{{ route('tasks.edit', ['id' => $task->folder_id, 'task_id' => $task->id]) }}">
  編集
</a>

これでタスクの編集機能は完成です!
ブラウザからタスクを編集できることを確認してみてください。

状態のバリデーションをテストする

さて前章と同様、この章でも画面からは確認できない機能があります。状態セレクトボックスの値が不正だった場合のバリデーションチェックです。テストコードを書いて確認しましょう。

tests/Feature/TaskTest.php に以下のメソッドを追加します。

TaskTest.php
/**
  * 状態が定義された値ではない場合はバリデーションエラー
  * @test
  */
public function status_should_be_within_defined_numbers()
{
    $this->seed('TasksTableSeeder');

    $response = $this->post('/folders/1/tasks/1/edit', [
        'title' => 'Sample task',
        'due_date' => Carbon::today()->format('Y/m/d'),
        'status' => 999,
    ]);

    $response->assertSessionHasErrors([
        'status' => '状態 には 未着手、着手中、完了 のいずれかを指定してください。',
    ]);
}

テストコードを実行してバリデーションが正しく動作することを確かめましょう。

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

✨ ✨ ✨

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

次の章では認証機能を実装します。会員登録とログイン機能に加えて、ログインしたユーザーは自分のフォルダのタスクだけを閲覧できる機能も実装します。

連載記事