#0012

プレビュー機能付きの記事編集画面の作り方(Laravel5)

2018-04-11 23:51 2018-04-23 12:09 "みやび"

MIYABI Labでは、技術ブログの編集フォームをLaravel5で製作して利用しています。

今回、WordPressなどではお馴染みのプレビュー機能をつけ忘れていたので早速実装してみたのですが、せっかくなので、プレビュー機能を実装した編集フォームの作り方としてセットで解説します。

全体の処理がどのような流れで進んでいくのかを図を用いてしっかりと理解した上で、Laravel5におけるルーティングやコントローラー設計で押さえておきたいポイント、そしてプレビューボタンを押した際に実行される動的な処理についても、JavascriptとjQueryでの2通りの方法でご紹介します。

スポンサーリンク

完成イメージ

まずは完成したページを先に紹介します。

こちらは、実際にMIYABI Labで記事を執筆している編集ページになります。

構造としてはとてもシンプルで、最下部の更新ボタンをクリックするとバリデーションチェックを経てデータベースに保存され、プレビューボタンを押すと別タブでプレビュー画面を表示します。

利用する言語・フレームワーク・ライブラリ等は以下の通りです。

  • PHP7.2
  • Laravel5.4
  • MySQL
  • HTML
  • jQuery 1.12.4(使用しなくてもOK)

記事を保存・更新する処理の流れを確認

Laravelなどのフレームワークを用いてサイトを製作している場合、機能ごとにファイルを細分化されているため、それぞれのファイルが何の役割を担っているかをしっかりと理解しておくことが大切です。

まずは基本的な処理として、フォームからPOST送信されたデータをバリデーション処理してデータベース(今回はMySQL)に保存するまでの流れを確認しておきます。

更新処理で利用するファイル

実際のものとはファイル名や中身を少し編集しています。

ルーティング

  • routes/web.php

コントローラー

  • APPPATH/Http/Controllers/EditController.php

フォームリクエスト

  • APPPATH/Http/Requests/EditRequest.php

モデル

  • APPPATH/Models/Edit.php

ビュー

  • resources/views/blog/blog.blade.php(記事のテンプレート)
  • resources/views/edit/blog.blade.php(編集画面のテンプレート)

具体的な更新処理の流れ

ユーザーがこの技術ブログにアクセスした時、上述したどのファイルを呼び出し、どのような流れで処理が進むのかを図で示すと以下のようになります。

  1. 編集画面から更新ボタンをクリックする
  2. URLから対応するコントローラーのメソッド(store)に処理を受け渡す
  3. 送信されたPOSTデータをルールにしたがってバリデーション処理する
  4. 条件に一致しているかどうかを返却し、OKなら次へ、NGならそのまま元のページへ
  5. Modelを介して既に保存されている記事データを取りに行かせる
  6. 既存のデータを変数に格納し、POST送信されたデータで必要なものを置き換え、データを上書き保存する
  7. データが正常に保存されたかどうかの結果が返る
  8. その結果がそのまま呼び出し元のコントローラーに渡る
  9. 正常に保存されていることを確認し、元の編集ページにリダイレクトする(実際には記事編集ページへのリダイレクトもルーティングやコントローラーを介すがここでは割愛)

それぞれのコードやプログラム

以下でご紹介するプログラム等は、実際に運用しているものよりもかなり簡潔に書いてます。細かい処理は端折っているので、コピペだけでは動きませんが、イメージを掴んで自分なりにアレンジしてみてください。

(※コントローラー名やメソッド名は架空のものに置き換えています)

routes/web.php
// この技術ブログ記事へのルート
Route::get('/blog/{id}', 'BlogController@blog')->where('id', '[1-9][0-9]*');
// 記事を保存するコントローラーへのルート
Route::post('/EDIT-PATH/store/{id}', 'EditController@store')->where('id', '[1-9][0-9]*');
EditController.php

※1:

  • 状況に応じて$idのチェックは変更する。
  • is_numeric()についてはあくまでもサンプルで、この場合は上のweb.phpでもチェックしているので必要なし。
  • 新規作成も共存させたい場合は、その分岐を書いたり、$idパラメーターを変更する。
  • その際はroutes/web.phpにおけるwhere句なども変更しなきゃダメ。

