PPW_kouridehiyasaretaramune_TP_V

CakePHP3とjquery-uiのSortableでフォームをグリグリと動かす

  • このエントリーをはてなブックマークに追加
  • Evernoteに保存

この記事はCakePHP Advent Calendar 2017の24日目の記事です。
昨日は@yuzoiwasakiさんの「後からアソシエーションを制御する方法いろいろ」でした。

みなさん、突然ですがフォームをグリグリ動かしたくないですか?

sortable

CakePHPで簡単にフォームをグリグリ動かしたくないですか??

今回はそんな思いから、CakePHP3にjquery-uiのSortableフォームを導入した記録を残しておきます。

jquery-uiの導入

jquery-uiのSortableについてはこちらにサンプルがあります。
実装に必要なJSファイルはCDNでも提供されているので今回はそちらを利用しましょう。

$this->Html->script('https://code.jquery.com/jquery-2.2.4.min.js');
$this->Html->script('https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js');

ViewかTemplateあたりで読み込んでおきましょう。

<ul class="todo-list">
	<li>
		<span class="handle">+</span>
		<div class="text">
			<input type="text" name="categories[]">
		</div>
	</li>
	<li>
		<span class="handle">+</span>
		<div class="text">
			<input type="text" name="categories[]">
		</div>
	</li>
</ul>
$(".todo-list").sortable({
	placeholder: "sort-highlight",
	handle: ".handle",
	forcePlaceholderSize: true,
	zIndex: 999999
});

あとはこんな感じでHTMLとJSを書けばフロント側は完成です。
ここからCSSを整える必要はありますが、とりあえずグリグリ動かせるフォームが出来上がります。

ちなみにbootstrapベースの管理画面用CSSプラグイン AdminLTEを導入していると、このコードだけでもそこそこ綺麗な可動式フォームができるようです。

システムを組み込む

それでは実際にjquery-uiのSortableを使用したフォームにシステムを組み込んでみましょう。
ここでは仮にブログシステムのようなものを想定し、記事を管理するPostモデルとそれに対して一対多で紐づくCategoryモデル、そして多対多で紐づくTagモデルを作成しておきます。

作成したテーブルの略図を載せておきますね。

posts

id integer ID
title varchar 記事タイトル
body text 本文

categories

id integer ID
name varchar カテゴリ名
post_id integer 記事ID
order_num integer 表示順

tags

id integer ID
name varchar タグ名

posts_tags

id integer ID
post_id integer 記事ID
tag_id integer タグID
order_num integer 表示順

bakeコマンドで勝手にいろいろ作ってもらうため、テーブル名やカラム名はできる限りCakePHPの命名規則に沿ったものを作るといいと思います。
そのほかSortableフォームを作成する際のポイントですが、表示順を保持しておくためのカラムを用意しておくことでしょうか。一対多であればbelongsTo側のテーブルに用意しますが、多対多の場合は中間テーブルに保持しないといけません。

一対多のSortableフォーム

まずは一対多で紐づくPostsとCategoriesのフォームを実装してみましょう。

Template

<?= $this->Form->create($post) ?>
	...
	<ul class="todo-list" id="categoryForm">
		<?php if ($post->categories) : ?>
			<?php foreach ($post->categories as $category) : ?>
				<li>
					<span class="handle">+</span>
					<div class="text">
						<?= $this->Form->control('categories..name', ['value' => $category->name]) ?>
					</div>
					<?= $this->Form->button(__('削除'), ['type' => 'button', 'class' => 'category-delete-btn']) ?>
				</li>
			<?php endforeach; ?>
		<?php endif; ?>
		<li style="display: none" id="newCategoryForm">
			<span class="handle">+</span>
			<div class="text">
				<?= $this->Form->control('categories..name') ?>
			</div>
			<?= $this->Form->button(__('削除'), ['type' => 'button', 'class' => 'category-delete-btn']) ?>
		</li>
	</ul>
	<?= $this->Form->button(__('カテゴリ追加'), ['type' => 'button', 'id' => 'categoryAddBtn']) ?>

	...
<?= $this->Form->end() ?>

Templateに記述するのはこんな感じです。
addでもeditでも使いまわせるように、$postで渡されたEntityにcategoriesが紐づいていた場合はそれをフォームとして表示させておき、それぞれに削除ボタンと、新しくカテゴリを追加できるボタンを設置します。

ポイントは下記の通り。

FormHelper::control()の第一引数の書き方

一対多のリレーションを登録するフォームであれば基本的に

$this->Form->control('<リレーション名>.<順番>.<カラム名>');

のようにすることで登録ができますが、Sortableフォームでグリグリ好き勝手に動かされちゃうと順番の管理が面倒なので、順番のところを空欄にしておいて勝手に入るようにしておきます。
(このやり方だと一度に複数カラムを登録するときに面倒なんですが、ここでは見ないふりをします)

登録済みカテゴリのvalue値設定

上記で順番を指定しないようにしてしまったため、editしたときなどに自動でvalue値を取ってこれなくなります。ちょっとダサいですが手動で引っ張ってきましょう。

なお、フォーム追加ボタンのJSとフォーム削除ボタンのJSはそれぞれこのようになります。

$("#categoryAddBtn").on("click", function () {
	var categoryForm = $("#newCategoryForm").clone(true);
	$(categoryForm).attr("id", "");
	$(categoryForm).show();
	$("#categoryForm").append(categoryForm);
});
$(".category-delete-btn").on("click", function () {
	$(this).parent().remove();
});

