Node-REDのカスタム設定ノードのバリデーション対応をしてみた


Node-REDの設定ノードにバリデーション機能を追加したくて、機能追加しました。

https://flows.nodered.org/node/node-red-contrib-line-messaging-api

フォームにrequiredつけるだけだと追加できちゃうんですよね。

---
---Gemini2.5Proに記事を書かせてみたよ。----


## はじめに

Node-REDでLINE Botを扱うノードを作ってますが、トークンなどを設定するときに利用者がよくエラーを起こす問題があるのでバリデーション機能を追加してみましt。

この記事では、Node-REDでカスタムの設定ノード（config node）を作成する際に、

- 外部APIと連携して設定値を自動取得する機能
- ユーザーを混乱させない、リアルタイム・バリデーション

を実装する方法を、試行錯誤の過程を含めて解説します。

### 完成形

最終的に、以下のような設定画面を目指します。
- 未入力や不正な値があると、「更新」ボタンが押せなくなる。
- なぜ押せないのか、理由がリアルタイムで表示される。
- 「Bot名＆アイコンを取得」ボタンで、トークンが正しければ情報を自動入力できる。

> ![CleanShot 2025-06-16 at 15.29.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35387/1b0f1634-4757-4a25-a371-154933044b6e.png)

https://x.com/n0bisuke/status/1934462824794988733

## 開発環境
- Node-RED: v4.x
- Node.js: v20系とv24系で試しました

---

## Step 1: 基本的な設定ノードの作成

まずは、ノードの骨格となるHTMLファイル（`line-config.html`）と、ロジックを記述するJSファイル（`line-config.js`）を用意します。今回は1つのHTMLファイルにまとめて記述します。

## Step 2: API連携機能の実装

「Bot名＆アイコンを取得」ボタンを押した際に、LINEのAPIを叩いて情報を取得する機能を実装します。

### フロントエンドの実装 (`oneditprepare`)

設定画面（HTML）側では、ボタンがクリックされたら入力されたトークンをサーバーサイドに送信します。

`oneditprepare` 内に `fetch` を使った処理を記述します。

```javascript
// oneditprepare 内
$("#node-config-fetch-info").on("click", async () => {
    const channelSecret = $("#node-config-input-LineChannelSecret").val();
    const channelAccessToken = $("#node-config-input-LineChannelAccessToken").val();

    // ...（中略）...

    try {
        const response = await fetch("line-config/bot-info", { // サーバーサイドのAPIエンドポイントを呼び出す
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ channelSecret, channelAccessToken })
        });
        // ...（レスポンスを処理）...
    } catch (err) {
        // ...（エラー処理）...
    }
});
```

### サーバーサイドの実装 (`line-config.js`)

フロントエンドからの `fetch` リクエストを受け取る口を、ノードのJSファイル側に作成します。これには `RED.httpAdmin.post` を使います。

`line-config.js`:

```javascript
module.exports = function(RED) {
    function LineConfigNode(n) {
        RED.nodes.createNode(this, n);
        this.botName = n.botName;
        this.botIconUrl = n.botIconUrl;
    }
    RED.nodes.registerType("lineConfig", LineConfigNode, {
        credentials: {
            LineChannelSecret: { type: "text" },
            LineChannelAccessToken: { type: "text" }
        }
    });

    // フロントエンドからのリクエストを受け取るAPIエンドポイント
    RED.httpAdmin.post("/line-config/bot-info", RED.auth.needsPermission("lineConfig.write"), async function(req, res) {
        const { channelAccessToken } = req.body;
        if (!channelAccessToken) {
            return res.status(400).send("Channel Access Token is required.");
        }

        try {
            // ここで実際にLINEのAPIを叩く
            // 本来は @line/bot-sdk を使うのが望ましいですが、ここでは fetch で実装します
            const response = await fetch("[https://api.line.me/v2/bot/info](https://api.line.me/v2/bot/info)", {
                headers: { "Authorization": "Bearer " + channelAccessToken }
            });
            if (!response.ok) {
                const errorData = await response.json();
                return res.status(response.status).json(errorData);
            }
            const botInfo = await response.json();
            res.json(botInfo);
        } catch (err) {
            res.status(500).send(err.message);
        }
    });
}
```

**ポイント**: セキュリティのため、`RED.auth.needsPermission` でこのAPIエンドポイントへのアクセス権限を設定しています。

