#やりたいこと
マスターデータはどこのシステムでも多かれ少なかれ使われるでしょう。マスターデータの追加・削除・更新はAdmin役割向けの機能だから一般的なユーザーはアクセスできるはずがないですが人数は少ないため重要機能ではないと思いません。しかしマスターデータを扱うのはただ1つテーブルでレコードを追加・削除・更新という基本的な操作で難しくではありませんが通常はマスターテーブルの数は結構多いだしテーブルの構成は統一ではないのでもし数・構成をよそにダイナミック的に編集できればベストではないでしょうか?
僕はLaravelを勉強する初心者で体験しながら以降のようなイメージの通りの機能を作ってみました:
- マスターテーブルの名前をSelectBoxから選択すればテーブルの構成をサーバーから取得してDatatableを構築する
- Editor DataTableを用いてマスターデータ自体を編集できるようにする
Datatableの追加たは別投稿で述べましたので今回はEditorというDatatableの重要なプラグインを重んじて話させてもらおうと思います
#開発手順
##Datatables
自らクライエントやサーバーサイドのを実装するのは先日の投稿で説明しましたけど本投稿はもっと簡単な方を紹介致します。Yajraさんという人はDatatableにつてのよく操作をパッケージしてライブラリー化にしてくれてもっと簡単なインターフェースを提供しています。詳細は以下のリンクを参照してください。
-> Yajra box
今回の例作れるためYajraさんが作ってくれた初心者向けのチュートリアルに従って実施していく。
-> Yajra Box Tutorial for Starter
##DataTables Editor
以上も言いましたけどEditorはDatatablesの一つの面白いプラグインです。PluginなのでDataTableに含められるとは限りません。「public/plugins/datatables」にeditorが存在しないかの確認は必要でもしなかったら以下のリンクをアクセスして追加ダウンロードは必要です。PHP/.Net/JSのそれぞれのライブラリーがありますが今回はLaravelプロジェクトで使用するつもりですのでPHPライブラリーをダウンロードしてください。
->[Datatables Editor] (https://editor.datatables.net/download/index)
プロジェクトでダウンロードしたDatatablesとEditorプラグインを使用できるため以下のようなインクルードを入れてください。
<link rel="stylesheet" type="text/css" href="{{ asset('/plugins/datatables/DataTables-1.10.12/css/jquery.dataTables.css')}}">
<link rel="stylesheet" type="text/css" href="{{ asset('/plugins/datatables/Editor-PHP-1.5.6/css/editor.dataTables.css')}}">
<link rel="stylesheet" type="text/css" href="{{ asset('/plugins/datatables/Select-1.2.0/css/select.dataTables.min.css')}}">
<script type="text/javascript" src="{{ asset('/plugins/datatables/DataTables-1.10.12/js/jquery.dataTables.min.js')}}"></script>
<script type="text/javascript" src="{{ asset('/plugins/datatables/Editor-PHP-1.5.6/js/dataTables.editor.js')}}"></script>
<script type="text/javascript" src="{{ asset('/plugins/datatables/Select-1.2.0/js/dataTables.select.js')}}"></script>
##マスターデータ編集画面
まずは以上のイメージような画面をbladeで作ります。
@section('main-content')
<div class="box box-info" id="main-box" name="main-box">
<div class="box-header with-border">
<h3 class="box-title"><i class="fa fa-cog"></i>{{trans('pg_system_master.box.lblTitle')}}</h3>
<div class="box-tools pull-right">
</div>
</div>
<div class="box-body">
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="optMasterTable" class="col-sm-3 control-label">Master Table</label>
<div class="col-sm-9">
<select class="form-control select2" id = "optMasterTable" name ="optMasterTable" style="width: 100%;">
@foreach($lstMasterTableNames as $mstTableName)
<option value="{{$mstTableName->name}}">{{$mstTableName->name}}</option>
@endforeach
</select>
</div>
</div>
</div>
</div>
<div class="table-responsive" name="tableDiv" id="tableDiv">
<table class="table table-striped table-bordered table-hover display" id="master-table" name ="master-table">
<thead>
</thead>
</table>
</div>
</div>
<div class="box-footer clearfix">
<a href="#" id='btnCreate' name='btnCreate'
class="btn btn-sm btn-success btn-flat pull-right">{{trans('pg_system_master.box.btnCreate')}}</a>
</div>
</div>
@endsection
見られた通りに画面の上で空白のtableとSelectBoxは定義しました。ブラウザーで開いて見んたらエラーが出てくるはず。SelectBoxのコンテンツはサーバーから持ってくるのでサーバーの処理は実装されて居ない限り動けないのをoptionのところ見て見たらわかると思います。ではサーバーの処理を弄って見ましょう。
Route::get('settings/master', ['as' => 'systems.settings.master', 'uses' => 'MasterDataController@index']);
public function index(Request $request) {
$lstMasterTableNames = DB::select ( 'SELECT TABLE_NAME as name FROM INFORMATION_SCHEMA.TABLES
WHERE
TABLE_SCHEMA="MySchema"
AND (TABLE_NAME LIKE "mst%")
AND TABLE_TYPE="BASE TABLE"
ORDER BY TABLE_NAME ASC
' );
return View::make ( 'settings_master' )->with ( 'lstMasterTableNames', $lstMasterTableNames );
}
- 分別しやすいためマスターデータのテーブル名は「mst」を頭につけて命名規約にする。
- Schema名は仮に「MySchema」としているが実装するときに自分が使っているSchema名を入れ替えてください。
- 使っていたSQLはただSchemaテーブルを見て取得したいテーブル名を取るだけです。
ここまで行けばブラウザーで開いたらSelectBoxのコンテンツにはマスターテーブル名が入れられているはずです。選択して見たら下のテーブルの表示は何も変わらないで薄っぺらな〜と思っている人は居ないのですか?(笑
##マスターデータを編集する機能
選択したテーブル名の内容は表示されるため2つの作業は必要
- ①テーブル名を元にテーブルの構成(Field名)情報を取得してDatatbleを構築すること
- ②テーブル名を元にデータを取得してDatatableがわかられる形に変換してクライエントに返す。
①テーブルの構成情報を取得
public function schema(Request $request) {
$tableName = strtoupper ( $request->input ( 'table_name' ) );
$className = Config::get ( 'constants.MASTER_TABLES.' . $tableName . '.CLASS' );
Log::debug ( $className );
try {
$model = new $className ();
$fillables = $model->getFillable ();
$fields = array ();
foreach ( $fillables as $fillable ) {
$field = array ();
$field ['label'] = $model->getColumnLabelByName ( $fillable );
$field ['name'] = $fillable;
$fields [] = $field;
}
$ret = json_encode ( [
'success' => true,
'fields' => $fields,
'columns' => $model->getColumnsSetting ()
] );
return $ret;
} catch ( Exception $e ) {
return json_encode ( [
'success' => false,
'message' => $e->getMessage ()
] );
}
}
'MASTER_TABLES'=>
[
'MSTBRANCHES'=>[
'NAME' => 'Branches',
'CLASS' => 'App\\Models\\Branch'
],
'MSTCHANNELS'=>[
'NAME' => 'Branches',
'CLASS' => 'App\\Models\\Channel'
],
'MSTCOMPANIES'=>[
'NAME' => 'Companies',
'CLASS' => 'App\\Models\\Company'
],
'MSTDEPARTMENTS'=>[
'NAME' => 'Departments',
'CLASS' => 'App\\Models\\Department'
],
]
何それ!!!ちょっと説明させてください
- 持っているテーブル名を元に構成情報を取得したい場合にSchema情報のようにRawQuery使って取れるはずがせっかくモデルをつくったのに使わないのは勿体無いではないかと思います。それに編集可能の内容だけを編集させるのである方法でテーブル名から該当Modelに変換できればfillableやguarded属性を見るだけでいいではないでしょうか?
そういうわけでちょっと鈍い方法かも知りませんがconfigでテーブル名〜モデールクラス名の関係を定義することにします。 - field名はそのまま画面で表示するのは良くない場合もありますのでField名から表示用のテキストに変換する関数を準備しておく(getColumnLabelByName)
- キー点は「$model = new $className ();」です。PHPではそういうインスタンスの作り方は可能ですのですごく便利です。
schema()の返却データはDatatableにもEditorプラグインにも使われます。とらえず
<script type="text/javascript">
var masterTable;
var masterTableEditor;
$(document).ready(function(){
$.ajaxSetup({
headers: {
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
}
});
var tableName = $('#optMasterTable').select2('val');
$.ajax({
type: 'GET',
url: '{!! route("api.master.schema") !!}' + '?table_name=' + tableName,
beforeSend : function(xhr, settings){
$('#main-box').append('<div class="overlay" id ="spin" name = "spin"><i class="fa fa-refresh fa-spin"></i></div>');
},
complete: function(xhr, textStatus){
$('#spin').remove();
},
success: function(d) {
var result = jQuery.parseJSON(d);
if(masterTable){
masterTable.destroy();
$('#tableDiv').empty(); // empty in case the columns change
/*
*Create <thead></thead>
*/
var fields = result.fields;
var columns = result.columns;
var htmlTable = '<table class="table table-striped table-bordered table-hover display" id="master-table" name ="master-table">';
htmlTable += '<thead>';
htmlTable += '<tr>';
columns.forEach(function(column){
htmlTable += '<th>' + column.label + '</th>';
});
htmlTable += '</tr>';
htmlTable += '</thead>';
htmlTable += '</table>';
$('#tableDiv').append(htmlTable);
}
masterTable = $('#master-table').DataTable({
lengthChange: false,
responsive: true,
processing: true,
serverSide: true,
ajax: {
url: '{!! route("api.master.datatables") !!}' + '?table_name=' + tableName,
method : 'GET'
},
dom: 'Bfrtip',
columns: result.columns,
select: true,
aoColumnDefs: [
{ 'bSortable': false, 'aTargets': [0, 1 , 4, 5] } ,
{ 'className': "dt-center", "aTargets": [0,1,2,3,4,5,6] },
],
initComplete: function(settings, json) {
$searchBox = $(".dataTables_filter input[type='search']");
$searchBox.addClass('form-control input-sm');
$searchBox.attr("placeholder", "Type here to search");
$('.dataTables_filter label').get(0).firstChild.nodeValue = "";
$('.dataTables_filter').addClass('col-sm-6');
},
});
masterTableEditor = new $.fn.dataTable.Editor( {
ajax: {
url: '{{route("api.master.crud")}}',
data: function (form) {
form.table_name = $('#optMasterTable').select2('val');
return form;
},
method: 'POST',
},
table: '#master-table',
idSrc: "id",
fields: result.fields,
} );
$('#master-table').on( 'click', 'tbody td.editable', function (e) {
masterTableEditor.inline( this );
} );
$('#master-table').on( 'click', 'tbody a.remove', function (e) {
masterTableEditor
.title( '{{trans("pg_system_master.dialog.delete.title")}}')
.message( '{{trans("pg_system_master.dialog.delete.message")}}' )
.buttons( { "label": "{{trans('pg_system_master.dialog.delete.btnOK')}}", "fn": function () { masterTableEditor.submit() } } )
.remove( $(this).closest('tr') );
} );
$('#master-table').on( 'click', 'tbody a.edit', function (e) {
masterTableEditor
.title( "{{trans('pg_system_master.dialog.edit.title')}}" )
.buttons( { "label": "{{trans('pg_system_master.dialog.edit.btnOK')}}", "fn": function () { masterTableEditor.submit() } } )
.edit($(this).closest('tr'));
} );
},
error : function(data){
$('#spin').remove();
}
});
$('#optMasterTable').on('change', function (e) {
var tableName = $('#optMasterTable').select2('val');
$.ajax({
type: 'GET',
url: '{!! route("api.master.schema") !!}' + '?table_name=' + tableName,
beforeSend : function(xhr, settings){
$('#main-box').append('<div class="overlay" id ="spin" name = "spin"><i class="fa fa-refresh fa-spin"></i></div>');
},
complete: function(xhr, textStatus){
$('#spin').remove();
},
success: function(data) {
var result = jQuery.parseJSON(data);
/*
* Clear table
*/
masterTable.destroy();
$('#tableDiv').empty(); // empty in case the columns change
/*
*Create <thead></thead>
*/
var fields = result.fields;
var columns = result.columns;
var htmlTable = '<table class="table table-striped table-bordered table-hover display" id="master-table" name ="master-table">';
htmlTable += '<thead>';
htmlTable += '<tr>';
columns.forEach(function(column){
htmlTable += '<th>' + column.label + '</th>';
});
htmlTable += '</tr>';
htmlTable += '</thead>';
htmlTable += '</table>';
$('#tableDiv').append(htmlTable);
masterTable = $('#master-table').DataTable({
lengthChange: false,
responsive: true,
processing: true,
serverSide: true,
ajax: {
url: '{!! route("api.master.datatables") !!}' + '?table_name=' + tableName,
method : 'GET'
},
dom: 'Bfrtip',
columns: columns,
select: true,
aoColumnDefs: [
{ 'bSortable': false, 'aTargets': [0, 1 , 4, 5] } ,
{ 'className': "dt-center", "aTargets": [0,1,2,3,4,5,6] },
],
initComplete: function(settings, json) {
$searchBox = $(".dataTables_filter input[type='search']");
$searchBox.addClass('form-control input-sm');
$searchBox.attr("placeholder", "Type here to search");
$('.dataTables_filter label').get(0).firstChild.nodeValue = "";
$('.dataTables_filter').addClass('col-sm-6');
},
});
/*
* Update the tables editor
*/
masterTableEditor.clear();
masterTableEditor.add(fields);
$('#master-table').on( 'click', 'tbody td.editable', function (e) {
masterTableEditor.inline( this );
} );
$('#master-table').on( 'click', 'tbody a.remove', function (e) {
masterTableEditor
.title( '{{trans("pg_system_master.dialog.delete.title")}}')
.message( '{{trans("pg_system_master.dialog.delete.message")}}' )
.buttons( { "label": "{{trans('pg_system_master.dialog.delete.btnOK')}}", "fn": function () { masterTableEditor.submit() } } )
.remove( $(this).closest('tr') );
} );
$('#master-table').on( 'click', 'tbody a.edit', function (e) {
masterTableEditor
.title( "{{trans('pg_system_master.dialog.edit.title')}}" )
.buttons( { "label": "{{trans('pg_system_master.dialog.edit.btnOK')}}", "fn": function () { masterTableEditor.submit() } } )
.edit($(this).closest('tr'));
} );
/*
fields.forEach(function(field){
console.log(field);
tableEditor.add(field);
});
*/
},
error : function(data){
$('#spin').remove();
}
});
});
$('#btnCreate').on( 'click', function () {
masterTableEditor
.title( "{{trans('pg_system_master.dialog.create.title')}}" )
.buttons( { "label": "{{trans('pg_system_master.dialog.create.btnOK')}}", "fn": function () { masterTableEditor.submit() } } )
.create();
} );
});
</script>
Route::get('master/datatables', ['as' => 'api.master.datatables', 'uses' => 'Systems\MasterDataController@datatables']);
Route::post('master/crud', ['as' => 'api.master.crud', 'uses' => 'Systems\MasterDataController@postProcess']);
Route::get('master/schema', ['as' => 'api.master.schema', 'uses' => 'Systems\MasterDataController@schema']);
キーポイント
- 初期化されたDatatableをサイド初期化するのはだめですので選択肢を変えるとdestroy()して完全にテーブルを作り直さなければなりません。
masterTable.destroy();
$('#tableDiv').empty(); // empty in case the columns change
var fields = result.fields;
var columns = result.columns;
var htmlTable = '<table class="table table-striped table-bordered table-hover display" id="master-table" name ="master-table">';
htmlTable += '<thead>';
htmlTable += '<tr>';
columns.forEach(function(column){
htmlTable += '<th>' + column.label + '</th>';
});
htmlTable += '</tr>';
htmlTable += '</thead>';
htmlTable += '</table>';
- Editorプラグインの初期化. 編集するときテーブル名は必要ですので送信するデータに加えて選択したテーブル名を入れておく。
masterTableEditor = new $.fn.dataTable.Editor( {
ajax: {
url: '{{route("api.master.crud")}}',
data: function (form) {
form.table_name = $('#optMasterTable').select2('val');
return form;
},
method: 'POST',
},
table: '#master-table',
idSrc: "id",
fields: result.fields,
} );
- Datatableのコンテンツを提供するロジックは以下です。見た通り、Yajraに提供してもらったライブラリーを使えばコードは短縮になりました。
use Yajra\Datatables\Datatables;
public function datatables(Request $request) {
$tableName = strtoupper ( $request->input ( 'table_name' ) );
$className = Config::get ( 'constants.MASTER_TABLES.' . $tableName . '.CLASS' );
$model = new $className ();
$columnList = $model->getColumnList ();
$objs = $className::select ( $columnList );
if (in_array ( 'ordering', $columnList )) {
$objs = $objs->orderBy ( 'ordering' );
}
return Datatables::of ( $objs )->addColumn ( 'action', function ($obj) {
$html = '<div class="tools"><a href="#" class="edit btn btn-xs btn-primary"><i class="fa fa-edit"></i></a>';
$html .= ' <a href="#" class="remove btn btn-xs btn-danger"><i class="fa fa-trash-o"></i></a></div>';
return $html;
} )->make ( true );
}
###新しいモデール
以上の説明の通りにLaravelに提供してもらっているモデルに加えてDatatableが分かれるためそのモデルを継承していくつかのメソッドを定義します。
use Illuminate\Database\Eloquent\Model;
use DB;
class GlobalModel extends Model
{
//画面で出てくるテキスト
protected $labels;
//orderできる項目名
protected $orderable;
//検索できる項目名
protected $searchable;
//データチェックルール
protected $rules;
//Customizeメッセージ
protected $messages;
public function getColumnLabelByName($column){
return $this->labels[$column];
}
public function getColumnList(){
$columns = DB::select('SELECT column_name AS name FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = :table_name', ['table_name'=> $this->getTable()]);
$columnNames = array();
foreach($columns as $column){
if (in_array($column->name, $this->hidden)){
continue;
}
$columnNames[]= $column->name;
}
return $columnNames;
}
public function getColumnsSetting(){
$columnSettings = array();
/*
* Get all the columns
*/
$columns = DB::select('SELECT column_name AS name FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = :table_name', ['table_name'=> $this->getTable()]);
foreach($columns as $column){
if (in_array($column->name, $this->hidden)){
continue;
}
$setting = array();
$setting['data'] = $column->name;
$setting['name'] = $column->name;
$setting['label'] = $this->getColumnLabelByName($column->name);
$setting['className'] = 'dt-center';
if (in_array($column->name, $this->fillable) || !in_array($column->name, $this->guarded)){
$setting['className'] .= ' editable';
}
if (in_array($column->name, $this->orderable)){
$setting['orderable']= true;
}else{
$setting['orderable']= false;
}
if (in_array($column->name, $this->searchable)){
$setting['searchable']= true;
}else{
$setting['searchable']= false;
}
$columnSettings[] = $setting;
}
$columnSettings[] = array( 'data'=> 'action', 'name'=> 'action', 'label'=>'Action', 'orderable'=> false, 'searchable'=> false);
return $columnSettings;
}
public function getValidationRules($action)
{
if($this->rules){
return $this->rules[$action];
}else
return [];
}
}
それでconfigで指定したモデルのは以上のものを継承することにします
use App\Models\GlobalModel;
class Branch extends GlobalModel
{
protected $table = 'mstBranches';
protected $primaryKey = 'id';
protected $guarded = ['id','created_at','updated_at'];
protected $fillable = ['name','display','ordering'];
protected $labels = [
'id' => 'ID',
'name'=> 'Property',
'display' => 'CSS Display',
'ordering'=> 'Display Order',
'created_at' => 'Created At',
'updated_at' => 'Updated At',
];
protected $orderable = ['name', 'ordering', 'created_at','updated_at'];
protected $searchable = ['name'];
}
###Inline Editorの処理
crudというルートはEditorでデータ編集されたときに呼び出されます。Editorプラグイン自体はサーバーサイドクラス持っていますがLaravelにインテグレーションするのは結構手間かかりますので今回僕は自らサーバーサイドを簡単に作りました。説明したら長くなると思いますので興味を持っている方はまず試して見てくれて、不明なところがあればコメントで相談しましょう
private function process(Request $request) {
Log::info ( 'Begin private function process' );
Log::debug ( $request );
$action = $request->input ( 'action' );
$tableName = strtoupper ( $request->input ( 'table_name' ) );
$model = Config::get ( 'constants.MASTER_TABLES.' . $tableName . '.CLASS' );
$data = $request->input ( 'data' );
$modelType = gettype ( $model );
if ($modelType != 'string') {
return json_encode ( [
'error' => 'System Error: Model name is not string, ' . $modelType . ' given.'
] );
} else {
if ($action == 'create') {
$ids = array_keys($data);
$row = $data[$ids[0]];
$instance = new $model();
$validator = Validator::make($row, $instance->getValidationRules($action));
if ($validator->fails()) {
$msgError = array();
$messages = $validator->errors();
$msgKeys = $messages->keys();
foreach ($msgKeys as $msgKey) {
$msgError[] =
array(
'name' => $msgKey,
'status' => $messages->get($msgKey)
);
}
return json_encode([ 'fieldErrors' => $msgError ]);
} else {
try {
$record = $model::firstOrNew ( $row );
$record->save ();
$resSuccessful = [ ];
$resSuccessful [] = $record->toArray ();
return json_encode ( [
'data' => $resSuccessful
] );
} catch ( Exception $e ) {
Log::debug ( $e->getMessage () );
return json_encode ( [
'error' => $e->getMessage ()
] );
}
}
} else if ($action == 'edit') {
Log::info ( 'Action = edit' );
$keys = array_keys($data);
$instance = new $model();
foreach($keys as $key){
$row = $data[$key];
$row['id'] = $key;
$validator = Validator::make($row, $instance->getValidationRules($action));
if ($validator->fails()) {
$msgError = array();
$messages = $validator->errors();
$msgKeys = $messages->keys();
foreach ($msgKeys as $msgKey) {
$msgError[] =
array(
'name' => $msgKey,
'status' => $messages->get($msgKey)
);
}
Log::debug($msgError);
return json_encode([ 'fieldErrors' => $msgError ]);
}
}
try {
$resSuccessful = array ();
foreach ( $data as $id => $content ) {
$obj = $model::find ( $id );
$obj->update ( $content );
$resSuccessful [] = $obj->toArray ();
}
return json_encode ( array (
'data' => $resSuccessful
) );
} catch ( Exception $e ) {
Log::debug($e->getMessage ()) ;
return json_encode ( [
'error' => $e->getMessage ()
] );
}
} else if ($action == 'remove') {
$keys = array_keys($data);
$instance = new $model();
foreach($keys as $key){
$row = $data[$key];
$row['id'] = $key;
$validator = Validator::make($row, $instance->getValidationRules($action));
if ($validator->fails()) {
$msgError = array();
$messages = $validator->errors();
$msgKeys = $messages->keys();
foreach ($msgKeys as $msgKey) {
$msgError[] =
array(
'name' => $msgKey,
'status' => $messages->get($msgKey)
);
}
return json_encode(array('fieldErrors' => $msgError));
}
}
try {
foreach ( $data as $id => $content ) {
$obj = $model::find ( $id );
if ($obj) {
$obj->delete ();
}
}
return json_encode ( [
'data' => [ ]
] );
} catch ( Exception $e ) {
return json_encode ( [
'error' => $e->getMessage ()
] );
}
} else {
return json_encode(array (
'error' => 'System Error: Variable passed to DT Editor is not a valid Eloquent Builder'
));
}
}
}
public function postProcess(Request $request) {
Log::info ( 'begin postProcess' );
/*
* Check input parameters
*/
if (! $request->has ( 'action' )) {
return json_encode ( [
'error' => 'System Error: Action is missing'
] );
}
if (! $request->has ( 'table_name' )) {
return json_encode ( [
'error' => 'System Error: Table name is missing'
] );
}
if (! $request->has ( 'data' )) {
return json_encode ( [
'error' => 'System Error: Data content is missing'
] );
}
$returnArray = $this->process ( $request );
Log::debug($returnArray);
Log::info ( 'End postProcess' );
return $returnArray;
}