概要
前回は一覧と作成ができることを確認した。
今回は、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になる。
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Coupon extends Model
{
protected $keyType = 'string';
public $incrementing = false;
}
ルーティングの設定
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
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
長いので非表示
@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">×</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">×</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>{{-- 更新 --}} </th>
<th>{{-- 削除 --}} </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ライブラリも追加している。
@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
トランスパイル設定
+ mix.ts('resources/ts/admin/coupon/index.ts', 'public/js/admin/coupon').version();
typescript用の設定の追加
@types/jquery.validation
はバージョンが1.16で止まっており、最新は1.19であったため使わない。
コンパイルを通すために以下の設定をおこなった。
{
"compilerOptions": {
"outDir": "./built/",
// 省略
+ "typeRoots": ["./resources/ts/types", "node_modules/@types"]
},
"include": ["resources/ts/**/*"]
}
interface JQuery {
validate(options: any): JQuery;
}
interface JQueryStatic {
validator: any;
}
バリデーションの設定
検証失敗/成功時に、bootstrap4のクラスを設定するようにしている。
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を帰すようにする。
ルーティング設定
+ Route::get('/coupon/unique','Actions\CouponAction@unique');
コントローラ
<?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 ファサード チートシート