1
3

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-11-01

概要

前回は一覧と作成ができることを確認した。
今回は、bootstrap4とjquery validationを使ったフロントのバリデーションも行った。

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

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

composer require laravelcollective/html

Laravelでのバリデーション

migration用ファイルの作成

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

<?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になる。

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

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

ルーティングの設定

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に格納するようにした。

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

長いので非表示
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

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

            @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ライブラリも追加している。

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

トランスパイル設定

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

typescript用の設定の追加

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

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

        // 省略


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

    "include": ["resources/ts/**/*"]
}
resouces/ts/types/index.d.ts
interface JQuery {
    validate(options: any): JQuery;
}
interface JQueryStatic {
    validator: any;
}

バリデーションの設定

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

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: '既に使われているIDです',
                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を帰すようにする。

ルーティング設定

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

コントローラ

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);
    }
}

ここまでのソース

参考

Laravel 6.0 バリデーション
jQuery Validation
jQuery Validation Plugin が使いやすくておすすめ
jsdelivr
Laravel 6.0 Blade テンプレート
Laravel のフロントエンドに TypeScript を導入する
Laravel mix の Vue.js を TypeScript にしていく
jquery-validation
【Laravel】Model を save すると、そのインスタンスの id が 0 になることがある
'Bootsrap 4.0.0 stable' causes 'Uncaught TypeError: Cannot read property 'apply' of undefined.'
jQuery Validation サーバーに通信しての値の検証(remote)
jquery.validate.js のエラーメッセージ表示制御にハマる
jQuery Validation を Twitter Bootstrap と組み合わせて使う
validate and bootstrap4
BootStrap4 でフロントエンド完結の password 一致のバリデーションを実現する
novalidate を付与する。html5 検証と validate は同時に使用できない。
大量の Input タグがあるページで jquery validation の Submit がどえらい遅いのは options.success のせいだったかもしれない。
jQuery Validate Plugin の解説と Validate 日本語環境用 Plugin と jQuery Form Plugin との連携
mdn:input
チェックボックスを作成する
php:date
jQuery Validation Plugin を使用した時、フォームが検証済みになった時にボタンを有効にする
LaravelCollective Form ファサード チートシート

1
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
3