かんがるーさんの日記

最近自分が興味をもったものを調べた時の手順等を書いています。今は Spring Boot をいじっています。

Spring Boot + npm + Geb で入力フォームを作ってテストする ( その18 )( 入力画面1を作成する2 )

概要

記事一覧はこちらです。

Spring Boot + npm + Geb で入力フォームを作ってテストする ( その17 )( 入力画面1を作成する ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。
    • 入力画面1の作成
    • 前回からの続きです。今回は Javascript の処理を実装します。

参照したサイト・書籍

  1. 徹底マスター JavaScriptの教科書

    • Javascript を普段ほとんど書かないので、一通り網羅していて、かつ ES2015 等の最新仕様も勉強できるものとして購入しました。
    • 読んだ感想としては、一通りの基礎知識を覚えるという目的を十分果たすことができ、仕様を説明するだけでなく実用性のあるサンプルも掲載されていて、満足のいく本でした。文章や図が分かりやすく、レイアウトも見やすくて良い本だと思います。お薦めです。
  2. resolve.modules
    https://webpack.js.org/configuration/resolve/#resolve-modules

  3. Node.js - use of module.exports as a constructor
    https://stackoverflow.com/questions/20534702/node-js-use-of-module-exports-as-a-constructor

  4. JSDoc使い方メモ
    http://qiita.com/opengl-8080/items/a36679f7926f4cac0a81

  5. in a javascript event, how to determine stopPropagation() has been called?
    https://stackoverflow.com/questions/7259753/in-a-javascript-event-how-to-determine-stoppropagation-has-been-called

  6. moji
    https://www.npmjs.com/package/moji

  7. JavaScript】全角・半角文字列の正規表現チェックまとめ(ひらがな/カタカナ/漢字・英数字)
    http://tokyo-wabisabi-boys.net/blog/javascriptjquery/js-string-regex-check

  8. 実践、jQuery - 第1回 .on()と.off()を使いこなす 1
    https://app.codegrid.net/entry/practical-jquery-1

  9. Add behaviour to set the focus on the first error field when the submit button is pushed …
    https://github.com/1000hz/bootstrap-validator/issues/128

目次

  1. Thymeleaf テンプレートファイルや .java ファイルを変更した時に browser-sync でリロードされない時がある。。。
  2. webpack.config.js で共通ライブラリを配置するディレクトリを指定する
  3. 変換&入力チェック処理の仕様を決める
  4. moji をインストールする
  5. 共通ライブラリ Form.js, converter.js, validator.js を実装する
  6. 入力チェックを実装する
  7. input01.html を修正する
  8. 動作確認
  9. メモ書き

手順

Thymeleaf テンプレートファイルや .java ファイルを変更した時に browser-sync でリロードされない時がある。。。

Thymeleaf テンプレートファイルや .java ファイルを変更して Ctrl+F9 を押して build しても browser-sync でリロードされないことがありますね。。。 Chokidar のページで watchOptions を見直したら awaitWriteFinish というオプションがありましたので、これと usePolling を追加してみます。

bs-springboot-config.js を以下のように変更します。

    "watchOptions": {
        "ignoreInitial": true,
        "ignorePermissionErrors": true,
        "usePolling": true,
        "awaitWriteFinish": true
    },
  • 以下の2行を追加します。
    • "usePolling": true
    • "awaitWriteFinish": true

変更後、npm run springboot コマンドを再実行します。

webpack.config.js で共通ライブラリを配置するディレクトリを指定する

今回共通ライブラリは src/main/assets/js/lib の下に置くようにします。src/main/assets/js の下に lib ディレクトリを新規作成します。

また今のままだと src/main/assets/js/lib の下に test.lib を作成した場合、src/main/assets/js/inquiry/input01.js から require するには var test = require("../lib/test.js");相対パスで指定する必要があるので、webpack.config.js に resolve.modules を設定します。

webpack.config.js を以下のように変更します。

module.exports = {
    entry: {
        "js/app": ["./src/main/assets/js/app.js"],
        "js/inquiry/input01": ["./src/main/assets/js/inquiry/input01.js"],
        "js/inquiry/input02": ["./src/main/assets/js/inquiry/input02.js"],
        "js/inquiry/input03": ["./src/main/assets/js/inquiry/input03.js"],
        "js/inquiry/confirm": ["./src/main/assets/js/inquiry/confirm.js"]
    },
    output: {
        path: __dirname + "/src/main/resources/static",
        publicPath: "/",
        filename: "[name].js"
    },
    resolve: {
        modules: [
            "node_modules",
            "src/main/assets/js"
        ]
    }
};
  • resolve: { modules: [ ... ] } を追加します。

変更後、npm run springboot コマンドを再実行します。これで var test = require("lib/test.js"); のように指定できるようになります。

変換&入力チェック処理の仕様を決める

各項目で以下の変換&入力チェック処理を行います。

項目 変換&入力チェック処理
お名前(漢字) 必須チェック
お名前(かな) かな変換
必須チェック
かなチェック
性別 必須チェック
年齢 半角数字変換
必須チェック
正規表現チェック
職業 ※何もチェックしない
  • 文字数は maxlength 属性で制御されている前提とし、Javascript ではチェックしません(サーバ側では文字数チェックは行います)。
  • 「お名前(漢字)」のように項目が2つに分かれているものは、両方にカーソルが入った後か、「次へ」ボタンをクリックした時でないと変換&入力チェック処理は行いません。例えば「お名前(漢字)」の「姓」にカーソルがある状態の時に文字列を入力してからマウスで「お名前(かな)」の姓にカーソルを移動した場合、「お名前(漢字)」の変換&入力チェック処理は実行しません。

moji をインストールする

半角/全角カタカナ –> 全角ひらがな変換処理に利用できるパッケージが npm にないか探してみたところ、moji を見つけました。今回はこれを利用します。

npm run springboot コマンドを終了した後、npm install --save moji コマンドを実行してインストールします。

f:id:ksby:20170827202214p:plain

インストール後、npm run springboot コマンドを再度実行します。

共通ライブラリ Form.js, converter.js, validator.js を実装する

画面の状態の管理、制御をするための Form クラスを定義する Form.js、変換処理用の関数を定義する converter.js、入力チェック用の関数を定義する validator.js を実装します。

src/main/assets/js/lib の下に class ディレクトリを作成し、src/main/assets/js/lib/class の下に Form.js を新規作成して、以下の内容を記述します。

"use strict";

var $ = require("admin-lte/plugins/jQuery/jquery-2.2.3.min.js");

module.exports = Form;

function Form(idList) {
    this.idList = idList;
    this.focused = [];
    addFocusEventListener(this);
}

/**
 * 変換&入力チェックを行う
 * @param {Form} form - Form オブジェクト
 * @param {jQuery.Event} event - イベント発生時に渡された jQuery.Event オブジェクト
 * @param {string} idFormGroup - Validation の SUCCESS/ERROR の結果を反映する要素の id
 * @param {Array} idList - チェックを行う要素の id の配列
 * @param {Function} converter - 変換処理を行う関数、変換しない場合には undefined を渡す
 * @param {Function} validator - 入力チェックを行う関数
 */
Form.prototype.convertAndValidate = function (form, event, idFormGroup, idList, converter, validator) {
    if (form.isAllFocused(form, idList)) {
        if (converter !== undefined) {
            converter();
        }

        if (validator !== undefined) {
            form.resetValidation(idFormGroup);
            try {
                validator();
            } catch (e) {
                event.stopPropagation();
            }
        }
    }
};

/**
 * idList で渡された id の要素全てに focused イベントが発生済かをチェックする
 * @param {Form} form - Form オブジェクト
 * @param {Array} idList - チェックする id がセットされた配列
 * @returns {boolean} true:発生した, false:発生していない
 */
Form.prototype.isAllFocused = function (form, idList) {
    return idList.reduce(function (p, id) {
        return (form.focused[id] === undefined) ? false : p;
    }, true);
};

/**
 * idList で渡された id の要素の中に未入力/未選択のものがあるかチェックする
 * @param {Array} idList - チェックする id がセットされた配列
 * @returns {boolean} true:未入力/未選択のものがある, false:全て入力/選択されている
 */
Form.prototype.isAnyEmpty = function (idList) {
    var anyEmpty = false;
    idList.forEach(function (id) {
        if ($(id).attr("type") === "radio") {
            if ($(id + ":checked").val() === undefined) {
                anyEmpty = true;
            }
        } else if ($(id).val() === "") {
            anyEmpty = true;
        }
    });
    return anyEmpty;
};

/**
 * 指定された id の要素から has-success, has-error クラスを取り除き、エラーメッセージを非表示にする
 * @param {string} idFormGroup - Validation の状態をリセットする id
 */
Form.prototype.resetValidation = function (idFormGroup) {
    $(idFormGroup)
        .removeClass("has-success")
        .removeClass("has-error");
    $(idFormGroup + " .js-errmsg")
        .addClass("hidden");
};

/**
 * 指定された id の要素に has-success クラスを追加する
 * @param {string} idFormGroup - has-success クラスを追加する要素の id
 */
Form.prototype.setSuccess = function (idFormGroup) {
    $(idFormGroup).addClass("has-success");
};

/**
 * 指定された id の要素に has-error クラスを追加し、エラーメッセージを表示する
 * @param {string} idFormGroup - has-error クラスを追加する要素の id
 * @param {string} errmsg - 表示するエラーメッセージ
 */
Form.prototype.setError = function (idFormGroup, errmsg) {
    $(idFormGroup).addClass("has-error");
    $(idFormGroup + " .js-errmsg").removeClass("hidden");
    $(idFormGroup + " .js-errmsg small").text(errmsg);
};

/**
 * form.idList にセットされている id を全て form.focused にセットして
 * focused イベントが発生したことにする
 * @param {Form} form - Form オブジェクト
 */
Form.prototype.forceAllFocused = function (form) {
    form.idList.forEach(function (id) {
        form.focused[id] = true;
    })
};

/**
 * Form オブジェクトの idList に列挙された id の要素の focus イベントハンドラに
 * Form.focused 配列に id をセットする関数をセットする
 * @param {Form} form - Form オブジェクト
 */
function addFocusEventListener(form) {
    form.idList.forEach(function (id) {
        $(id).on("focus", function (event) {
            form.focused[id] = true;
        })
    })
}

src/main/assets/js/lib の下に util ディレクトリを作成し、src/main/assets/js/lib/util の下に converter.js を新規作成して、以下の内容を記述します。

"use strict";

var $ = require("admin-lte/plugins/jQuery/jquery-2.2.3.min.js");
var moji = require("moji");

module.exports = {

    /**
     * 半角/全角カタナカ → ひらがな変換用関数
     * @param {Array} idList - 変換処理を行う要素の id の配列
     */
    convertHiragana: function (idList) {
        convert(idList, function (id) {
            return moji($(id).val())
                .convert('HK', 'ZK')     // 半角カタナカ → 全角カタカナ
                .convert('KK', 'HG')     // 全角カタカナ → ひらがな
                .toString();
        });
    },

    /**
     * 全角英数 → 半角英数変換用関数
     * @param {Array} idList - 変換処理を行う要素の id の配列
     */
    convertHanAlphaNumeric: function (idList) {
        convert(idList, function (id) {
            return moji($(id).val())
                .convert('ZE', 'HE')     // 全角英数 → 半角英数
                .toString();
        });
    }

};

/**
 * idList で指定された項目の値を convertRuleFunc 関数で変換する
 * @param {Array} idList - 変換処理を行う要素の id の配列
 * @param {Function} convertRuleFunc - 変換関数
 */
function convert(idList, convertRuleFunc) {
    // 変換して値をセットし直すとカーソルのある項目の選択状態が解除されるので、
    // 最初にカーソルのある要素を取得して、セット後に選択状態にし直す
    var $focused = $(":focus");
    idList.forEach(function (id) {
        $(id).val(convertRuleFunc(id));
    });
    $focused.select();
}

src/main/assets/js/lib/util の下に validator.js を新規作成して、以下の内容を記述します。

"use strict";

var $ = require("admin-lte/plugins/jQuery/jquery-2.2.3.min.js");

module.exports = {

    /**
     * 必須チェック用関数
     * @param {Form} form - Form オブジェクト
     * @param {string} idFormGroup - Validation の SUCCESS/ERROR の結果を反映する要素の id
     * @param {Array} idList - チェックを行う要素の id の配列
     * @param {string} errmsg - チェックエラー時に表示するエラーメッセージ
     */
    checkRequired: function (form, idFormGroup, idList, errmsg) {
        var isValid = !form.isAnyEmpty(idList);
        setSuccessOrError(form, idFormGroup, errmsg, isValid);
    },

    /**
     * ひらがなチェック用関数
     * @param {Form} form - Form オブジェクト
     * @param {string} idFormGroup - Validation の SUCCESS/ERROR の結果を反映する要素の id
     * @param {Array} idList - チェックを行う要素の id の配列
     * @param {string} errmsg - チェックエラー時に表示するエラーメッセージ
     */
    checkHiragana: function (form, idFormGroup, idList, errmsg) {
        var isValid = validateRegexp(idList, "^[\u3041-\u3096]+$");
        setSuccessOrError(form, idFormGroup, errmsg, isValid);
    },

    /**
     * 正規表現チェック用関数
     * @param {Form} form - Form オブジェクト
     * @param {string} idFormGroup - Validation の SUCCESS/ERROR の結果を反映する要素の id
     * @param {Array} idList - チェックを行う要素の id の配列
     * @param {string} pattern - チェックで使用する正規表現のパターン文字列
     * @param {string} errmsg - チェックエラー時に表示するエラーメッセージ
     */
    checkRegexp: function (form, idFormGroup, idList, pattern, errmsg) {
        var isValid = validateRegexp(idList, pattern);
        setSuccessOrError(form, idFormGroup, errmsg, isValid);
    }

};

/**
 * 正規表現チェック用共通関数
 * @param {Array} idList - チェックを行う要素の id の配列
 * @param {string} pattern - チェックで使用する正規表現のパターン文字列
 * @returns {boolean} true:チェックOK, false:チェックNG
 */
function validateRegexp(idList, pattern) {
    var regexp = new RegExp(pattern);
    return idList.reduce(function (p, id) {
        return (!$(id).val().match(regexp)) ? false : p;
    }, true);
}

/**
 *
 * @param {Form} form - Form オブジェクト
 * @param {string} idFormGroup - Validation の SUCCESS/ERROR の結果を反映する要素の id
 * @param {string} errmsg - チェックエラー時に表示するエラーメッセージ
 * @param {boolean} isValid - チェック結果
 */
function setSuccessOrError(form, idFormGroup, errmsg, isValid) {
    if (isValid) {
        form.setSuccess(idFormGroup);
    } else {
        form.setError(idFormGroup, errmsg);
        throw new Error(errmsg);
    }
}

入力チェックを実装する

src/main/assets/js/inquiry/input01.js を以下のように変更します。

"use strict";

var $ = require("admin-lte/plugins/jQuery/jquery-2.2.3.min.js");
var Form = require("lib/class/Form.js");
var converter = require("lib/util/converter.js");
var validator = require("lib/util/validator.js");

var form = new Form([
    "#lastname",
    "#firstname",
    "#lastkana",
    "#firstkana",
    "input:radio[name='sex']",
    "#age",
    "#job"
]);

var nameValidator = function (event) {
    var idFormGroup = "#form-group-name";
    var idList = ["#lastname", "#firstname"];
    form.convertAndValidate(form, event, idFormGroup, idList,
        undefined,
        function () {
            validator.checkRequired(form, idFormGroup, idList, "お名前(漢字)を入力してください");
        }
    );
};

var kanaValidator = function (event) {
    var idFormGroup = "#form-group-kana";
    var idList = ["#lastkana", "#firstkana"];
    form.convertAndValidate(form, event, idFormGroup, idList,
        function () {
            converter.convertHiragana(idList);
        },
        function () {
            validator.checkRequired(form, idFormGroup, idList, "お名前(かな)を入力してください");
            validator.checkHiragana(form, idFormGroup, idList, "お名前(かな)にはひらがなを入力してください");
        }
    );
};

var sexValidator = function (event) {
    var idFormGroup = "#form-group-sex";
    var idList = ["input:radio[name='sex']"];
    form.convertAndValidate(form, event, idFormGroup, idList,
        undefined,
        function () {
            validator.checkRequired(form, idFormGroup, idList, "性別を選択してください");
        }
    );
};

var ageValidator = function (event) {
    var idFormGroup = "#form-group-age";
    var idList = ["#age"];
    form.convertAndValidate(form, event, idFormGroup, idList,
        function () {
            converter.convertHanAlphaNumeric(idList);
        },
        function () {
            validator.checkRequired(form, idFormGroup, idList, "年齢を入力してください");
            validator.checkRegexp(form, idFormGroup, idList, "^[0-9]+$", "年齢には数字を入力してください");
        }
    );
};

var jobValidator = function (event) {
    var idFormGroup = "#form-group-job";
    var idList = ["#job"];
    form.setSuccess(idFormGroup);
};

var btnNextClickHandler = function (event) {
    // 全ての入力チェックを実行する
    form.forceAllFocused(form);
    [
        nameValidator,
        kanaValidator,
        sexValidator,
        ageValidator,
        jobValidator
    ].forEach(function (validator) {
        validator(event);
    });
    // 入力チェックエラーがある場合には処理を中断する
    if (event.isPropagationStopped()) {
        // 一番最初のエラーの項目にカーソルを移動する
        $(".has-error:first :input:first").focus().select();
        return false;
    }

    // 「次へ」ボタンをクリック不可にする
    $(".js-btn-next").prop("disabled", true);

    // サーバにリクエストを送信する
    $("#input01Form").attr("action", "/inquiry/input/01/?move=next");
    $("#input01Form").submit();

    // return false は
    // event.preventDefault() + event.stopPropagation() らしい
    return false;
};

$(document).ready(function () {
    // 入力チェック用の validator 関数をセットする
    $("#lastname").on("blur", nameValidator);
    $("#firstname").on("blur", nameValidator);
    $("#lastkana").on("blur", kanaValidator);
    $("#firstkana").on("blur", kanaValidator);
    $("input:radio[name='sex']").on("blur", sexValidator);
    $("#age").on("blur", ageValidator);
    $("#job").on("blur", jobValidator);

    // 「次へ」ボタンクリック時の処理をセットする
    $(".js-btn-next").on("click", btnNextClickHandler)
});

input01.html を修正する

「次へ」ボタンはいつでも押せるようにします。src/main/resources/templates/web/inquiry/input01.html を以下のように変更します。

            <div class="text-center">
              <button class="btn bg-green js-btn-next"><i class="fa fa-arrow-right"></i> 次へ</button>
            </div>
  • <button class="btn bg-green js-btn-next" disabled><button class="btn bg-green js-btn-next"> へ変更します。

動作確認

Tomcat を起動して http://localhost:9080/inquiry/input/01/ にアクセスすると入力画面1が表示されます。

f:id:ksby:20170830004059p:plain

何も入力せずに Tab キーでカーソルを「次へ」ボタンまで移動すると入力チェックをしていない「職業」は緑色になり、それ以外は赤色になってエラーメッセージが表示されます。

f:id:ksby:20170830004306p:plain

F5 キーを押してリロードした後「次へ」ボタンをいきなりクリックすると「職業」以外がエラーになり、カーソルは一番最初のエラー項目である「お名前(漢字)」の「姓」へ移動します(下の画像では分かりませんが)。また入力画面2へは遷移しません。

f:id:ksby:20170830004507p:plain

F5 キーを押してリロードした後、全ての項目にエラーにならないようデータを入力すると、全ての項目が緑色になります。

f:id:ksby:20170830004818p:plain

「次へ」ボタンをクリックすると入力画面2へ遷移します。

f:id:ksby:20170830005527p:plain

F5 キーを押してリロードした後、「お名前(かな)」のかなチェック、「年齢」の正規表現チェックだけエラーになるようにデータを入力してから「次へ」ボタンをクリックすると、「お名前(かな)」「年齢」だけ赤色になりカーソルは一番最初のエラー項目である「お名前(かな)」の「姓」に移動します。

f:id:ksby:20170830005036p:plain

「お名前(かな)」のかな変換も動作しています。

f:id:ksby:20170830010349p:plain f:id:ksby:20170830010443p:plain

「年齢」の半角数字変換も動作していました(こちらは画面キャプチャはなしです)。

問題なく動作しているようです。もう少し書きたいことがあるので次回も Javascript での実装です。

メモ書き

  • Javascript の this がよく分かりません。。。 Java の Class のように書くと、いつの間にかクラスではなく window に変わっていました。動きがよく分からなくて、Form.js ではオブジェクトが欲しいメソッドでは第一引数にオブジェクトを渡すようにしました。this 怖いです。。。
  • まだそんなに大きなファイルでないからだと思いますが、js のファイルが1つだけだと Chrome で debug がしやすい気がします!

履歴

2017/08/30
初版発行。
2017/08/31
* Form.js の Form.prototype.isAnyEmpty から else if ($(id).attr("type") === "select") { ... } の記述を削除した。
* $(".has-error:first :input:first").focus().select(); の記述する位置が間違っていたので、if (event.isPropagationStopped()) { ... } の中へ移動した。