📝

a-blog cmsでカテゴリーを一括登録してみる

2024/12/10に公開

htmxとa-blog cms

https://htmx.org/
htmxは、簡単な設定で静的HTMLページに動的なコンテンツ書き換えが実装できる軽量なJavaScriptライブラリーです。

htmx gives you access to AJAX, CSS Transitions, WebSockets and Server Sent Events directly in HTML, using attributes, so you can build modern user interfaces with the simplicity and power of hypertext
htmx is small (~14k min.gz’d), dependency-free, extendable & has reduced code base sizes by 67% when compared with react

a-blog cmsには、ポストインクルードという、フォームをpostして取得したデータをページのリロードなしで表示する仕組みがあり、htmxは、それのさらに高速・高機能版として代替できるとして、a-blog cmsとの親和性が高いと公式も推しています。

一般的にはフロント側で使うと便利なものですが、それを管理画面で何か使えないかと考えてみました。

カテゴリーを連続登録させてみる

a-blog cmsは、エントリーをCSVなどで一括登録することはできますが、カテゴリーは一つずつ登録していく必要があります。これまでも、サイトの立ち上げ時に複数登録する際に、ちょっとめんどくさいなと思ったことが少なからずありました。
手で登録するコストと複数カテゴリー一括登録の機能拡張を作るコストを比較すると、まあ手でやるよなぁ…と我慢してきたところでした。
そこで、ページのリロードなしでPOSTができるのであれば、カテゴリー登録フォームをハックして、連続でカテゴリー情報をPOSTしたら、実質一括登録になるのでは…?と思いつきました。

必要な要素

登録に必要と思われる要素は次のようなものです。

  1. カテゴリー登録フォーム(属性設定、登録ボタン)
  2. カテゴリー一覧(親カテゴリー選択・登録状況確認用)
  3. 新たに登録するカテゴリーのコードと名前の入力らん

カテゴリー登録フォーム

具体的なフォームの書式は、実際のカテゴリー登録画面のHTML/bid/1/admin/category_edit/?edit=insertから部分的にパクってくることにしました。
最低限必要と思われるフィールドは次のとおり。
これらの情報を、JSで取得して随時設定します。

<input type="hidden" name="category[]" value="name"><!-- カテゴリー名 -->
<input type="hidden" name="category[]" value="code"><!-- カテゴリーコード -->
<input type="hidden" name="category[]" value="parent"><!-- 親カテゴリー -->
<input type="hidden" name="category[]" value="scope"><!-- 下の階層のブログが利用することを許可するかどうか -->
<input type="hidden" name="category[]" value="status"><!-- 公開状態 -->
<input type="hidden" name="category[]" value="indexing"><!-- 一覧に表示するかどうか -->

そして、追加ボタンは、

<button type="submit" name="ACMS_POST_Category_Insert">追加</button>

nameがACMS_POST_Category_Insertとなっています。
これらをまとめて、後ほど登録フォームにします。
登録フォームのファイルは、使用するテーマフォルダの/admin/category/insert.htmlとして作成します。

カテゴリー一覧

ここは、一番わかりやすいhtmxの例になるかと思います。
使用するテーマフォルダに/include/htmx/category_list.htmlというファイルを作成。

/include/htmx/category_list.html
<!-- BEGIN_MODULE Touch_SessionWithAdministration -->
<!-- BEGIN_MODULE Category_List ctx="bid/%{BID}" -->
@include("/admin/module/setting.html")
	<!-- BEGIN category:loop --><!-- BEGIN ul#front -->
	<ul class="acms-admin-ul">
		<!-- END ul#front --><!-- BEGIN li#front -->
		<li><!-- END li#front --><!-- BEGIN category:veil -->
			<a href="{url}">{name}({ccd})</a><!-- END category:veil --><!-- BEGIN li#rear -->
		</li><!-- END li#rear --><!-- BEGIN ul#rear -->
	</ul>
	<!-- END ul#rear --><!-- END category:loop -->
<!-- END_MODULE Category_List -->
<!-- END_MODULE Touch_SessionWithAdministration -->

このファイルを、/admin/category/insert.htmlから呼び出して一覧を表示します。