※2:

  • 送られてきたデータで既存のデータを上書きする。
  • 自分で製作作ってみたけど、そのままDBに保存できる形式なら$edit->fill($request->Input())などで埋めてもいい。
  • 上書きするかどうかの判断は、モデルにて、$fillableや$guarded等で指定しておく(この指定は、getAttributes()で取得できるプロパティにも影響する)。

※3:

  • データベースに保存するためにデータを整形したりする処理を入れる。
  • プロフィールはserializeしたり、チェックボックスはimplodeで繋げるなど、配列の取り扱いにも注意する。
class EditController extends Controller
{

	public function store($id, EditRequest $request)
	{
	
		// ※1
		if(is_numeric($id)){
			$edit = Edit::find($id);
		}else{
			return redirect()->back()->withInput($request->input());
		}

		// ※2
		foreach($edit->getAttributes() as $column => $value){
			if( isset($request[$column]) ){
				// array -> comma implode
				if( is_array($request[$column]) ){
					$request[$column] = implode(',', $request[$column]);
				}
				// set
				$edit[$column] = $request[$column];
			}
		}

		// ※3
	
		// 保存する
		if( $edit->save() ){ // 戻り値はbool(保存の結果)
			return redirect()->action("EditController@edit", ['id' => $edit->id])->with('success-msg', 'SUCCESS:データを保存しました!');
		}else{
			return redirect()->back()->withInput($request->Input())->withErrors(['予期せぬエラーが発生しました!', '保存が失敗した可能性があります。念のためローカルにデータをコピーしておいてください。']);
		}

	}

}
EditRequest.php

バリデーションルールはここに記載します。

サポートされている使用可能なバリデーションルールは公式を参照してください。

class EditRequest extends FormRequest
{
	public function authorize()
	{
		return true;
	}

	public function rules()
	{

		return [
			'user_id' => 'required',
			'title' => 'required',
			/*~~~~~
			  省略
			~~~~~*/
		];
		
	}

	public function attributes() {
		
		return [
			'user_id' => '執筆者',
			'title' => 'タイトル',
			/*~~~~~
			  省略
			~~~~~*/
		];
		
	}
	
}
Models/Edit.php

Eloquentを利用しているためEditモデルはとても簡潔です。

$guardedや$fillableといったプロパティの仕様については、リンク先の複数代入の項目を参照してください。

class Edit extends Model
{
	
	protected $guarded = [
		'id',
		'created_at',
		'updated_at',
	];
	
	public function User()
	{
		return $this->belongsTo(User::class);
	}

}
edit/blog.blade.php(編集画面のテンプレート)

ここではHTMLフォームの一部のみご紹介します。プレビューの際は、このフォームのaction属性などをJavascriptやjQueryから操作します。

ちなみに、formのaction属性の数値が12になっていますが、この記事のIDです。Laravel側の出力の際はもちろん動的でお願いします。

また、action属性をフルパスで入れている理由としては、Laravelのbladeテンプレートにて {!! Form::open() !!} を利用すると、自動的にhttp(セキュアではない)で始まるURLに変換されてしまうため、わざわざhttpsで書いてます(大した数ではないので関数作ってないです)。

<form method="POST" action="https://miyabi-lab.space/EDIT-PATH/store/12" accept-charset="UTF-8" id="admin-blog-form" class="form-horizontal" enctype="multipart/form-data">
	
	<div class="form-group">
		<label for="user_id" class="col-sm-2 control-label">執筆者</label>
		<div class="col-sm-2">
			<select class="form-control" id="user_id" name="user_id">
				<option value="1" selected="selected">加藤雅大</option>
				<option value="2">湯座丞太郎</option>
				<option value="3">...</option>
			</select>
		</div>
	</div>

	<div class="form-group">
		<label for="title" class="col-sm-2 control-label">タイトル</label>
		<div class="col-sm-8">
			<input class="form-control" id="title" name="title" type="text" value="タイトル">
		</div>
	</div>

	<!-- ~~~
	  省略
	~~~  -->

	<input name="_token" type="hidden" value="CSRF-TOKEN">

	<div class="form-group">
		<div class="col-sm-8 col-sm-offset-2 text-center">
			<input class="btn btn-primary" type="submit" value="更新する">
		</div>
	</div>

</form>

ちなみに、Laravel5のフォームファサードを利用して書くと以下のようになります。

CSRFトークンについては、{!! Form::open() !!}を呼び出すことで自動的に挿入されるため、{{ Form::token() }} を敢えて呼び出す必要はありません。