Controller

次はControllerでの処理を見ていきましょう。
基本的にはbakeコマンドで生成されるadd()の処理を流用しますが、少しだけ処理を加えます。

public function add()
{
	$post = $this->Posts->newEntity();
	if ($this->request->is('post')) {
		$postData = $this->request->getData();

		$categories = [];
		$i = 1;
		foreach ($postData['categories'] as $category) {
			if (!empty($category['name'])) {
				$categories[] = ['name' => $category['name'], 'order_num' => $i];
				$i++;
			}
		}
		$postData['categories'] = $categories;

		$post = $this->Posts->patchEntity($post, $postData);
		if ($this->Posts->save($post)) {
...

追加した処理でやっていることとしては、空欄で追加されたカテゴリの削除と表示順制御カラムorder_numの追加ぐらいです。

Model

あとはModelの設定もちょこちょこ変更します。PostsTableのinitialize()を見ていきます。

$this->hasMany('Categories', [
	'foreignKey' => 'post_id',
	'sort' => ['order_num' => 'asc'],
	'saveStrategy' => 'replace',
]);
'sort' => ['order_num' => 'asc'],

リレーションでデータを取得した際の表示順をorder_numの昇順に設定しておきます

'saveStrategy' => 'replace',

これがないとフォームを編集するたびにカテゴリがどんどん増えていきます。
なお、categoriesテーブルのpost_idカラムにNOT NULL制約を付けていないと、フォームを編集するたびに既存のレコードは削除されずにpost_idがNULLになってレコード自体は残り続けます。これもなんだか気持ち悪いので気を付けましょう。
これで、グリグリ動かせるカテゴリ追加フォームができたかと思います!
次はこのままタグ追加フォームに行ってみましょう!!

多対多のSortableフォーム

ここでPostsとTagsの関係はbelongsToMany、つまり多対多を想定しています。
CategoriesのようにPostsを追加・編集する際に都度レコードを作成していく形式ではなく、すでに作成されたTagsのレコードの中からセレクトボックスなりで選び取っていく形です。
カテゴリと同様にこちらも自由に追加、削除ができるフォームを作成していきましょう。

Template

<ul class="todo-list" id="tagForm">
	<?php if ($post->tags) : ?>
		<?php foreach ($post->tags as $tag) : ?>
			<li>
				<span class="handle">+</span>
				<div class="text">
					<?= $this->Form->control('tags..id', ['type' => 'select', 'options' => $tags, 'empty' => '-', 'value' => $tag->id]) ?>
				</div>
				<?= $this->Form->button(__('削除'), ['type' => 'button', 'class' => 'tag-delete-btn']) ?>
			</li>
		<?php endforeach; ?>
	<?php endif; ?>
	<li style="display: none" id="newTagForm">
		<span class="handle">+</span>
		<div class="text">
			<?= $this->Form->control('tags..id', ['type' => 'select', 'options' => $tags, 'empty' => '-']) ?>
		</div>
		<?= $this->Form->button(__('削除'), ['type' => 'button', 'class' => 'tag-delete-btn']) ?>
	</li>
</ul>
<?= $this->Form->button(__('タグ追加'), ['type' => 'button', 'id' => 'tagAddBtn']) ?>

まずは、Templateです。
だいたいは先ほどのCategoriesフォームを踏襲していますが、ところどころ変えています。
表示順を挿入する関係で、多対多リレーションに便利なtags._idsは使っていません。

Controller

次にControllerの処理はこんな感じです。

public function add()
{
	$post = $this->Posts->newEntity();
	if ($this->request->is('post')) {
		$postData = $this->request->getData();

		$tags = [];
		$j = 1;
		foreach ($postData['tags'] as $tag) {
			if (!empty($tag['id'])) {
				$tags[] = ['id' => $tag['id'], '_joinData' => ['order_num' => $j]];
				$j++;
			}
		}
		$postData['tags'] = $tags;

		$post = $this->Posts->patchEntity($post, $postData);
		if ($this->Posts->save($post)) {
...

ここでポイントになるのは

$tags[] = ['id' => $tag['id'], '_joinData' => ['order_num' => $j]];

この処理です。
カテゴリと同様に表示順を登録しますが、タグの場合は中間テーブルposts_tagsに登録しなければなりません。
その場合、_joinDataというキーを使用すると、登録した中間テーブルのレコードに対してデータを挿入することができます。

参考:
cakePHP3でbelongsToMany

Model

Modelの設定もしてやります。
PostsTableのinitialize()内で、下記のリレーション設定を定義してあげてください。

$this->belongsToMany('Tags', [
	'foreignKey' => 'post_id',
	'targetForeignKey' => 'tag_id',
	'joinTable' => 'posts_tags',
	'sort' => ['order_num' => 'asc'],
]);

(ここでのsort設定は特に何も考えずに書いてもposts_tagsテーブルのデータを見てくれるようです。便利ですね。)
いかがでしたでしょうか??
これにてSortableなカテゴリ追加/タグ追加フォームができたかと思います。
この記事を見たフォーム作成に苦しむCakePHPerの皆様が少しでも救われますように!

明日はいよいよCakePHP Advent Calendar 2017最終日、@chinpei215さんの担当です!

ここまでお読みいただきありがとうございました!
よい聖夜をお過ごしください。

  • このエントリーをはてなブックマークに追加
  • Evernoteに保存