Laravel6.0 で バリデーションをサーバ側とクライアント側(bootstrap4 + jquery validate)で行ってみたメモ

## 概要

[前回](https://qiita.com/hibohiboo/items/18810b64b8d92940d3b2)は一覧と作成ができることを確認した。
今回は、bootstrap4とjquery validationを使ったフロントのバリデーションも行った。

## Form用ライブラリのインストール

bladeでFormを扱うのが楽になるライブラリを導入した。

```
composer require laravelcollective/html
```

## Laravelでのバリデーション
### migration用ファイルの作成

今回作成するマスタのテーブルを作成するモデルを作成。

```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateCouponsTable extends Migration
{
    public function up()
    {
        Schema::create('coupons', function (Blueprint $table) {
            $table->string('id');
            $table->integer('type')->default(config('const.Coupons.TYPE_GET', 1))->comment('1:取得, 2:使用');
            $table->string('name');
            $table->integer('point')->default(0);
            $table->boolean('is_display')->default(true);
            $table->timestamp('created_at')->useCurrent();
            $table->timestamp('updated_at')->default(DB::raw('CURRENT_TIMESTAMP on update CURRENT_TIMESTAMP'));

            $table->primary('id');
        });
    }

    public function down()
    {
        Schema::dropIfExists('coupons');
    }
}
```

### モデルの作成

以下の主キーに関する設定を行わないと、string型のidをHTMLで表示するときに0になる。

```php:app/Models/Coupon.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;

class Coupon extends Model
{
    protected $keyType = 'string';
    public $incrementing = false; 
}
```

### ルーティングの設定

```diff:routes/web.php
    Route::put('/tag/{tag}','Admin\TagController@update');


+    Route::get('/coupon','Admin\CouponController@index');
+    Route::post('/coupon','Admin\CouponController@store');
+    Route::put('/coupon/{coupon}','Admin\CouponController@update');
+    Route::delete('/coupon/{coupon}','Admin\CouponController@destroy');
```

### コントローラの設定

indexではページネーションの設定を行っている。
storeではサーバサイドのバリデーションを作成時に行っている。
idがキーなので、一意であることを確認している。
updateでは上記のバリデーションは不要。
チェックボックスのデータはチェックがないときはnullになるので、
チェックがあるときの値と比較を行ってbool値をDBに格納するようにした。

```php:app/Http/Controllers/Admin/CouponController.php
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Coupon;
use Illuminate\Http\Request;

class CouponController extends Controller
{
    public function index()
    {
        $coupons = Coupon::orderBy('updated_at', 'DESC')->paginate(config('const.Paginator.PER_PAGE'));
        return view('admin/coupons', [
            'coupons' => $coupons,
            'types' => [ config('const.Coupons.TYPE_GET', 1) => '取得', 
                         config('const.Coupons.TYPE_USE', 2) => '使用']
        ]);
    }

    public function store(Request $request)
    {
        // bail:最初のバリデーションに失敗したら、残りのバリデーションルールの判定を停止
        $validatedData = $request->validate([
            'id' => 'bail|required|unique:coupons|max:255',
            'name' => 'bail|required|max:255',
            'point' => 'required|integer',
            'type' => 'required|integer'
        ]);

        $coupon = new Coupon();
        $coupon->id = $request->id;
        $coupon->name = $request->name;
        $coupon->point = $request->point;
        $coupon->type = $request->type;
        $coupon->is_display = $request->is_display === '1';
        $coupon->save();

        return redirect('/coupon');
    }

    public function update(Request $request, Coupon $coupon)
    {
        $validatedData = $request->validate([
            'name' => 'bail|required|max:255',
            'point' => 'required|integer',
            'type' => 'required|integer'
        ]);

        $coupon->name = $request->name;
        $coupon->point = $request->point;
        $coupon->type = $request->type;
        $coupon->is_display =  $request->is_display === '1';
        $coupon->save();

        return redirect('/coupon');
    }
    public function destroy(Coupon $coupon)
    {
        $coupon->delete();

        return redirect('/coupon');
    }
}
```

### View

<details><summary>長いので非表示</summary><div>

```php:resources/views/admin/coupon.blade.php
@extends('layouts.app')
@section('title') クーポン管理 @endsection
@section('admin')
  <li class="nav-item"><a class="nav-link" href="{{ url('/admin') }}">管理者ダッシュボード</span></a></li>
@endsection

@section('content')
    <div id="editModal" class="modal fade" tabindex="-1" role="dialog">
    <form id="edit-form" action="dummy{{--jsで置き換え--}}" method="POST">
        @csrf
        @method('PUT')
        <div class="modal-dialog" role="document">
          <div class="modal-content">
            <div class="modal-header">
              <h5 class="modal-title">編集</h5>
              <button type="button" class="close" data-dismiss="modal" aria-label="閉じる">
                <span aria-hidden="true">&times;</span>
              </button>
            </div>{{-- /.modal-header --}}
            <div class="modal-body">
                {{-- クーポン名 --}}
                <div class="form-group row">
                    <label for="edit-coupon-name" class="col-sm-3 col-form-label">クーポン名</label>

                    <div class="col-sm-6">
                        <input type="text" name="name" id="edit-coupon-name" class="form-control" value="{{ old('coupon') }}" required>
                        <div class="valid-feedback">
                            入力済み!
                        </div>
                    </div>
                </div>
                {{-- クーポン種別 --}}
                <div class="form-group row">
                    <label for="edit-coupon-type" class="col-sm-3 col-form-label">クーポン種別</label>

                    <div class="col-sm-6">
                          {{Form::select('type', $types, old('coupon')) }}
                    </div>
                </div>
                {{-- クーポン値 --}}
                <div class="form-group row">
                    <label for="edit-coupon-point" class="col-sm-3 col-form-label">値</label>

                    <div class="col-sm-6">
                        <input type="number" name="point" id="edit-coupon-point" class="form-control" value="{{ old('coupon', 0) }}" required>
                    </div>
                </div>
                {{-- 表示フラグ --}}
                <div class="form-group row form-check">
                    <div class="col-sm-6">
                        {{Form::checkbox('is_display', true, true, ['class' => 'form-check-input', 'id'=>'edit-coupon-is_display'])}}
                        <label for="edit-coupon-is_display" class="form-check-label">表示</label>
                      </div>
                </div>
            </div>{{-- /.modal-body --}}
            <div class="modal-footer">
              <button type="button" class="btn btn-secondary" data-dismiss="modal">閉じる</button>
              <button type="submit" class="btn btn-primary">変更を保存</button>
            </div>{{-- /.modal-footer --}}
          </div>{{-- /.modal-content --}}
        </div>{{-- /.modal-dialog --}}
      </form>
    </div>{{-- /.modal --}}

    {{-- 削除モーダル--}}
    <div id="deleteModal" class="modal fade" tabindex="-1" role="dialog">
        <form id="delete-form" action="dummy" method="POST">
          @csrf
          @method('DELETE')
          <div class="modal-dialog" role="document">
            <div class="modal-content">
              <div class="modal-header">
                <h5 class="modal-title">削除</h5>
                <button type="button" class="close" data-dismiss="modal" aria-label="閉じる">
                  <span aria-hidden="true">&times;</span>
                </button>
              </div>{{-- /.modal-header --}}
              <div class="modal-body">
                <mark id="delete-item-name">{{-- 削除アイテム名 --}}</mark>を削除します。よろしいですか？
              </div>{{-- /.modal-body --}}
              <div class="modal-footer">
                <button type="button" class="btn btn-secondary" data-dismiss="modal">閉じる</button>
                <button type="submit" class="btn btn-danger">削除</button>
              </div>{{-- /.modal-footer --}}
            </div>{{-- /.modal-content --}}
          </div>{{-- /.modal-dialog --}}
        </form>
      </div>{{-- /.modal --}}

    {{-- ここからメイン --}}
    <main class="container">
        <div class="col-sm-offset-2 col-sm-8">
            <div class="card">
                <div class="card-header">新しいクーポン</div>
                <div class="card-body">
                    {{-- バリデーションエラーの表示 --}}
                    @include('common.errors')

                    {{-- 新クーポンフォーム --}}
                    <form id="create-form" action="{{ url('coupon')}}" method="POST">
                        @csrf
                        {{-- クーポンID --}}
                        <div class="form-group row">
                            <label for="coupon-id" class="col-sm-3 col-form-label" >クーポンID</label>

                            <div class="col-sm-6">
                                <input type="text" name="id" id="coupon-id" class="form-control" aria-describedby="idHelpBlock" value="{{ old('coupon') }}" required>
                                <small id="idHelpBlock" class="form-text text-muted">IDは半角英数字と-_で、重複しないものを入力してください</small>
                            </div>
                        </div>

                        {{-- クーポン名 --}}
                        <div class="form-group row">
                            <label for="coupon-name" class="col-sm-3 col-form-label">クーポン名</label>

                            <div class="col-sm-6">
                                <input type="text" name="name" id="coupon-name" class="form-control" value="{{ old('coupon') }}" required>
                                <div class="valid-feedback">
                                    入力済み!
                                </div>
                            </div>
                        </div>
                        {{-- クーポン種別 --}}
                        <div class="form-group row">
                            <label for="coupon-type" class="col-sm-3 col-form-label">クーポン種別</label>

                            <div class="col-sm-6">
                                 {{Form::select('type', $types, old('coupon')) }}
                            </div>
                        </div>
                        {{-- クーポン値 --}}
                        <div class="form-group row">
                            <label for="coupon-point" class="col-sm-3 col-form-label">値</label>

                            <div class="col-sm-6">
                                <input type="number" name="point" id="coupon-point" class="form-control" value="{{ old('coupon', 0) }}" required>
                            </div>
                        </div>
                        {{-- 表示フラグ --}}
                        <div class="form-group row form-check">
                            <div class="col-sm-6">
                                  
                                {{Form::checkbox('is_display', true, true, ['class' => 'form-check-input', 'id'=>'coupon-is_display'])}}
                                <label for="coupon-is_display" class="form-check-label">表示</label>
                              </div>
                        </div>
                        {{-- クーポン追加ボタン --}}
                        <div class="form-group row">
                            <div class="col-sm-offset-3 col-sm-6">
                                <button type="submit" class="btn btn-primary">
                                    <i class="fa fa-btn fa-plus"></i> クーポン追加
                                </button>
                            </div>
                        </div>
                    </form>
                </div>
            </div>

            @if (count($coupons) > 0)
                <div class="card">
                    <div class="card-header">クーポン一覧</div>
                    <div class="card-body">
                        <table class="table table-striped coupon-table">
                            {{-- テーブルヘッダ --}}
                            <thead>
                                <tr>
                                    <th>ID</th>
                                    <th>クーポン</th>
                                    <th>値</th>
                                    <th>種別</th>
                                    <th>表示</th>
                                    <th>更新日時</th>
                                    <th>{{-- 更新 --}}&nbsp;</th>
                                    <th>{{-- 削除 --}}&nbsp;</th>
                                </tr>
                            </thead>
                            {{-- テーブル本体 --}}
                            <tbody>
                              @foreach ($coupons as $coupon)
                                <tr>
                                    <td class="table-text">
                                        <div>{{ $coupon->id }}</div>
                                    </td>
                                  <td class="table-text">
                                      <div>{{ $coupon->name }}</div>
                                  </td>
                                  <td class="table-text">
                                      <div>{{ $coupon->point }}</div>
                                  </td>
                                  <td class="table-text">
                                      <div>{{ $types[$coupon->type]  }}</div>
                                  </td>
                                  <td class="table-text">
                                      <div>{{ $coupon->is_display ? '表示' : '隠す'  }}</div>
                                  </td>
                                  <td class="table-text">
                                      <div>{{ $coupon->updated_at->format('Y/m/d H:i:s')  }}</div>
                                  </td>
                                  <td>
                                    <button type="button" class="btn" data-toggle="modal" data-target="#editModal" 
                                            data-action="{{ url('coupon/' . $coupon->id) }}" data-name="{{$coupon->name}}" data-point="{{$coupon->point}}"
                                            data-is_display="{{$coupon->is_display}}" data-type="{{$coupon->type}}"
                                    >
                                    <i class="fa fa-btn fa-edit"></i>
                                  </button>
                                  </td>
                                  <td>
                                    <button type="button" class="btn btn-danger" data-toggle="modal" data-target="#deleteModal" 
                                      data-action="{{ url('coupon/' . $coupon->id) }}" data-name="{{$coupon->name}}"
                                    >
                                      <i class="fa fa-btn fa-trash"></i> 
                                    </button>
                                  </td>
                                </tr>
                              @endforeach
                            </tbody>
                        </table>
                    </div>
                </div>
            @endif
            {{ $coupons->links() }}
        </div>
    </div>
@endsection

@section('scripts')
<script src="https://cdn.jsdelivr.net/npm/jquery-validation@1.19.1/dist/jquery.validate.min.js" integrity="sha256-sPB0F50YUDK0otDnsfNHawYmA5M0pjjUf4TvRJkGFrI=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery-validation@1.19.1/dist/additional-methods.min.js"></script>
<script src="https://ajax.aspnetcdn.com/ajax/jquery.validate/1.19.1/localization/messages_ja.js"></script>
<script src="{{ mix('js/admin/coupon/index.js') }}"></script>
@endsection
```

</div></details>

前回やっていないのは、ページング用のタグをいれたことくらいか。

```diff
            @endif
+            {{ $coupons->links() }}
        </div>
    </div>
@endsection
```

## クライアントサイドのバリデーション

### ライブラリの追加

viewに以下を追記。
今回は一意のチェックをjquery-validationのremoteで行うので、jqueryをslimではないものにしている。
`TypeError: undefined is not an object (evaluating 'b.apply').  Exception occurred when checking element , check the 'remote' method.`のようなエラーがslimだと出てしまう。
また、正規表現でのチェックをpatternで行うので、additional-methodsライブラリも追加している。

```php:resouces/views/admin/coupon.blade.php

@section('scripts')
<script src="https://cdn.jsdelivr.net/npm/jquery-validation@1.19.1/dist/jquery.validate.min.js" integrity="sha256-sPB0F50YUDK0otDnsfNHawYmA5M0pjjUf4TvRJkGFrI=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery-validation@1.19.1/dist/additional-methods.min.js"></script>
<script src="https://ajax.aspnetcdn.com/ajax/jquery.validate/1.19.1/localization/messages_ja.js"></script>
<script src="{{ mix('js/admin/coupon/index.js') }}"></script>
@endsection
```

### トランスパイル設定

```diff:webpack.mix.js
+ mix.ts('resources/ts/admin/coupon/index.ts', 'public/js/admin/coupon').version();
```


### typescript用の設定の追加

`@types/jquery.validation`はバージョンが1.16で止まっており、最新は1.19であったため使わない。
コンパイルを通すために以下の設定をおこなった。

```diff:tsconfig.json
{
    "compilerOptions": {
        "outDir": "./built/",

        // 省略


+        "typeRoots": ["./resources/ts/types", "node_modules/@types"]
    },

    "include": ["resources/ts/**/*"]
}
```



```ts:resouces/ts/types/index.d.ts
interface JQuery {
    validate(options: any): JQuery;
}
interface JQueryStatic {
    validator: any;
}
```


### バリデーションの設定

検証失敗/成功時に、bootstrap4のクラスを設定するようにしている。

```ts:resources/ts/admin/coupon/index.ts
import { ModalEventHandler } from 'bootstrap';

$.validator.setDefaults({
    debug: false, // trueの場合、デバッグモードになりフォームは送信されない
    onkeyup: false, // 有効の場合はkeyupの度にremoteが走ってしまうため。。
    success: null,
    validClass: 'valid-feedback',
    errorClass: 'invalid-feedback',
    errorElement: 'span',
    errorPlacement: function(error: JQuery, element: JQuery) {
        error.addClass('invalid-feedback');
        element.closest('.form-group').append(error);
    },
    highlight: function(element: HTMLElement, errorClass: string, validClass: string) {
        $(element).addClass('is-invalid');
    },
    unhighlight: function(element: HTMLElement, errorClass: string, validClass: string) {
        $(element).removeClass('is-invalid');
    }
});
const $createForm: JQuery<HTMLFormElement> = $('#create-form');
$createForm
    .submit(event => {
        // bootstrap4のカスタムバリデーション
        const form = event.target;
        if (form.checkValidity() === false) {
            event.preventDefault();
            event.stopPropagation();
        }
        form.classList.add('was-validated');
    })
    .validate({
        rules: {
            id: {
                required: true,
                remote: '/api/coupon/unique',
                pattern: '[a-zA-Z0-9_-]+' // patternを使うにはadditonalの読み込みが必要
            },
            name: { required: true },
            point: { required: true, number: true }
        },
        messages: {
            id: {
                remote: '既に使われているＩＤです',
                pattern: '半角英数字と-_を使用できます'
            }
        }
    });

// 編集
$('#editModal').on('show.bs.modal', function(event: ModalEventHandler<HTMLElement>) {
    const target = event.relatedTarget;
    if (target === undefined) {
        return;
    }
    const $button = $(target); // モーダル切替えボタン
    const action = $button.data('action'); // data-* 属性から情報を抽出
    const name = $button.data('name');
    const point = $button.data('point');
    const type = $button.data('type');
    const is_display = $button.data('is_display') === 1;

    const $modal = $(this);
    $modal.find('#edit-coupon-name').val(name);
    $modal.find('#edit-coupon-type').val(type);
    $modal.find('#edit-coupon-point').val(point);
    $modal.find('#edit-coupon-is_display').prop('checked', is_display);
    $modal.find('#edit-form').attr('action', action);
});

// HTML5標準のエラーメッセージのカスタマイズ
$('#edit-coupon-name').on('invalid', e => {
    const nameInput = e.target as HTMLInputElement;
    if (nameInput.value === '') {
        nameInput.setCustomValidity('名前を入力してください。');
    }
});

// 削除
$('#deleteModal').on('show.bs.modal', function(event: ModalEventHandler<HTMLElement>) {
    const target = event.relatedTarget;
    if (target === undefined) {
        return;
    }
    const $button = $(target); // モーダル切替えボタン
    const action = $button.data('action'); // data-* 属性から情報を抽出
    const name = $button.data('name');

    const $modal = $(this);
    $modal.find('#delete-item-name').text(name);
    $modal.find('#delete-form').attr('action', action);
});
```

### サーバ側でのチェック

jquery.validateのremoteで呼び出され、true/falseを返すAPIを作る。
バリデーションに失敗したらfalseを帰すようにする。

#### ルーティング設定

```diff:routes/api.php
+ Route::get('/coupon/unique','Actions\CouponAction@unique');
```

#### コントローラ

```php:app/Http/Controllers/Actions/CouponAction.php
<?php
namespace App\Http\Controllers\Actions;
use App\Http\Controllers\Controller;
use App\Models\Coupon;
use Illuminate\Http\Request;

class CouponAction extends Controller
{
    /**
     * 存在していなければtrue。存在していればfalseを返す
     */
    public function unique(Request $request)
    {
        $result = true;
        if ($request->has('id')) {
            $result = ! Coupon::where('id', '=', $request->query('id'))->exists();
        }
        return response()->json($result);
    }
}
```


[ここまでのソース](https://github.com/hibohiboo/whitemap/tree/99f157d2e3bb1f292670bb33ee2ea55f45432605/laravel_docker)

## 参考

[Laravel 6.0 バリデーション](https://readouble.com/laravel/6.0/ja/validation.html)
[jQuery Validation](https://jqueryvalidation.org/documentation/)
[jQuery Validation Plugin が使いやすくておすすめ](https://qiita.com/uedatakeshi/items/8b46bf7da1b81aad5650)
[jsdelivr](https://www.jsdelivr.com/package/npm/jquery-validation)
[Laravel 6.0 Blade テンプレート](https://readouble.com/laravel/6.0/ja/blade.html)
[Laravel のフロントエンドに TypeScript を導入する](https://blog.nakamu.life/posts/laravel-typescript)
[Laravel mix の Vue.js を TypeScript にしていく](https://megu-tech.hatenablog.com/entry/2019/08/02/154710)
[jquery-validation](https://github.com/jquery-validation/jquery-validation)
[【Laravel】Model を save すると、そのインスタンスの id が 0 になることがある](https://qiita.com/Kra8/items/bc2b99ab7ab9880cecf8)
['Bootsrap 4.0.0 stable' causes 'Uncaught TypeError: Cannot read property 'apply' of undefined.'](https://stackoverflow.com/questions/48407867/bootsrap-4-0-0-stable-causes-uncaught-typeerror-cannot-read-property-apply)
[jQuery Validation サーバーに通信しての値の検証(remote)](https://symfoware.blog.fc2.com/blog-entry-2352.html)
[jquery.validate.js のエラーメッセージ表示制御にハマる](https://qiita.com/k-serenade/items/054f85361481b2cd0e21)
[jQuery Validation を Twitter Bootstrap と組み合わせて使う](https://qiita.com/tetsuya/items/0a5eb5356a2dfa682b90)
[validate and bootstrap4](https://stackoverflow.com/questions/52159649/jquery-validation-plugin-and-bootstrap-4)
[BootStrap4 でフロントエンド完結の password 一致のバリデーションを実現する](https://qiita.com/Coolucky/items/8ae60a9f3911423aca0c)
[novalidate を付与する。html5 検証と validate は同時に使用できない。](https://stackoverflow.com/questions/22549260/why-is-jquery-validation-adding-novalidate-attribute)
[大量の Input タグがあるページで jquery validation の Submit がどえらい遅いのは options.success のせいだったかもしれない。](https://qiita.com/qyen/items/716d49fec68f944e4d06)
[jQuery Validate Plugin の解説と Validate 日本語環境用 Plugin と jQuery Form Plugin との連携](https://kudakurage.hatenadiary.com/entry/20091211/1260521031)
[mdn:input](https://developer.mozilla.org/ja/docs/Web/HTML/Element/Input)
[チェックボックスを作成する](http://recipes.laravel.jp/recipe/209)
[php:date](https://www.php.net/manual/ja/function.date.php)
[jQuery Validation Plugin を使用した時、フォームが検証済みになった時にボタンを有効にする](https://qiita.com/ysti/items/2ff7bb1ed0df488231b1)
[LaravelCollective Form ファサード チートシート](https://qiita.com/zushi_0905/items/a21212a205b755cff2db)