/admin/category/insert.htmlの抜粋
<div class="acms-admin-margin-top-medium">
	<h2 class="acms-admin-admin-title3">現在のカテゴリー一覧</h2>
	<div id="category_list">
		@include("/include/htmx/category_list.html")
	</div>

	<form hx-post="" hx-ext="ajax-header" hx-trigger="submit" hx-target="#category_list" hx-swap="innerHTML">
		<input type="hidden" name="bid" value="%{BID}">
		<input type="hidden" name="tpl" value="/include/htmx/category_list.html">
		<button type="submit" name="ACMS_POST_2GET" id="current_list">カテゴリーリストを手動更新</button>
	</form>
	<script src="https://unpkg.com/htmx.org@2.0.3"></script>
	<script src="https://unpkg.com/htmx.org/dist/ext/ajax-header.js"></script>
</div>

初回はincludeで読み出して表示させ、それ以降はsubmitボタンを押すと、htmxを利用して動的に#category_listの中が書きかわります。
htmxのライブラリーをCDNから読み込んでいますが、実際に使用する際には、ダウンロードしたファイルをテーマフォルダ内に設置するか、webpackやviteなどでバンドルしてください。

新たに登録するカテゴリーのコードと名前の入力らん

登録するカテゴリーに必要な情報は上記の通りですので、それらをまとめて一つのフォームにして/admin/category/insert.htmlに追記します。

/admin/category/insert.htmlに追記
<div class="acms-admin-margin-top-medium">
	<h2 class="acms-admin-admin-title3">登録カテゴリー名</h2>
	<div style="display:flex; gap:2em;" class="acms-admin-form">
		<div>
			<label for="category_name" class="acms-admin-form-block">カテゴリー名</label>
			<textarea name="" rows="10" id="category_name" class="acms-admin-margin-top-mini"></textarea>
		</div>
		<div>
			<label for="category_code" class="acms-admin-form-block">カテゴリーコード</label>
			<textarea name="" rows="10" id="category_code" class="acms-admin-margin-top-mini"></textarea>
		</div>
	</div>
	<p>カテゴリー名と、それに対応するカテゴリーコードを一行ずつ入力してください。</p>

	<form action="" class="acms-admin-form" hx-post="" hx-ext="ajax-header" hx-swap="none" hx-trigger="submit" style="margin-top: 20px;" id="insert_form">
		<h2 class="acms-admin-admin-title3">共通設定</h2>
		<table class="adminTable acms-admin-table-admin-edit">
			<tr>
				<th>ステータス</th>
				<td>
					<select name="status" class="js-select2">
						<option value="open" selected="selected">公開</option>
						<option value="close">非公開</option>
						<option value="secret">シークレット</option>
					</select>
				</td>
			</tr>
			<tr>
				<th>親カテゴリー</th>
				<td>
					<!-- BEGIN_MODULE Category_List -->
					<select name="parent">
						<option value=""></option><!-- BEGIN category:loop --><!-- BEGIN li#front -->
						<option value="{cid}"><!-- END li#front --><!-- BEGIN category:veil -->
							{name}({ccd})<!-- END category:veil --><!-- BEGIN li#rear -->
						</option><!-- END li#rear --><!-- END category:loop -->
					</select>
					<!-- END_MODULE Category_List -->
				</td>
			</tr>
			<tr>
				<th>インデキシング</th>
				<td>
					<div class="acms-admin-form-checkbox">
						<input type="checkbox" name="indexing" value="on" checked="checked" id="input-checkbox-indexing">
						<label for="input-checkbox-indexing" class="acms-admin-form-checkbox">
							<i class="acms-admin-ico-checkbox"></i>
							リストに出す
						</label>
					</div>
				</td>
			</tr>
		</table>
		<input type="hidden" name="scope" value="local">
		<input id="input_name" type="hidden" name="name" class="acms-admin-form-width-mini">
		<input id="input_code" type="hidden" name="code" class="acms-admin-form-width-mini">

		<input type="hidden" name="category[]" value="name">
		<input type="hidden" name="category[]" value="code">
		<input type="hidden" name="category[]" value="parent">
		<input type="hidden" name="category[]" value="scope">
		<input type="hidden" name="category[]" value="status">
		<input type="hidden" name="category[]" value="indexing">
		<button type="submit" name="ACMS_POST_Category_Insert" class="acms-admin-hide" id="submit">追加</button>
	</form>

	<button type="button" class="acms-admin-btn acms-admin-btn-large acms-admin-margin-top-large" id="run">カテゴリーを追加</button>