{!! Form::open(['url' => "https://miyabi-lab.space/EDIT-PATH/store/{$edit->id}", 'id'=> 'admin-blog-form', 'class' => 'form-horizontal', 'files' => true]) !!}

	<div class="form-group{{ $errors->has('user_id') ? ' has-error' : '' }}">
		{!! Form::label('user_id', '執筆者', ['class' => 'col-sm-2 control-label']) !!}
		<div class="col-sm-2">
			{!! Form::select('user_id',$users, @$edit->user->id, ['class' => 'form-control']) !!}
		</div>
	</div>

	<div class="form-group{{ $errors->has('title') ? ' has-error' : '' }}">
		{!! Form::label('title', 'タイトル', ['class' => 'col-sm-2 control-label']) !!}
		<div class="col-sm-8">
			{!! Form::text('title', $edit->title, ['class' => 'form-control']) !!}
		</div>
	</div>

	<!-- ~~~
	  省略
	~~~  -->

	<div class="form-group">
		<div class="col-sm-8 col-sm-offset-2 text-center">
			{!! Form::submit('更新する', ['class' => 'btn btn-primary']) !!}
			{!! Form::submit('プレビュー', ['id' => 'admin-blog-preview-btn', 'class' => 'btn btn-warning']) !!}
		</div>
	</div>

{!! Form::close() !!}

その他はこのブログのソースコードとほぼ同じです。

また、フォームファサードはデフォルトでは利用できないので、composerで追加してあげる必要があります。初回のみ以下の作業を行いましょう。

Laravelプロジェクトのルートディレクトリにて、コマンドラインから以下のコマンドを叩きます。

$ composer require laravelcollective/html

完了したら、APPPATH/config/app.php の2つの配列に以下の行をそれぞれ追加します。

return [

	'providers' => [
		// 【追加】
		Collective\Html\HtmlServiceProvider::class,
	],

	'aliases' => [
		// 【追加】
		'Form' => Collective\Html\FormFacade::class,
		'Html' => Collective\Html\HtmlFacade::class,
	],

]

これでbladeテンプレート内で{!! Form::open() !!}などが利用できます。

このケースで遭遇することはないかと思いますが、もしクラスを読み込めないなどのエラーが発生した場合には、以下のコマンドを叩いて autoload_classmap.php ファイルを更新してください。

$ composer dump-autoload

記事をプレビューする処理の流れを考える

次に、完成イメージをコードやプログラムに落とし込むために、プレビューボタンをクリックしてからの処理を時系列順に並べてみます。

プレビューを作成する方法はいくつかありますが、今回はJavascriptやjQueryを用いてフォームタグのアクションを変更し、データのPOST送信先を別のURLにする(厳密には別のコントローラーで処理させる)、というシンプルかつ汎用性の高い手法をとります。

処理の違いを理解する

こちらが先ほどの「更新ボタンを押したときの流れ」です。

そしてこちらが「プレビューボタンを押したときの流れ」です。

  1. 編集画面からプレビューボタンをクリックする
  2. URLから対応するコントローラーのメソッド(preview)に処理を受け渡す
  3. 送信されたPOSTデータをルールにしたがってバリデーション処理する(同じ)
  4. 条件に一致しているかどうかを返却し、OKなら次へ、NGならそのまま元のページへ
  5. 通常の技術ブログで利用しているビューテンプレートに、POST送信された変数を埋め込む
  6. プレビュー画面を返す

更新処理の流れと比較すると、更新とプレビューでは異なるコントローラーを利用していることがわかります。

プレビュー用のコントローラーが返すビューテンプレートは、実際のブログで利用しているもの(blog.blade.php)をそのまま利用しています。

また、プレビューでは実際にデータの更新を行うことはないのでモデルは利用しません。モデルから取得するデータの代わりに、POST送信したフォームデータをそのまま変数に格納し、必要であれば名前を揃え、ビューで用意してある変数に当てはめます。

コード・プログラムを追記する

ここまでイメージできれば、あとはこれをプログラムに落とし込むだけです。

今回のケースに限らず、全体の処理をイメージしたり、上手く理解できない場合には絵を描いてみるのも非常に効果的です。

それでは先ほどのファイルにプログラムを追記していきましょう。実際には書くことはほとんどありません。

routes/web.php(追記後)
// この技術ブログ記事へのルート
Route::get('/blog/{id}', 'BlogController@blog')->where('id', '[1-9][0-9]*');
// 記事を保存するコントローラーへのルート
Route::post('/EDIT-PATH/store/{id}', 'EditController@store')->where('id', '[1-9][0-9]*');