-----

## Step 3: 堅牢なバリデーションへの道（試行錯誤）

ここからが本題です。ただ動くだけでなく、ユーザーが間違った値を入力しにくいUIを目指して試行錯誤しました。

### 失敗1: `required: true` だけでは不十分

最初は `defaults` や `credentials` に `required: true` を設定しましたが、設定ノードの `credentials` では期待通りにボタンが無効化されませんでした。

### 失敗2: `blur` イベントでの通知は "ウザい"

次に入力欄からフォーカスが外れた（`blur`）際に `RED.notify` で通知を出す方法を試しました。しかし、フィールドを移動するたびに通知が大量に表示され、ユーザー体験を大きく損なう結果に...。

### 失敗3: `click` イベントの乗っ取りは不安定

最終手段として「更新」ボタンのクリックイベントを奪おうとしましたが、Node-RED本体の処理との実行順序の問題で、保存された後に通知が出てしまうなど、不安定な動作になりました。

-----

## Step 4: 【決定版】快適なリアルタイム・バリデーション

数々の失敗を経て、以下の方法にたどり着きました。これが最も確実で、ユーザーにとっても快適なベストプラクティスです。

1.  **ボタンの無効化**: 入力が不正な間は「更新」ボタンを無効化する。
2.  **ヘルプテキストの表示**: なぜボタンが押せないのか、その理由をダイアログの下部にテキストでリアルタイムに表示する。
3.  **ポップアップ通知は使わない**: 操作を妨げるポップアップは一切使わない。

この実装の核心は `oneditprepare` 内に記述する `validate` 関数です。

```javascript
// oneditprepare内
function validate() {
    let errorMessages = [];
    // ...（必須チェック、形式チェック、文字数チェックなど）...

    if (errorMessages.length > 0) {
        // エラーがある場合
        $("#node-config-dialog-ok").prop("disabled", true); // ボタンを無効化
        validationTip.html(errorMessages.join('<br/>')); // エラー内容を表示
        validationTip.show();
    } else {
        // エラーがない場合
        $("#node-config-dialog-ok").prop("disabled", false); // ボタンを有効化
        validationTip.hide(); // エラー表示を隠す
    }
}

// 各入力フィールドのイベントを監視して、入力のたびにvalidate()を呼び出す
$("#node-config-input-botName, ...").on('keyup input change', validate);
```

-----

## 完成版コード全体像

これまでの知見をすべて盛り込んだ、最終的な `line-config.html` の全コードです。