</div>

追加ボタン<button type="submit" name="ACMS_POST_Category_Insert" class="acms-admin-hide" id="submit">追加</button>は、htmxのイベントで随時押されるようにするため、ユーザーからはみないようにしてあります。
かわりに、ユーザーは別の一括追加ボタン<button type="button" class="acms-admin-btn acms-admin-btn-large acms-admin-margin-top-large" id="run">カテゴリーを追加</button>をクリックします。

動作のJavaScript

/admin/category/category_insert.js
document.addEventListener("DOMContentLoaded", function () {
	const code_list = document.getElementById("category_code");
	const name_list = document.getElementById("category_name");
	const submitButton = document.getElementById("submit");
	const runButton = document.getElementById('run');
	const insertForm = document.getElementById("insert_form");

	if (!htmx || !code_list || !name_list || !submitButton || !runButton) {
		console.error("必須要素が見つかりません。");
		return;
	}
	const runButtonLabel = runButton.innerText;

	let index = 0;
	const stats = {
		codes: [],
		names: []
	};

	function triggerFormSubmit(idx) {
		console.log(`triggerFormSubmit:${idx}`);
		document.getElementById("input_code").value = stats.codes[idx];
		document.getElementById("input_name").value = stats.names[idx];
		submitButton.click();
	}

	insertForm.addEventListener('htmx:beforeRequest', function (evt) {
		console.log("追加中");
	});

	insertForm.addEventListener('htmx:afterRequest', function (evt) {
		if (index < stats.codes.length - 1) {
			index += 1;
			triggerFormSubmit(index);
		} else {
			console.log("追加完了");
			const currentList = document.getElementById("current_list");
			if (currentList) {
				currentList.click();
			}
			runButton.disabled = false;
			runButton.innerText = runButtonLabel;
		}
	});

	runButton.addEventListener('click', function () {
		const codes = code_list.value.trim();
		const names = name_list.value.trim();

		// カテゴリーコードと名前が空でないかチェック
		if (!codes || !names) {
			alert("カテゴリー名と対応するカテゴリーコードを入力してください。");
			return;
		}

		// カテゴリーを改行で分割し配列に変換
		stats.codes = codes.split("\n");
		stats.names = names.split("\n");

		// コードと名前の数が一致するかを確認
		if (stats.codes.length !== stats.names.length) {
			alert("カテゴリー名とカテゴリーコードの数が一致しません。");
			return;
		}

		runButton.disabled = true;
		runButton.innerText = "カテゴリー追加中。おまちください。";
		index = 0;  // 送信開始時にインデックスをリセット
		// 最初の送信をトリガー
		triggerFormSubmit(index);
	});
});

動作の流れはおおよそ次のとおりです。

  1. 一括登録のボタンを押すと、カテゴリーIDとカテゴリーコードのテキストエリアの文字列を改行で分割して配列にする
  2. カテゴリーIDとカテゴリーコードをフォームに設定し、一発目のカテゴリー登録フォームの送信を行う
  3. htmxのイベントリスナー htmx:afterRequestで、フォーム送信完了後にあらためて配列の残りを確認し、候補があったらカテゴリーIDとカテゴリーコードを変更して再度登録フォームを送信する。一覧の更新も行う
    上記はおおざっぱなエラー確認しかしていないので、もし実務利用するなら、いろいろとフェールセーフは必要になるかと思います。

完成品

https://github.com/kadoyan/acms_insert_categories/tree/main
こちらが上記をまとめたものです。
ファイルを3つコピーすれば、どのテーマでも利用できるはずです。
自分の場合、規模的に業務でReactのようなものを使うことがないので(Vueはちょっとある。Svelteは趣味で触った)、htmxの手軽さと実用度の高さはとても期待しています。
色々な場面で使えそうだなという手応えを感じました。

Discussion