// 【追記】 記事をプレビューするコントローラーへのルート
Route::post('/EDIT-PATH/preview/{id}', 'EditController@preview')->where('id', '[1-9][0-9]*');
EditController.php(追記後)

ビューテンプレートを使い回したことによって、本来であれば埋まるはずの変数が定義されていない、などのエラーが生じる可能性があります。

その際、大抵の場合はあまりプレビューに関係のない変数ばかりなので(必要な情報はPOST送信されているので)、状況に応じて対応する変数に応じてboolや数値、"表示されません"などの文字列を入れてしまえば通ります。

私の場合、著者名を取得する方法をEloquentのリレーションに頼っており、モデルを使用しないプレビューでは Edit::with("user") のようなことができないため、$edit->user->name には"非表示"という文字列を後から与えて調整してます。

どうしても著者名を表示したい場合は、Editコントローラーのプレビュー用メソッドにてリクエストを受け取り、送られてきたユーザーIDを利用して $edit->user->name = User::find($request->user_id) のようなことをしてあげれば取りに行けます。(その際は、$edit->user をnew stdClass() として定義しないと変数入れられない気がします)

ちょっとしたことですが、頼まれごとのシステムの場合はこちらの方が親切ですね。

class EditController extends Controller
{
	// 注意!実際のプログラムよりもかなり簡単に書いてます!
	// 細かい処理は端折っているので、イメージだけ掴んでください。

	public function store($id, EditRequest $request)
	{	
		/*~~~~~
		  省略
		~~~~~*/
	}

	// 【追記】
	public function preview($id, EditRequest $request)
	{
		$edit = $request;
	
		if( is_null($edit) ) abort(404);
		
		// blog.blade.php ビューテンプレートでエラーが出る部分を簡易的に補填(重要では無いので)
		$edit->user = new \stdClass();
		$edit->user->name = "非表示";
		$edit->user->nickname = "非表示";

		// blog.blade.php 使い回し
		return view('blog', compact('edit', 'etc..'));
	}

}
edit/blog.blade.php(追記後)
<form method="POST" action="https://miyabi-lab.space/EDIT-PATH/store/12" accept-charset="UTF-8" id="admin-blog-form" class="form-horizontal" enctype="multipart/form-data">

	<!-- ~~~
	  省略
	~~~  -->

	<input name="_token" type="hidden" value="CSRF-TOKEN">

	<div class="form-group">
		<div class="col-sm-8 col-sm-offset-2 text-center">
			<input class="btn btn-primary" type="submit" value="更新する">
			
			<!-- ここにプレビューボタンを追記 -->
			<input id="admin-blog-preview-btn" class="btn btn-warning" type="submit" value="プレビュー">

		</div>
	</div>

</form>

Javascriptでフォーム属性を動的に変更する

ファイルへの追記は以上で終わりです。

実際に自分好みにカスタマイズしていくとどんどんプログラムが増えていくのですが、絶対に外せない部分だけに絞ると非常にシンプルですね。

さあ、プレビューボタンを設置したのでクリックしてみよう!と言いたいところですが、これだけでは思った通りの動作をしてくれません。

現状では input type="submit" ボタンが2つある状態で、どちらをクリックしてもPOSTデータはaction属性に指定されたURLへと送信されます

ではどうすれば上手く対応できるのでしょうか。もう少し具体的に処理の流れを考えてみましょう。

具体的な処理の流れ

全ての処理のスタートはプレビューボタンのクリックです。

要となる処理としては、JavascriptやjQueryを利用してクリックイベントを検知し、本来の送信処理を一時的に中断させ、action属性のURLを書き換えた上で再度送信することで、POSTデータをプレビュー用メソッドに渡すことができます。

しかしそれだけだと、今書いている記事ページのタブ内で遷移してしまうため、プレビュー画面は別タブで開く必要があります。

フォーム送信結果を別タブで表示する場合、aタグと同様にformタグに直接 target="_blank" を付け加えるだけでOKです。

また、無事に別タブでプレビュー画面が開いた後にも注意が必要です。動的に変更したactionやtargetはそのまま残るので、今度は更新ボタンを押した際にもプレビューが開いてしまうので、一度開いたら属性を元に戻してあげる必要があります。

