htmx + Node-REDでどこまでイケるか?
前回、htmx + Node-RED 超入門的な記事を書きました。
サーバサイドから部分的に HTML を返すコンセプトらしいので、これに則って Web アプリ/サイトの作り方を考察して実際に試してみます。
ナビゲーションとコンテンツ
まずは、 Web サイト/アプリで必ず存在するナビゲーション(メニュー)をクリックするとコンテンツが切り替わるという動きをやっていきます。
Web アプリ/サイトの画面構成
言わずもがなですが、Web アプリ/サイトの画面構成として以下のようにブロックに分かれてます(今回はひとまずデスクトップ画面に限定)
上図はどちらかというと Web サイトです(Web アプリだとキービジュアルなど幾つかのブロックが削減されるイメージ)
グローバルナビゲーション
グローバルナビゲーションで考慮しないといけないのがアクティブ制御です。
メニュー項目をクリックすると、それに対応したコンテンツがコンテンツエリアに表示されますが、これだけなら前回の内容だけで充分です。
問題はクリックしたメニュー項目がアクティブな状態としてユーザーにわかりやすく伝わらなければいけない点です。
文章で書いてもピンとこないと思いますが以下のように現在の表示中のコンテンツが属するメニュー項目に色をつけるなどして強調することです。
現在選択されているメニュー項目を強調表示するには CSS と HTML だけで可能です。
以下の ようにメニュー項目に active
と class 指定することで、対応する CSS の設定が効いてきます。
<style>
.active {
background-color: lightblue;
}
</style>
<div>
<a href="/content1" class="active">Content 1</a>
<a href="/content2">Content 2</a>
<a href="/content3">Content 3</a>
</div>
通常は、この class に active
を動的に付与する部分を javascript でやるわけですが、今回 htmx + Node-RED だけでやるとどうなるかという話ですね。
初期表示画面のフロー
それでは、まずは Node-RED で初期表示画面のフローを作ります。フローは以下のような感じです。
ここで小ネタですが、フローライブラリで CSS や HTML を複数の template ノードに分けて管理する方法がありましたので、template ノードの内部のコードをできる限り少なくするために真似してみました。
CSS を記述している tmplate ノードの中身は以下のような感じです。アクティブの設定のほか、メニュー項目やコンテンツのデザインを多少加えています。
.nav-item {
padding: 10px;
cursor: pointer;
display: inline-block;
}
.active {
background-color: lightblue;
}
#content-area {
margin-top: 20px;
padding: 20px;
border: 1px solid #ddd;
}
赤枠で囲ってますが、一旦 msg.css
などの msg.payload
以外のプロパティへ CSS を保管するイメージです。
続いて、HTML を記述する template ノードの中身です。以下のように、 Mustache 記法で msg.css
に保管している CSS を読み込みます。こうすることで HTML を記述する template ノードに CSS が混ざらず、シンプルに管理することができます。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>HTMX Example</title>
<script src="https://unpkg.com/htmx.org"></script>
<style>{{{css}}}</style>
</head>
<body>
<div id="global-nav">
<a href="/content1" hx-get="/content1" class="nav-item active">Content 1</a>
<a href="/content2" hx-get="/content2" class="nav-item">Content 2</a>
<a href="/content3" hx-get="/content3" class="nav-item">Content 3</a>
</div>
<div id="content-area">
<h1>Content 1</h1>
</div>
</body>
</html>
これで以下のように表示されます。
さて、ここまでやって気づきましたが、メニュー項目のアクティブ状態を切り替えつつコンテンツも変更するということは、1度の htmx トリガでページ内の複数箇所を更新する必要があります。
こういう時に hx-swap-oob
を使うようです。
簡単に言うとサーバからのレスポンスに hx-swap-oob
を持つ複数の HTML タグを記述すれば、対象のエレメントを書き換えることができます。
今回ですと以下のようにサーバから返すとグローバルナビゲーションとコンテンツエリアの両方を1回のリクエストで書き換えることが可能になります。
<div id="global-nav" hx-swap-oob="true">
<a href="/content1" hx-get="/content1" class="nav-item active">Content 1</a>
<a href="/content2" hx-get="/content2" class="nav-item">Content 2</a>
<a href="/content3" hx-get="/content3" class="nav-item">Content 3</a>
</div>
<div id="content-area" hx-swap-oob="true">
<h1>Content 1</h1>
</div>
※ hx-swap-oob
の値は true
か直接対象のIDをセットすることができます
メニュー項目を選択した後のフロー
実際にメニュー項目がクリックされた際にグローバルナビゲーションとコンテンツエリアを更新してみます。
各メニュー項目をクリックしたら hx-get
によって各コンテンツのエンドポイント( /content1
, /content2
, /content3
)へリクエストが飛びますので以下のようにエンドポイントを作成します。
それぞれの template ノードに hx-swap-oob
を利用したサーバレスポンスを記述します。
/content1
の templateノード
<div id="global-nav" hx-swap-oob="true">
<a href="/content1" hx-get="/content1" class="nav-item active">Content 1</a>
<a href="/content2" hx-get="/content2" class="nav-item">Content 2</a>
<a href="/content3" hx-get="/content3" class="nav-item">Content 3</a>
</div>
<div id="content-area" hx-swap-oob="true">
<h1>Content 1</h1>
</div>
/content2
の templateノード
<div id="global-nav" hx-swap-oob="true">
<a href="/content1" hx-get="/content1" class="nav-item">Content 1</a>
<a href="/content2" hx-get="/content2" class="nav-item active">Content 2</a>
<a href="/content3" hx-get="/content3" class="nav-item">Content 3</a>
</div>
<div id="content-area" hx-swap-oob="true">
<h1>Content 2</h1>
</div>
/content3
の templateノード
<div id="global-nav" hx-swap-oob="true">
<a href="/content1" hx-get="/content1" class="nav-item">Content 1</a>
<a href="/content2" hx-get="/content2" class="nav-item">Content 2</a>
<a href="/content3" hx-get="/content3" class="nav-item active">Content 3</a>
</div>
<div id="content-area" hx-swap-oob="true">
<h1>Content 3</h1>
</div>
以上で思った通りの動きになります。
フローJSONは以下です。
[
{
"id": "bf73777f47226551",
"type": "http in",
"z": "d72856f1b5016722",
"name": "",
"url": "/nav",
"method": "get",
"upload": false,
"swaggerDoc": "",
"x": 160,
"y": 340,
"wires": [
[
"f6dfa00849013a3d"
]
]
},
{
"id": "463c73f2a36fb471",
"type": "template",
"z": "d72856f1b5016722",
"name": "",
"field": "payload",
"fieldType": "msg",
"format": "html",
"syntax": "mustache",
"template": "<!DOCTYPE html>\n<html lang=\"ja\">\n\n<head>\n <meta charset=\"UTF-8\">\n <title>HTMX Example</title>\n <script src=\"https://unpkg.com/htmx.org\"></script>\n <style>{{{css}}}</style>\n</head>\n\n<body>\n\n <div id=\"global-nav\">\n <a href=\"/content1\" hx-get=\"/content1\" class=\"nav-item active\">Content 1</a>\n <a href=\"/content2\" hx-get=\"/content2\" class=\"nav-item\">Content 2</a>\n <a href=\"/content3\" hx-get=\"/content3\" class=\"nav-item\">Content 3</a>\n </div>\n\n <div id=\"content-area\">\n <h1>Content 1</h1>\n </div>\n\n</body>\n\n</html>",
"output": "str",
"x": 500,
"y": 340,
"wires": [
[
"335e544cc418edfb"
]
]
},
{
"id": "f6dfa00849013a3d",
"type": "template",
"z": "d72856f1b5016722",
"name": "css",
"field": "css",
"fieldType": "msg",
"format": "css",
"syntax": "mustache",
"template": ".nav-item {\n padding: 10px;\n cursor: pointer;\n display: inline-block;\n}\n\n.active {\n background-color: lightblue;\n}\n\n#content-area {\n margin-top: 20px;\n padding: 20px;\n border: 1px solid #ddd;\n}",
"output": "str",
"x": 330,
"y": 340,
"wires": [
[
"463c73f2a36fb471"
]
]
},
{
"id": "335e544cc418edfb",
"type": "http response",
"z": "d72856f1b5016722",
"name": "",
"statusCode": "",
"headers": {},
"x": 690,
"y": 420,
"wires": []
},
{
"id": "0e1f2f06069c7580",
"type": "http in",
"z": "d72856f1b5016722",
"name": "",
"url": "/content1",
"method": "get",
"upload": false,
"swaggerDoc": "",
"x": 300,
"y": 400,
"wires": [
[
"5f9b9d0ac89ee71f"
]
]
},
{
"id": "5f9b9d0ac89ee71f",
"type": "template",
"z": "d72856f1b5016722",
"name": "",
"field": "payload",
"fieldType": "msg",
"format": "html",
"syntax": "mustache",
"template": "<div id=\"global-nav\" hx-swap-oob=\"true\">\n <a href=\"/content1\" hx-get=\"/content1\" class=\"nav-item active\">Content 1</a>\n <a href=\"/content2\" hx-get=\"/content2\" class=\"nav-item\">Content 2</a>\n <a href=\"/content3\" hx-get=\"/content3\" class=\"nav-item\">Content 3</a>\n</div>\n\n<div id=\"content-area\" hx-swap-oob=\"true\">\n <h1>Content 1</h1>\n</div>",
"output": "str",
"x": 500,
"y": 400,
"wires": [
[
"335e544cc418edfb"
]
]
},
{
"id": "909404b2292e2b37",
"type": "template",
"z": "d72856f1b5016722",
"name": "",
"field": "payload",
"fieldType": "msg",
"format": "html",
"syntax": "mustache",
"template": "<div id=\"global-nav\" hx-swap-oob=\"true\">\n <a href=\"/content1\" hx-get=\"/content1\" class=\"nav-item\">Content 1</a>\n <a href=\"/content2\" hx-get=\"/content2\" class=\"nav-item active\">Content 2</a>\n <a href=\"/content3\" hx-get=\"/content3\" class=\"nav-item\">Content 3</a>\n</div>\n\n<div id=\"content-area\" hx-swap-oob=\"true\">\n <h1>Content 2</h1>\n</div>",
"output": "str",
"x": 500,
"y": 460,
"wires": [
[
"335e544cc418edfb"
]
]
},
{
"id": "f711141c19bbe7f3",
"type": "template",
"z": "d72856f1b5016722",
"name": "",
"field": "payload",
"fieldType": "msg",
"format": "html",
"syntax": "mustache",
"template": "<div id=\"global-nav\" hx-swap-oob=\"true\">\n <a href=\"/content1\" hx-get=\"/content1\" class=\"nav-item\">Content 1</a>\n <a href=\"/content2\" hx-get=\"/content2\" class=\"nav-item\">Content 2</a>\n <a href=\"/content3\" hx-get=\"/content3\" class=\"nav-item active\">Content 3</a>\n</div>\n\n<div id=\"content-area\" hx-swap-oob=\"true\">\n <h1>Content 3</h1>\n</div>",
"output": "str",
"x": 500,
"y": 520,
"wires": [
[
"335e544cc418edfb"
]
]
},
{
"id": "06689ca94bff9986",
"type": "http in",
"z": "d72856f1b5016722",
"name": "",
"url": "/content2",
"method": "get",
"upload": false,
"swaggerDoc": "",
"x": 300,
"y": 460,
"wires": [
[
"909404b2292e2b37"
]
]
},
{
"id": "873f08f993fe5796",
"type": "http in",
"z": "d72856f1b5016722",
"name": "",
"url": "/content3",
"method": "get",
"upload": false,
"swaggerDoc": "",
"x": 300,
"y": 520,
"wires": [
[
"f711141c19bbe7f3"
]
]
}
]
フォーム
次は htmx + Node-RED でフォーム開発をやっていきます。この例では日本の問い合わせフォームにありがちな「入力画面」→「入力値確認画面」→「送信」 or 「修正」という画面遷移でやってみます。
template ノードに HTML でフォームを作るだけ
基本的には以下のように template ノードに HTML でフォームを書くだけです。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Contact Form with HTMX</title>
<script src="https://unpkg.com/htmx.org"></script>
<style>{{{css}}}</style>
</head>
<body>
<form id="contact-form" hx-post="/confirm-form" hx-swap="innerHTML">
<div class="form-group">
<label for="name">名前:</label>
<input type="text" id="name" name="name" value="{{{payload.name}}}" required><br>
</div>
<div class="form-group">
<label for="email">メールアドレス:</label>
<input type="email" id="email" name="email" value="{{{payload.email}}}" required><br>
</div>
<div class="form-group">
<label for="zipcode">郵便番号:</label>
<input type="text" id="zipcode" name="zipcode" value="{{{payload.zipcode}}}" pattern="^\d{3}-\d{4}$" required><br>
</div>
<div class="form-group">
<label for="prefecture">都道府県:</label>
<input type="text" id="prefecture" name="prefecture" value="{{{payload.prefecture}}}" required><br>
</div>
<div class="form-group">
<label for="city">市区町村:</label>
<input type="text" id="city" name="city" value="{{{payload.city}}}" required><br>
</div>
<div class="form-group">
<label for="address">住所:</label>
<input type="text" id="address" name="address" value="{{{payload.address}}}" required><br>
</div>
<div class="form-group">
<label for="building">ビル・マンション名:</label>
<input type="text" id="building" name="building" value="{{{payload.building}}}"><br>
</div>
<div class="form-group">
<label for="phone">電話番号:</label>
<input type="tel" id="phone" name="phone" pattern="^\d{2,4}-\d{2,4}-\d{4}$" value="{{{payload.phone}}}" required><br>
</div>
<div class="form-group">
<label for="content">問い合わせ内容:</label>
<textarea id="content" name="content" required>{{{payload.content}}}</textarea><br>
</div>
<div class="button-container">
<button type="submit">確認</button>
</div>
</form>
</body>
</html>
required
属性をつけるだけで必須入力項目になります。
pattern="^\d{3}-\d{4}$"
のような属性を追加するだけで入力値のバリデーションが可能です。
入力確認画面
続いて Node-RED のフローにフォームの送信先 /confirm-form
のエンドポイントを追加します。
入力確認画面として返す template ノードの HTML は以下です。
<form id="contact-form">
<input type="hidden" name="name" value="{{{payload.name}}}" />
<input type="hidden" name="email" value="{{{payload.email}}}" />
<input type="hidden" name="zipcode" value="{{{payload.zipcode}}}" />
<input type="hidden" name="prefecture" value="{{{payload.prefecture}}}" />
<input type="hidden" name="city" value="{{{payload.city}}}" />
<input type="hidden" name="address" value="{{{payload.address}}}" />
<input type="hidden" name="building" value="{{{payload.building}}}" />
<input type="hidden" name="phone" value="{{{payload.phone}}}" />
<input type="hidden" name="content" value="{{{payload.content}}}" />
<h2>入力内容の確認</h2>
<p>名前: <span id="confirm-name">{{{payload.name}}}</span></p>
<p>メールアドレス: <span id="confirm-email">{{{payload.email}}}</span></p>
<p>郵便番号: <span id="confirm-zipcode">{{{payload.zipcode}}}</span></p>
<p>都道府県: <span id="confirm-prefecture">{{{payload.prefecture}}}</span></p>
<p>市区町村: <span id="confirm-city">{{{payload.city}}}</span></p>
<p>住所: <span id="confirm-address">{{{payload.address}}}</span></p>
<p>ビル・マンション名: <span id="confirm-building">{{{payload.building}}}</span></p>
<p>電話番号: <span id="confirm-phone">{{{payload.phone}}}</span></p>
<p>問い合わせ内容: <span id="confirm-content">{{{payload.content}}}</span></p>
<button hx-post="/send-form" hx-target="#contact-form" hx-swap="innerHTML">この内容で送信</button>
<button hx-post="/edit-form" hx-target="#contact-form" hx-swap="innerHTML">修正する</button>
</form>
フォームの入力値を hidden
で引き回してますが、 Node-RED のコンテキストを使わない理由はセッション管理していないからです。安易に Node-RED のコンテキストでデータを保持してしまうと、同じタイミングでフォームにアクセスした別の人に値が漏洩してしまう可能性があります。
修正・送信
あとは、フォームデータ送信のエンドポイント /send-form
と、入力値修正画面のエンドポイント /edit-form
を作成します。
/send-form
の template ノードの HTML
<div id="contact-form">
<h2>送信しました</h2>
<button hx-get="/form" hx-target="#contact-form" hx-swap="innerHTML">戻る</button>
</div>
/edit-form
の template ノードの HTML
<form id="contact-form" hx-post="/confirm-form" hx-swap="innerHTML">
<div class="form-group">
<label for="name">名前:</label>
<input type="text" id="name" name="name" value="{{{payload.name}}}" required><br>
</div>
<div class="form-group">
<label for="email">メールアドレス:</label>
<input type="email" id="email" name="email" value="{{{payload.email}}}" required><br>
</div>
<div class="form-group">
<label for="zipcode">郵便番号:</label>
<input type="text" id="zipcode" name="zipcode" value="{{{payload.zipcode}}}" pattern="^\d{3}-\d{4}$" required><br>
</div>
<div class="form-group">
<label for="prefecture">都道府県:</label>
<input type="text" id="prefecture" name="prefecture" value="{{{payload.prefecture}}}" required><br>
</div>
<div class="form-group">
<label for="city">市区町村:</label>
<input type="text" id="city" name="city" value="{{{payload.city}}}" required><br>
</div>
<div class="form-group">
<label for="address">住所:</label>
<input type="text" id="address" name="address" value="{{{payload.address}}}" required><br>
</div>
<div class="form-group">
<label for="building">ビル・マンション名:</label>
<input type="text" id="building" name="building" value="{{{payload.building}}}"><br>
</div>
<div class="form-group">
<label for="phone">電話番号:</label>
<input type="tel" id="phone" name="phone" pattern="^\d{2,4}-\d{2,4}-\d{4}$" value="{{{payload.phone}}}" required><br>
</div>
<div class="form-group">
<label for="content">問い合わせ内容:</label>
<textarea id="content" name="content" required>{{{payload.content}}}</textarea><br>
</div>
<div class="button-container">
<button type="submit">確認</button>
</div>
</form>
今回は省略してますが、もちろん /send-form
エンドポイントの後続のフローで問い合わせ内容をメール送信したり、データベースに保存するような処理が必要です。
フローJSONは以下です。
[
{
"id": "fc5b0675784de232",
"type": "http in",
"z": "334995aafa9a54b3",
"name": "",
"url": "/form",
"method": "get",
"upload": false,
"swaggerDoc": "",
"x": 140,
"y": 60,
"wires": [
[
"43c832a3101df71a"
]
]
},
{
"id": "0642d0f1a557d01a",
"type": "template",
"z": "334995aafa9a54b3",
"name": "",
"field": "payload",
"fieldType": "msg",
"format": "html",
"syntax": "mustache",
"template": "<!DOCTYPE html>\n<html lang=\"ja\">\n<head>\n <meta charset=\"UTF-8\">\n <title>Contact Form with HTMX</title>\n <script src=\"https://unpkg.com/htmx.org\"></script>\n <style>{{{css}}}</style>\n</head>\n<body>\n\n<form id=\"contact-form\" hx-post=\"/confirm-form\" hx-swap=\"innerHTML\">\n \n <div class=\"form-group\">\n <label for=\"name\">名前:</label>\n <input type=\"text\" id=\"name\" name=\"name\" value=\"{{{payload.name}}}\" required><br>\n </div>\n\n <div class=\"form-group\">\n <label for=\"email\">メールアドレス:</label>\n <input type=\"email\" id=\"email\" name=\"email\" value=\"{{{payload.email}}}\" required><br>\n </div>\n\n <div class=\"form-group\">\n <label for=\"zipcode\">郵便番号:</label>\n <input type=\"text\" id=\"zipcode\" name=\"zipcode\" value=\"{{{payload.zipcode}}}\" pattern=\"^\\d{3}-\\d{4}$\" required><br>\n </div>\n\n <div class=\"form-group\">\n <label for=\"prefecture\">都道府県:</label>\n <input type=\"text\" id=\"prefecture\" name=\"prefecture\" value=\"{{{payload.prefecture}}}\" required><br>\n </div>\n\n <div class=\"form-group\">\n <label for=\"city\">市区町村:</label>\n <input type=\"text\" id=\"city\" name=\"city\" value=\"{{{payload.city}}}\" required><br>\n </div>\n\n <div class=\"form-group\">\n <label for=\"address\">住所:</label>\n <input type=\"text\" id=\"address\" name=\"address\" value=\"{{{payload.address}}}\" required><br>\n </div>\n\n <div class=\"form-group\">\n <label for=\"building\">ビル・マンション名:</label>\n <input type=\"text\" id=\"building\" name=\"building\" value=\"{{{payload.building}}}\"><br>\n </div>\n\n <div class=\"form-group\">\n <label for=\"phone\">電話番号:</label>\n <input type=\"tel\" id=\"phone\" name=\"phone\" pattern=\"^\\d{2,4}-\\d{2,4}-\\d{4}$\" value=\"{{{payload.phone}}}\" required><br>\n </div>\n\n <div class=\"form-group\">\n <label for=\"content\">問い合わせ内容:</label>\n <textarea id=\"content\" name=\"content\" required>{{{payload.content}}}</textarea><br>\n </div>\n \n <div class=\"button-container\">\n <button type=\"submit\">確認</button>\n </div>\n</form>\n\n</body>\n</html>",
"output": "str",
"x": 480,
"y": 60,
"wires": [
[
"71990fb52a8a2202"
]
]
},
{
"id": "71990fb52a8a2202",
"type": "http response",
"z": "334995aafa9a54b3",
"name": "",
"statusCode": "",
"headers": {},
"x": 670,
"y": 140,
"wires": []
},
{
"id": "64d2606071584198",
"type": "http in",
"z": "334995aafa9a54b3",
"name": "",
"url": "/confirm-form",
"method": "post",
"upload": false,
"swaggerDoc": "",
"x": 270,
"y": 120,
"wires": [
[
"d4fe3e394d17e359"
]
]
},
{
"id": "43c832a3101df71a",
"type": "template",
"z": "334995aafa9a54b3",
"name": "css",
"field": "css",
"fieldType": "msg",
"format": "css",
"syntax": "mustache",
"template": "body {\n font-family: Arial, sans-serif;\n margin: 20px;\n}\n\nform {\n width: 600px;\n}\n\n.form-group {\n display: flex;\n align-items: center;\n margin-bottom: 10px;\n /* 各フォームグループ間の余白 */\n}\n\n.form-group label {\n flex: 0 0 150px;\n /* ラベルの幅を固定 */\n margin-right: 10px;\n /* ラベルとフィールドの間隔 */\n text-align: right;\n}\n\n/* 送信ボタンをセンタリングするためのスタイル */\n.button-container {\n display: flex;\n justify-content: center;\n /* コンテナの幅をフル幅に設定 */\n}\n\ninput[type=\"text\"],\ninput[type=\"email\"],\ninput[type=\"tel\"],\ninput[type=\"number\"],\ntextarea {\n flex-grow: 1;\n /* 入力フィールドを残りのスペースで拡張 */\n margin: 5px 0;\n /* 上下の余白 */\n padding: 8px;\n border: 1px solid #ccc;\n border-radius: 4px;\n}\n\nbutton {\n padding: 10px 15px;\n margin-top: 10px;\n /* ボタンの上の余白 */\n border: none;\n background-color: #4A90E2;\n color: white;\n border-radius: 4px;\n cursor: pointer;\n}",
"output": "str",
"x": 310,
"y": 60,
"wires": [
[
"0642d0f1a557d01a"
]
]
},
{
"id": "d4fe3e394d17e359",
"type": "template",
"z": "334995aafa9a54b3",
"name": "",
"field": "payload",
"fieldType": "msg",
"format": "html",
"syntax": "mustache",
"template": "<form id=\"contact-form\">\n <input type=\"hidden\" name=\"name\" value=\"{{{payload.name}}}\" />\n <input type=\"hidden\" name=\"email\" value=\"{{{payload.email}}}\" />\n <input type=\"hidden\" name=\"zipcode\" value=\"{{{payload.zipcode}}}\" />\n <input type=\"hidden\" name=\"prefecture\" value=\"{{{payload.prefecture}}}\" />\n <input type=\"hidden\" name=\"city\" value=\"{{{payload.city}}}\" />\n <input type=\"hidden\" name=\"address\" value=\"{{{payload.address}}}\" />\n <input type=\"hidden\" name=\"building\" value=\"{{{payload.building}}}\" />\n <input type=\"hidden\" name=\"phone\" value=\"{{{payload.phone}}}\" />\n <input type=\"hidden\" name=\"content\" value=\"{{{payload.content}}}\" />\n\n <h2>入力内容の確認</h2>\n <p>名前: <span id=\"confirm-name\">{{{payload.name}}}</span></p>\n <p>メールアドレス: <span id=\"confirm-email\">{{{payload.email}}}</span></p>\n <p>郵便番号: <span id=\"confirm-zipcode\">{{{payload.zipcode}}}</span></p>\n <p>都道府県: <span id=\"confirm-prefecture\">{{{payload.prefecture}}}</span></p>\n <p>市区町村: <span id=\"confirm-city\">{{{payload.city}}}</span></p>\n <p>住所: <span id=\"confirm-address\">{{{payload.address}}}</span></p>\n <p>ビル・マンション名: <span id=\"confirm-building\">{{{payload.building}}}</span></p>\n <p>電話番号: <span id=\"confirm-phone\">{{{payload.phone}}}</span></p>\n <p>問い合わせ内容: <span id=\"confirm-content\">{{{payload.content}}}</span></p>\n\n <button hx-post=\"/send-form\" hx-target=\"#contact-form\" hx-swap=\"innerHTML\">この内容で送信</button>\n <button hx-post=\"/edit-form\" hx-target=\"#contact-form\" hx-swap=\"innerHTML\">修正する</button>\n</form>",
"output": "str",
"x": 480,
"y": 120,
"wires": [
[
"71990fb52a8a2202"
]
]
},
{
"id": "a082cd4d75374746",
"type": "http in",
"z": "334995aafa9a54b3",
"name": "",
"url": "/send-form",
"method": "post",
"upload": false,
"swaggerDoc": "",
"x": 280,
"y": 180,
"wires": [
[
"af369c25f6af0b87"
]
]
},
{
"id": "af369c25f6af0b87",
"type": "template",
"z": "334995aafa9a54b3",
"name": "",
"field": "payload",
"fieldType": "msg",
"format": "html",
"syntax": "mustache",
"template": "<div id=\"contact-form\">\n <h2>送信しました</h2>\n <button hx-get=\"/form\" hx-target=\"#contact-form\" hx-swap=\"innerHTML\">戻る</button>\n</div>",
"output": "str",
"x": 480,
"y": 180,
"wires": [
[
"71990fb52a8a2202"
]
]
},
{
"id": "047284348c2d5ca5",
"type": "http in",
"z": "334995aafa9a54b3",
"name": "",
"url": "/edit-form",
"method": "post",
"upload": false,
"swaggerDoc": "",
"x": 280,
"y": 240,
"wires": [
[
"e9c0b4fd5a581448"
]
]
},
{
"id": "e9c0b4fd5a581448",
"type": "template",
"z": "334995aafa9a54b3",
"name": "",
"field": "payload",
"fieldType": "msg",
"format": "html",
"syntax": "mustache",
"template": "<form id=\"contact-form\" hx-post=\"/confirm-form\" hx-swap=\"innerHTML\">\n \n <div class=\"form-group\">\n <label for=\"name\">名前:</label>\n <input type=\"text\" id=\"name\" name=\"name\" value=\"{{{payload.name}}}\" required><br>\n </div>\n\n <div class=\"form-group\">\n <label for=\"email\">メールアドレス:</label>\n <input type=\"email\" id=\"email\" name=\"email\" value=\"{{{payload.email}}}\" required><br>\n </div>\n\n <div class=\"form-group\">\n <label for=\"zipcode\">郵便番号:</label>\n <input type=\"text\" id=\"zipcode\" name=\"zipcode\" value=\"{{{payload.zipcode}}}\" pattern=\"^\\d{3}-\\d{4}$\" required><br>\n </div>\n\n <div class=\"form-group\">\n <label for=\"prefecture\">都道府県:</label>\n <input type=\"text\" id=\"prefecture\" name=\"prefecture\" value=\"{{{payload.prefecture}}}\" required><br>\n </div>\n\n <div class=\"form-group\">\n <label for=\"city\">市区町村:</label>\n <input type=\"text\" id=\"city\" name=\"city\" value=\"{{{payload.city}}}\" required><br>\n </div>\n\n <div class=\"form-group\">\n <label for=\"address\">住所:</label>\n <input type=\"text\" id=\"address\" name=\"address\" value=\"{{{payload.address}}}\" required><br>\n </div>\n\n <div class=\"form-group\">\n <label for=\"building\">ビル・マンション名:</label>\n <input type=\"text\" id=\"building\" name=\"building\" value=\"{{{payload.building}}}\"><br>\n </div>\n\n <div class=\"form-group\">\n <label for=\"phone\">電話番号:</label>\n <input type=\"tel\" id=\"phone\" name=\"phone\" pattern=\"^\\d{2,4}-\\d{2,4}-\\d{4}$\" value=\"{{{payload.phone}}}\" required><br>\n </div>\n\n <div class=\"form-group\">\n <label for=\"content\">問い合わせ内容:</label>\n <textarea id=\"content\" name=\"content\" required>{{{payload.content}}}</textarea><br>\n </div>\n \n <div class=\"button-container\">\n <button type=\"submit\">確認</button>\n </div>\n</form>",
"output": "str",
"x": 480,
"y": 240,
"wires": [
[
"71990fb52a8a2202"
]
]
}
]
データテーブル
次に Web アプリでよく使うデータテーブルを実装します。
全体的なフローは以下のような感じです。
Node-RED フロー側でのテストデータの操作の解説は割愛しますので前回を参照ください。
データ一覧画面
データ一覧画面はテストデータの配列を template ノードの Mustache 繰り返し処理で描写する感じです。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Editable Table with HTMX</title>
<script src="https://unpkg.com/htmx.org"></script>
<style>{{{css}}}</style>
</head>
<body>
<table>
<thead>
<tr>
<th>ID</th>
<th>名前</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{{#payload}}
<tr>
<td>{{id}}</td>
<td>{{name}}</td>
<td>
<button hx-get="/edit-row/{{id}}" hx-trigger="click" hx-target="closest tr" hx-swap="outerHTML">編集</button>
</td>
</tr>
{{/payload}}
</tbody>
</table>
/edit-row/{id}
エンドポイント
データ一覧画面の編集ボタンがクリックされると /edit-row/{id}
エンドポイントへアクセスが来て、編集画面的な以下の HTML にクリックされた行だけ swap されます。
<tr>
<td>{{payload.id}}</td>
<td>
<input id="row-{{payload.id}}" type="text" name="name" value="{{payload.name}}">
</td>
<td>
<button hx-put="/update-row/{{payload.id}}" hx-vals='js:{"name":document.getElementById("row-{{payload.id}}").value}' hx-target="closest tr" hx-swap="outerHTML">保存</button>
</td>
</tr>
実は HTML だけで実装する限界に当たりました。
保存ボタンの属性 hx-vals
に以下のような javascript が必要です。 hx-vals
は form
タグがない場合に送信するデータを定義する属性で、ここに送信データを渡さないといけないのですが、画面上で編集された新たなデータを送るには、このように DOM でアクセスする必要があります。
js:{"name":document.getElementById("row-{{payload.id}}").value}
form
タグでも実装できたんですが、このように見た目をインライン編集的なスマートなものにしたい場合は今回のような実装になります。
htmx と jQuery の相性が良いと言われる所以は、この辺りにあるようですね。
私見としては jQuery に Ajax 通信や状態管理まで担わせることに無理があったという理解なので、Ajax 部分を htmx に、状態管理をサーバサイド( Node-RED )に担わせる今回のアプローチは jQuery をシンプルに使える好例になると思いました。
/update-row/*
エンドポイント
この例では変更値を画面に返すのみとしています。
今回は省略してますが、もちろん /update-row/*
エンドポイントの後続のフローでデータベースに変更値を保存するような処理が必要です。
<tr>
<td>{{payload.id}}</td>
<td>{{payload.name}}</td>
<td>
<button hx-get="/edit-row/{{payload.id}}" hx-trigger="click" hx-target="closest tr" hx-swap="outerHTML">編集</button>
</td>
</tr>
フローJSONは以下です。
[
{
"id": "5851ac9a8ed92fce",
"type": "http in",
"z": "5f6d46a9b543b408",
"name": "",
"url": "/list",
"method": "get",
"upload": false,
"swaggerDoc": "",
"x": 100,
"y": 80,
"wires": [
[
"595990d138a5606f"
]
]
},
{
"id": "595990d138a5606f",
"type": "template",
"z": "5f6d46a9b543b408",
"name": "css",
"field": "css",
"fieldType": "msg",
"format": "css",
"syntax": "mustache",
"template": "body {\n font-family: Arial, sans-serif;\n margin: 20px;\n}\n\ntable {\n width: 100%;\n border-collapse: collapse;\n margin-bottom: 20px;\n}\n\nth, td {\n border: 1px solid #ccc;\n padding: 8px;\n text-align: center;\n}\n\nth {\n background-color: #f9f9f9;\n}\n\ntd {\n background-color: #fff;\n}\n\nbutton {\n padding: 10px 15px;\n border: none;\n background-color: #4A90E2;\n color: white;\n border-radius: 4px;\n cursor: pointer;\n}\n\n/* 入力フィールドのスタイル */\ninput[type=\"text\"],\ninput[type=\"email\"],\ninput[type=\"tel\"],\ntextarea {\n width: 90%; /* テーブルセルに合わせて幅を100%に設定 */\n padding: 8px;\n margin-bottom: 0; /* テーブル内でのマージンは不要なため、0に設定 */\n border: 1px solid #ccc;\n border-radius: 4px;\n}\n\n/* 編集モードにあるセルのスタイル */\ntd.editing {\n padding: 0; /* 入力フィールドのパディングを0に設定 */\n}\n\n/* 編集ボタンのセルのスタイル */\ntd.action-cell {\n text-align: center; /* ボタンを中央揃えに */\n}\n",
"output": "str",
"x": 250,
"y": 80,
"wires": [
[
"7f7a263c1bd403fd"
]
]
},
{
"id": "2f4e9418e5031888",
"type": "template",
"z": "5f6d46a9b543b408",
"name": "",
"field": "payload",
"fieldType": "msg",
"format": "html",
"syntax": "mustache",
"template": "<!DOCTYPE html>\n<html lang=\"ja\">\n<head>\n <meta charset=\"UTF-8\">\n <title>Editable Table with HTMX</title>\n <script src=\"https://unpkg.com/htmx.org\"></script>\n <style>{{{css}}}</style>\n</head>\n<body>\n\n<table>\n <thead>\n <tr>\n <th>ID</th>\n <th>名前</th>\n <th>Action</th>\n </tr>\n </thead>\n <tbody>\n {{#payload}}\n <tr>\n <td>{{id}}</td>\n <td>{{name}}</td>\n <td>\n <button hx-get=\"/edit-row/{{id}}\" hx-trigger=\"click\" hx-target=\"closest tr\" hx-swap=\"outerHTML\">編集</button>\n </td>\n </tr>\n {{/payload}}\n </tbody>\n</table>",
"output": "str",
"x": 600,
"y": 80,
"wires": [
[
"66d7d47a0f3fcac6"
]
]
},
{
"id": "66d7d47a0f3fcac6",
"type": "http response",
"z": "5f6d46a9b543b408",
"name": "",
"statusCode": "",
"headers": {},
"x": 690,
"y": 140,
"wires": []
},
{
"id": "7f7a263c1bd403fd",
"type": "change",
"z": "5f6d46a9b543b408",
"name": "",
"rules": [
{
"t": "set",
"p": "members",
"pt": "flow",
"to": "[\t {\"id\":\"001\",\"name\":\"kojo\"},\t {\"id\":\"002\",\"name\":\"yokoi\"},\t {\"id\":\"003\",\"name\":\"taiji\"},\t {\"id\":\"004\",\"name\":\"seigo\"}\t]",
"tot": "jsonata"
},
{
"t": "set",
"p": "payload",
"pt": "msg",
"to": "members",
"tot": "flow"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 420,
"y": 80,
"wires": [
[
"2f4e9418e5031888"
]
]
},
{
"id": "52db3dbc6509ea0f",
"type": "http in",
"z": "5f6d46a9b543b408",
"name": "",
"url": "/edit-row/*",
"method": "get",
"upload": false,
"swaggerDoc": "",
"x": 120,
"y": 140,
"wires": [
[
"8f5f5e72aa49b9a5"
]
]
},
{
"id": "21c66ddfb4bd8e1b",
"type": "template",
"z": "5f6d46a9b543b408",
"name": "",
"field": "payload",
"fieldType": "msg",
"format": "html",
"syntax": "mustache",
"template": "<tr>\n <td>{{payload.id}}</td>\n <td>\n <input id=\"row-{{payload.id}}\" type=\"text\" name=\"name\" value=\"{{payload.name}}\">\n </td>\n <td>\n <button hx-put=\"/update-row/{{payload.id}}\" hx-vals='js:{\"name\":document.getElementById(\"row-{{payload.id}}\").value}' hx-target=\"closest tr\" hx-swap=\"outerHTML\">保存</button>\n </td>\n</tr>",
"output": "str",
"x": 520,
"y": 140,
"wires": [
[
"66d7d47a0f3fcac6"
]
]
},
{
"id": "8f5f5e72aa49b9a5",
"type": "change",
"z": "5f6d46a9b543b408",
"name": "",
"rules": [
{
"t": "set",
"p": "id",
"pt": "msg",
"to": "req.params.0",
"tot": "msg"
},
{
"t": "set",
"p": "payload",
"pt": "msg",
"to": "$filter($flowContext(\"members\"), function($v, $i, $a) {\t $v.id = id\t})",
"tot": "jsonata"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 340,
"y": 140,
"wires": [
[
"21c66ddfb4bd8e1b"
]
]
},
{
"id": "8631b3fc1c339a96",
"type": "http in",
"z": "5f6d46a9b543b408",
"name": "",
"url": "/update-row/*",
"method": "put",
"upload": false,
"swaggerDoc": "",
"x": 130,
"y": 200,
"wires": [
[
"d7e147d0b46883e6"
]
]
},
{
"id": "6741c4ee2960d43a",
"type": "template",
"z": "5f6d46a9b543b408",
"name": "",
"field": "payload",
"fieldType": "msg",
"format": "html",
"syntax": "mustache",
"template": "<tr>\n <td>{{payload.id}}</td>\n <td>{{payload.name}}</td>\n <td>\n <button hx-get=\"/edit-row/{{payload.id}}\" hx-trigger=\"click\" hx-target=\"closest tr\" hx-swap=\"outerHTML\">編集</button>\n </td>\n</tr>",
"output": "str",
"x": 520,
"y": 200,
"wires": [
[
"66d7d47a0f3fcac6"
]
]
},
{
"id": "d7e147d0b46883e6",
"type": "change",
"z": "5f6d46a9b543b408",
"name": "",
"rules": [
{
"t": "set",
"p": "new",
"pt": "msg",
"to": "payload",
"tot": "msg"
},
{
"t": "set",
"p": "id",
"pt": "msg",
"to": "req.params.0",
"tot": "msg"
},
{
"t": "set",
"p": "payload",
"pt": "msg",
"to": "$filter($flowContext(\"members\"), function($v, $i, $a) {\t $v.id = id\t})",
"tot": "jsonata"
},
{
"t": "set",
"p": "payload.name",
"pt": "msg",
"to": "new.name",
"tot": "msg"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 340,
"y": 200,
"wires": [
[
"6741c4ee2960d43a"
]
]
}
]
まとめ
htmx と Node-RED を使った Web アプリ/サイト画面開発は結構イケそうな感触です。
このように試してみるとフロントエンドのフレームワークが乱立した辺りから、フロントエンドの担当箇所が増え過ぎたんだなぁというのが率直な感想です。
htmx を使えば状態管理をサーバサイドが担っても、あまり UX を損なうことはありません。
フロントエンドの javascript は DOM アクセスとウェジェットくらいしか必要なくなるんじゃないでしょうか?
htmx + Node-RED の強力な組み合わせでシンプルなフロントエンド実装になりますね!