```html
<script type="text/javascript">
    RED.nodes.registerType('lineConfig', {
        category: 'config',
        color: '#00afd5',
        defaults: {
            botName: { value: "" },
            botIconUrl: { value: "" }
        },
        credentials: {
            LineChannelSecret: { type: "text" },
            LineChannelAccessToken: { type: "text" }
        },
        label: function () {
            return this.botName || 'No Title Bot';
        },
        oneditprepare: function () {
            const node = this;
            const validationTip = $("#node-config-validation-tip");

            function validate() {
                let errorMessages = [];
                const hasWhitespace = /\s/;
                const hasFullWidth = /[^\x00-\x7F]/;

                if ($("#node-config-input-botName").val() === "") {
                    errorMessages.push("・Bot Name は必須です。");
                }

                const secret = $("#node-config-input-LineChannelSecret").val();
                if (secret === "") {
                    errorMessages.push("・Channel Secret は必須です。");
                } else if (hasWhitespace.test(secret)) {
                    errorMessages.push("・Channel Secret にスペースや改行は含められません。");
                } else if (hasFullWidth.test(secret)) {
                    errorMessages.push("・Channel Secret に全角文字は含められません。");
                } else if (secret.length !== 32) {
                    errorMessages.push("・Channel Secret は32文字で入力してください。");
                }

                const token = $("#node-config-input-LineChannelAccessToken").val();
                if (token === "") {
                    errorMessages.push("・Channel Access Token は必須です。");
                } else if (hasWhitespace.test(token)) {
                    errorMessages.push("・Channel Access Token にスペースや改行は含められません。");
                } else if (hasFullWidth.test(token)) {
                    errorMessages.push("・Channel Access Token に全角文字は含められません。");
                } else if (token.length < 150) {
                    errorMessages.push("・Channel Access Token が短すぎます。正しい値を貼り付けてください。");
                }

                if (errorMessages.length > 0) {
                    $("#node-config-dialog-ok").prop("disabled", true);
                    validationTip.html(errorMessages.join('<br/>'));
                    validationTip.show();
                } else {
                    $("#node-config-dialog-ok").prop("disabled", false);
                    validationTip.hide();
                }
            }

            $("#node-config-input-botName, #node-config-input-LineChannelSecret, #node-config-input-LineChannelAccessToken")
                .on('keyup input change', validate);
            validate();

            if (node.botIconUrl) {
                $("#node-config-botIcon").attr("src", node.botIconUrl).show();
            }

            $("#node-config-fetch-info").on("click", async () => {
                const channelSecret = $("#node-config-input-LineChannelSecret").val();
                const channelAccessToken = $("#node-config-input-LineChannelAccessToken").val();
                if (!channelSecret || !channelAccessToken) {
                    RED.notify("Channel Secret と Access Token を両方入力してから取得してください。", "warning");
                    return;
                }
                try {
                    const response = await fetch("line-config/bot-info", {
                        method: "POST",
                        headers: { "Content-Type": "application/json" },
                        body: JSON.stringify({ channelSecret, channelAccessToken })
                    });
                    if (!response.ok) {
                        throw new Error("APIエラー: " + await response.text());
                    }
                    const botInfo = await response.json();
                    if (botInfo.displayName) {
                        $("#node-config-input-botName").val(botInfo.displayName).trigger('change');
                    }
                    if (botInfo.pictureUrl) {
                        $("#node-config-botIcon").attr("src", botInfo.pictureUrl).show();
                        $("#node-config-input-botIconUrl").val(botInfo.pictureUrl);
                    } else {
                        $("#node-config-input-botIconUrl").val("");
                        $("#node-config-botIcon").hide();
                    }
                    RED.notify("Botの情報を取得しました。", "success");
                } catch (err) {
                    RED.notify("Bot情報を取得できませんでした: " + err.message, "error");
                }
            });
        },
        oneditsave: function() {
            this.botIconUrl = $("#node-config-input-botIconUrl").val();
        }
    });
</script>

<script type="text/html" data-template-name="lineConfig">
    <div class="form-row">
        <label for="node-config-input-botName"><i class="fa fa-tag"></i> Bot Name</label>
        <input type="text" id="node-config-input-botName" placeholder="BOT Name">
    </div>
    <div class="form-row">
        <label for="node-config-input-LineChannelSecret">Channel Secret</label>
        <input type="text" id="node-config-input-LineChannelSecret" placeholder="32-character string">
    </div>
    <div class="form-row">
        <label for="node-config-input-LineChannelAccessToken">Channel Access Token</label>
        <input type="text" id="node-config-input-LineChannelAccessToken" placeholder="Long-lived access token">
    </div>
    <div class="form-row">
        <label>Bot Icon</label><br/>
        <img id="node-config-botIcon" src="" style="display:none; max-width: 100px; border-radius: 10%;" />
    </div>
    <input type="hidden" id="node-config-input-botIconUrl" />
    <div class="form-row">
        <div id="node-config-validation-tip" class="form-tips" style="display: none; color: #a94442; padding-top: 5px;"></div>
    </div>
    <div class="form-row">
        <button type="button" id="node-config-fetch-info" class="red-ui-button">Bot名＆アイコンを取得</button>
    </div>
</script>
```

## まとめ

Node-REDのカスタムノード開発において、少しの手間をかけるだけで設定画面のUXを大きく向上させることができます。特に設定ノードは一度設定するとあまり触らない部分だからこそ、最初の設定でユーザーが迷わないような親切な作り込みが重要だと感じました。

----

と、ここまでAI執筆

設定追加時のバリデーションをしたくてoneditsaveの中でチェックしようとしたところ、ボタンが押されて処理が走った後にチェックされるような挙動となっていました。

なのでoneditprepareの中でバリデーションを随時発生されるような処理にしてみたのですが、onpresaveみたいな押される直前をフックするような書き方にできたほうが良さそうな気はしてるんですけど詳しい方いたら教えて下しあ。

> oneditsave: (function) 編集ダイアログで完了ボタンが押された時に呼び出されます。カスタム編集の動作を参照してください。
> 参考: https://nodered.jp/docs/creating-nodes/node-html