これらの流れをまとめると以下のようになります。

  1. プレビューボタンをクリックする
  2. 送信をキャンセルする
  3. formタグのaction属性を、/EDIT-PATH/store/12 から /EDIT-PATH/preview/12 に変更する
  4. formタグにtarget属性を追加して"_blank"を与える
  5. フォームを送信する(別タブでプレビューが開く)
  6. formタグのaction属性を、/EDIT-PATH/preview/12 から /EDIT-PATH/store/12 に戻す
  7. formタグのtarget属性を削除する

イメージできましたでしょうか?それではプログラミングしていきます。

僕はjQueryが好きなのですが、反対派もいると思うので念のため両方書いておきます。

Javascriptで書いてみる

Ajaxなどを利用しない限りそこまで大差ないかなという印象です。

var prevBtn = document.getElementById("admin-blog-preview-btn");
prevBtn.addEventListener("click", function(e){

	// stop sending
	e.preventDefault();

	// set variables
	var blogForm = document.getElementById("admin-blog-form");
	var defaultAction = blogForm.getAttribute("action");
	var previewAction = defaultAction.replace("/store/", "/preview/");

	// rewrite action & submit
	blogForm.setAttribute("action", previewAction);
	blogForm.setAttribute("target", "_blank");
	blogForm.submit();

	// reset
	blogForm.setAttribute("action", defaultAction);
	blogForm.removeAttribute("target");

});

jQueryで書いてみる

この程度ではほとんど違いはありませんが、やっぱりこちらの方が直感的で好きです。チェーンメソッドのあたりが単調にならなくていいですね。

当然ですが、jQueryはライブラリを読み込まないと動きません。

// change temporary action by jQuery
var prevBtn = $("#admin-blog-preview-btn");
prevBtn.on("click", function(e){

	// stop sending
	e.preventDefault();

	// set variables
	var blogForm = $("form#admin-blog-form");
	var defaultAction = blogForm.attr("action");
	var previewAction = defaultAction.replace("/store/", "/preview/");

	// rewrite action & submit
	blogForm.attr("action", previewAction)
			.attr("target", "_blank")
			.submit();

	// reset
	blogForm.attr("action", defaultAction)
			.removeAttr("target");

});

動かしてみる

記述したスクリプトが正常に動くか試してみましょう。

actionとtargetはすぐに切り替わってしまうため、目視で変化が起こっているか確認するためには、後半の // reset 以下をコメントアウトして実行するとよく分かるかと思います。

ちゃんと表示されました。

投稿時間・更新時間のところがバグっていますが、記事のデータそのものはPOSTデータを利用しており、MySQLを参照していないのでUNIXタイムスタンプ=0の東京時間(+09:00)が表示されています。

もう少し詰めるべきポイント

プレビューボタンを押した場合でもバリデーションが適用されるため、例えばコンテンツが空っぽのままプレビューを表示しようとすると、バリデーションエラーが表示された同じ編集画面が別タブで表示されてしまいます。

何か問題があるかと言われれば何もないのですが、同じ記事の編集画面が別タブで2ページできてしまいどちらが最新かわかりづらくなってしまったり、CSRFトークンによる送信エラーの懸念などもあるので、対策をしないのであればエラー付きの新しいタブはすぐに消してしまうのが良いかもしれません。

全体を通しての感想

製作時間よりもこの記事執筆に掛けた時間の方が圧倒的に長かったです(笑)

細かく書いているため記事が長くなってしまいましたが、実際にプレビュー機能を実装するのはそこまで難しい課題ではありません。

むしろ、HTMLフォームやPHP(Laravel5)、Javascriptの基礎を応用したちょうどいい勉強材料だと感じました。

これから脱初心者にチャレンジする方にとっては、広い知識を融合しながらWeb製作を行う良い足掛かりになりそうです。

また別の機能を実装したら適宜まとめていきます。

この記事を書いた人

Webエンジニア PHPエンジニア HTML CSS JS jQuery PHP Laravel Python SQL WordPress AWS Linux Apache

【名前】 "みやび"

【関連】 株式会社PLAN / MIYABI Lab / JAPAN MENSA /

【MIYABI Lab】平日オフィスを勉強用に解放中!みんなで楽しくプログラミングを学べる環境を作る!詳しくはコチラ(https://miyabi-lab.space)◆HTML, CSS, JS, PHP, Python, SQL, AWS◆生物学系修士→製薬会社→Webエンジニア(株式会社PLAN)・MENSA会員

Twitterやってます

最新の技術ブログはこちら