かんがるーさんの日記

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

IntelliJ IDEA を 2017.2.3 → 2017.2.4 へバージョンアップ

IntelliJ IDEA を 2017.2.3 → 2017.2.4 へバージョンアップする

IntelliJ IDEA の 2017.2.4 がリリースされたのでバージョンアップします。

※ksbysample-webapp-lending プロジェクトを開いた状態でバージョンアップしています。

  1. IntelliJ IDEA のメインメニューから「Help」-「Check for Updates…」を選択します。

  2. IDE and Plugin Updates」ダイアログが表示されます。左下に「Update and Restart」ボタンが表示されていますので、「Update and Restart」ボタンをクリックします。

    f:id:ksby:20170922003945p:plain

  3. Plugin の update も表示されました。「Error-prone Compiler Integration」はバージョンアップすると動かなくなりますので、これだけチェックを外して「Update and Restart」ボタンをクリックします。

    f:id:ksby:20170922004128p:plain

  4. Patch がダウンロードされて IntelliJ IDEA が再起動します。

  5. IntelliJ IDEA が起動すると画面下部に「Indexing…」のメッセージが表示されますので、終了するまで待機します。

    f:id:ksby:20170922005039p:plain

  6. IntelliJ IDEA のメインメニューから「Help」-「About」を選択し、2017.2.4 へバージョンアップされていることを確認します。

  7. Gradle Tool Window のツリーを見ると「Tasks」の下に「other」しかない状態になっているので、左上にある「Refresh all Gradle projects」ボタンをクリックして更新します。

    f:id:ksby:20170922005418p:plain

  8. clean タスク実行 → Rebuild Project 実行 → build タスクを実行して、"BUILD SUCCESSFUL" のメッセージが出力されることを確認しようとしたら、Rebuild Project で以下の画像のエラーが出ました。

    f:id:ksby:20170922010002p:plain

    エラーメッセージの途中に com.haskforce.jps.model... と出力されているので、どうも Haskell のためにインストールしている HaskForce Plugin が原因のようです。

    HaskForce Plugin をアンインストールしてから clean タスク実行 → Rebuild Project 実行 → build タスクを実行すると、今度は “BUILD SUCCESSFUL” のメッセージが出力されました。

    f:id:ksby:20170922011052p:plain

  9. Project Tool Window で src/test を選択した後、コンテキストメニューを表示して「Run ‘All Tests’ with Coverage」を選択し、テストが全て成功することを確認します。

    f:id:ksby:20170922013832p:plain

ちなみに HaskForce Plugin は Scala Plugin をインストールしたら再び使えるようになりました。

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

概要

記事一覧はこちらです。

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

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

参照したサイト・書籍

  1. 電話番号に関するQ&A
    http://www.soumu.go.jp/main_sosiki/joho_tsusin/top/tel_number/q_and_a.html

  2. 正規表現でのメールアドレスチェックは見直すべき – ReDoS
    https://blog.ohgaki.net/redos-must-review-mail-address-validation

  3. 正しいメールアドレスのチェック方法
    https://blog.ohgaki.net/how-to-check-email-address

目次

  1. input02.html 内の input タグに maxlength 属性を追加する
  2. 変換&入力チェック処理の仕様を決める
  3. validator.js の validateRegexp 関数を変更する
  4. Form.js に isAllEmpty, isAnyNotEmpty メソッドを追加する
  5. 入力チェックを実装する
  6. input02.html を修正する
  7. 動作確認
  8. 次回は。。。

手順

input02.html 内の input タグに maxlength 属性を追加する

INQUIRY_DATA テーブルのカラムに定義した文字数を src/main/resources/templates/web/inquiry/input02.html 内の <input type="text" .../> に maxlength 属性として追加します。

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

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

項目 変換&入力チェック処理
郵便番号 半角数字変換
必須チェック
7桁固定チェック
住所 必須チェック
電話番号 半角数字変換
「市外局番」の先頭 0 チェック
「市外局番」+「市内局番」= 6桁固定チェック
「加入者番号」= 4桁固定チェック
メールアドレス 半角文字変換
メールアドレスチェック
  • 文字数は maxlength 属性で制御されている前提とし、Javascript ではチェックしません(サーバ側では文字数チェックは行います)。
  • 「郵便番号」のように項目が2つに分かれているものは、両方にカーソルが入った後か、「前の画面へ戻る」「次へ」ボタンをクリックした時でないと変換&入力チェック処理は行いません。例えば「郵便番号」の左側の項目にカーソルがある状態の時に文字列を入力してからマウスで「住所」にカーソルを移動した場合、「郵便番号」の変換&入力チェック処理は実行しません。
  • 「電話番号」と「メールアドレス」は全てにカーソルが入った後にどちらも入力されていなければエラーメッセージを表示します。
  • 「次へ」ボタンがクリックされた時は全ての入力チェックを行いますが、「前の画面へ戻る」ボタンがクリックされた時は入力されているものだけ入力チェックを行います(必須チェックを行いません)。

量が多いので、以下の処理は次回以降に実装します。

  • メールアドレスチェック
  • 「前の画面へ戻る」ボタンがクリックされた時は入力されているものだけ入力チェックする

validator.js の validateRegexp 関数を変更する

src/main/assets/js/lib/util/validator.js の validateRegexp 関数は idList で渡された id の要素を1つずつ正規表現にマッチするかチェックしていますが、id の要素の値を全て結合した文字列に対して正規表現にマッチするかチェックするように変更します。

src/main/assets/js/lib/util/validator.js を以下のように変更します。

/**
 * 正規表現チェック用共通関数
 * @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 p + $(id).val();
    }, "").match(regexp);
}
  • validateRegexp 関数を上記の内容に変更します。

Form.js に isAllEmpty, isAnyNotEmpty メソッドを追加する

idList で渡された id の要素が全て未入力/未選択かをチェックする isAllEmpty メソッド、idList で渡された id の要素の中に1つでも入力/選択されているものがあるかをチェックする isAnyNotEmpty メソッドを追加します。

..........

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

/**
 * idList で渡された id の要素の中に未入力/未選択のものがあるかチェックする
 * @param {Array} idList - チェックする id がセットされた配列
 * @returns {boolean} true:未入力/未選択のものがある, false:全て入力/選択されている
 */
Form.prototype.isAnyEmpty = function (idList) {
    ..........
};

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

今さらながら気づきましたが、vaildator.js は Array.reduce で実装していてこちらの処理も Array.reduce を使えばいいのに、Form.js は Array.forEach で実装していました。。。 次回あたりに修正します。

入力チェックを実装する

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

"use strict";

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([
    "#zipcode1",
    "#zipcode2",
    "#address",
    "#tel1",
    "#tel2",
    "#tel3",
    "#email"
]);

var zipcodeValidator = function (event) {
    var idFormGroup = "#form-group-zipcode";
    var idList = ["#zipcode1", "#zipcode2"];
    form.convertAndValidate(form, event, idFormGroup, idList,
        function () {
            converter.convertHanAlphaNumeric(idList);
        },
        function () {
            validator.checkRequired(form, idFormGroup, idList, "郵便番号を入力してください");
            validator.checkRegexp(form, idFormGroup, idList, "^[0-9]{7}$", "郵便番号は7入力してください");
        }
    );
};

var addressValidator = function (event) {
    var idFormGroup = "#form-group-address";
    var idList = ["#address"];
    form.convertAndValidate(form, event, idFormGroup, idList,
        undefined,
        function () {
            validator.checkRequired(form, idFormGroup, idList, "住所を入力してください");
        }
    );
};

var telAndEmailValidator = function (event) {
    var telIdFormGroup = "#form-group-tel";
    var emailIdFormGroup = "#form-group-email";
    var telIdList = ["#tel1", "#tel2", "#tel3"];
    var emailIdList = ["#email"];
    var idList = telIdList.concat(emailIdList);

    var validateFunction = function () {
        if (form.isAllEmpty(idList)) {
            var errmsg = "電話番号とメールアドレスのいずれか一方を入力してください";
            form.setError(telIdFormGroup, errmsg);
            form.setError(emailIdFormGroup, errmsg);
            throw new Error(errmsg);
        } else {
            // 最初に入力チェックOKの状態にしておく
            form.setSuccess(telIdFormGroup);
            form.setSuccess(emailIdFormGroup);

            // 「電話番号」に1つでも値が入力されていたら入力チェックする
            if (form.isAnyNotEmpty(telIdList)) {
                validator.checkRegexp(form, telIdFormGroup, ["#tel1"],
                    "^0", "市外局番の先頭には 0 の数字を入力してください");
                validator.checkRegexp(form, telIdFormGroup, ["#tel1", "#tel2"],
                    "^[0-9]{6}$", "市外局番+市内局番の組み合わせが数字6桁になるように入力してください");
                validator.checkRegexp(form, telIdFormGroup, ["#tel3"],
                    "^[0-9]{4}$", "加入者番号には4桁の数字を入力してください");
            }
        }
    };

    form.convertAndValidate(form, event, telIdFormGroup, idList,
        function () {
            converter.convertHanAlphaNumeric(telIdList);
        },
        validateFunction
    );
    form.convertAndValidate(form, event, emailIdFormGroup, idList,
        function () {
            converter.convertHanAlphaNumeric(emailIdList);
        },
        validateFunction
    );
};

var executeAllValidator = function (event) {
    form.forceAllFocused(form);
    [
        zipcodeValidator,
        addressValidator,
        telAndEmailValidator
    ].forEach(function (validator) {
        validator(event);
    });
};

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

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

    // サーバにリクエストを送信する
    $("#input02Form").attr("action", url);
    $("#input02Form").submit();

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

$(document).ready(function () {
    // 入力チェック用の validator 関数をセットする
    $("#zipcode1").on("blur", zipcodeValidator);
    $("#zipcode2").on("blur", zipcodeValidator);
    $("#address").on("blur", addressValidator);
    $("#tel1").on("blur", telAndEmailValidator);
    $("#tel2").on("blur", telAndEmailValidator);
    $("#tel3").on("blur", telAndEmailValidator);
    $("#email").on("blur", telAndEmailValidator);

    // 「前の画面へ戻る」「次へ」ボタンクリック時の処理をセットする
    $(".js-btn-back").on("click", function (event) {
        return btnBackOrNextClickHandler(event, "/inquiry/input/02/?move=back");
    });
    $(".js-btn-next").on("click", function (event) {
        return btnBackOrNextClickHandler(event, "/inquiry/input/02/?move=next");
    });

    // 「郵便番号」の左側の項目にフォーカスをセットする
    $("#zipcode1").focus().select();
});

input02.html を修正する

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

            <div class="text-center">
              <br/>
              <p class="text-primary text-bold">※電話番号とメールアドレスのいずれか一方は必ず入力してください。</p><br/>
              <button class="btn bg-blue js-btn-back"><i class="fa fa-arrow-left"></i> 前の画面へ戻る</button>
              <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"> へ変更します。

動作確認

npm run springboot を実行し、Tomcat を起動して http://localhost:9080/inquiry/input/02/ にアクセスすると入力画面2が表示されます。

f:id:ksby:20170920224201p:plain

何も入力せずに Tab キーでカーソルを「前の画面へ戻る」ボタンまで移動すると赤色になってエラーメッセージが表示されます。

f:id:ksby:20170920224341p:plain

F5 キーを押してリロードした後「次へ」ボタンをいきなりクリックすると全ての項目がエラーになり、カーソルは一番最初のエラー項目である「郵便番号」の左側の項目へ移動します。また入力画面3へは遷移しません。今は「前の画面へ戻る」ボタンをクリックしても同じように全ての項目が赤色になり、画面遷移はしません。

f:id:ksby:20170920224505p:plain

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

f:id:ksby:20170920225837p:plain

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

f:id:ksby:20170920230125p:plain

F5 キーを押してリロードした後、電話番号とメールアドレスに “電話番号とメールアドレスのいずれか一方を入力してください” のエラーメッセージが表示されている状態にしてから、

f:id:ksby:20170920230726p:plain

電話番号を入力すると、入力されている値に従ったエラーメッセージが表示されます。電話番号が入力されていればメールアドレスは入力不要なので緑色になります。

f:id:ksby:20170920230848p:plain f:id:ksby:20170920230957p:plain f:id:ksby:20170920231134p:plain

電話番号には何も入力せずにメールアドレスを入力すると、電話番号もメールアドレスもどちらも緑色になります。

f:id:ksby:20170920231338p:plain

メールアドレスを入力して、かつ電話番号にエラーのある値を入力すると、電話番号だけエラーメッセージが表示されます。

f:id:ksby:20170921063058p:plain

問題なく動作しているようです。

次回は。。。

引き続き Javascript の処理を実装します。

今回実装しなかった以下の2つと、

  • メールアドレスチェック
  • 「前の画面へ戻る」ボタンがクリックされた時は入力されているものだけ入力チェックする

実装していて気づいた以下の1点の予定です。

  • Form.js のメソッド内の処理で Array.reduce に変更可能なものは変更する

履歴

2017/09/21
初版発行。

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

概要

記事一覧はこちらです。

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

  • 今回の手順で確認できるのは以下の内容です。
    • 入力画面1の作成
    • サーバ側のテストを作成します。

参照したサイト・書籍

  1. Learning Ant path style
    https://stackoverflow.com/questions/2952196/learning-ant-path-style

目次

  1. InquiryInput01Form クラスのテストを作成する
  2. ksbysample-webapp-lending から TestHelper, HtmlResultMatchers クラスをコピーする
  3. HtmlResultMatchers クラスに val メソッドを追加する
  4. テストデータを用意する
  5. InquiryInputController クラスのテストを作成する
  6. 次回は。。。

手順

InquiryInput01Form クラスのテストを作成する

src/main/java/ksbysample/webapp/bootnpmgeb/web/inquiry/form/InquiryInput01Form.java で Ctrl+Shift+T を押して「Create Test」ダイアログを表示してから、以下の画像の値にした後「OK」ボタンをクリックします。

f:id:ksby:20170909065713p:plain

src/test/groovy/ksbysample/webapp/bootnpmgeb/web/inquiry/form/InquiryInput01FormTest.groovy が新規作成されるので、以下の内容を記述します。

package ksbysample.webapp.bootnpmgeb.web.inquiry.form

import spock.lang.Specification
import spock.lang.Unroll

import javax.validation.ConstraintViolation
import javax.validation.Validation

class InquiryInput01FormTest extends Specification {

    def validator
    def inquiryInput01Form

    def setup() {
        validator = Validation.buildDefaultValidatorFactory().getValidator()
        inquiryInput01Form = new InquiryInput01Form(
                lastname: "田中"
                , firstname: "太郎"
                , lastkana: "たなか"
                , firstkana: "たろう"
                , sex: "1"
                , age: "30"
                , job: "1")
    }

    def "placeholder で表示している例の Bean Validation のテスト"() {
        when:
        Set<ConstraintViolation<InquiryInput01Form>> constraintViolations =
                validator.validate(inquiryInput01Form)

        then:
        constraintViolations.size() == 0
    }

    @Unroll
    def "lastname の Bean Validation の Bean Validation のテスト(#lastname --> #size)"() {
        setup:
        inquiryInput01Form.lastname = lastname
        Set<ConstraintViolation<InquiryInput01Form>> constraintViolations =
                validator.validate(inquiryInput01Form)

        expect:
        constraintViolations.size() == size

        where:
        lastname || size
        ""       || 2
        "a"      || 0
        "a" * 20 || 0
        "a" * 21 || 1
    }

    @Unroll
    def "firstname の Bean Validation のテスト(#firstname --> #size)"() {
        setup:
        inquiryInput01Form.firstname = firstname
        Set<ConstraintViolation<InquiryInput01Form>> constraintViolations =
                validator.validate(inquiryInput01Form)

        expect:
        constraintViolations.size() == size

        where:
        firstname || size
        ""        || 2
        "a"       || 0
        "a" * 20  || 0
        "a" * 21  || 1
    }

    @Unroll
    def "lastkana の Bean Validation のテスト(#lastkana --> #size)"() {
        setup:
        inquiryInput01Form.lastkana = lastkana
        Set<ConstraintViolation<InquiryInput01Form>> constraintViolations =
                validator.validate(inquiryInput01Form)

        expect:
        constraintViolations.size() == size

        where:
        lastkana || size
        ""       || 3
        "a"      || 1
        "a" * 20 || 1
        "a" * 21 || 2
        "あ"      || 0
        "あ" * 20 || 0
        "あ" * 21 || 1
        "ア"      || 1
        "A"      || 1
        "1"      || 1
        "あa"     || 1
    }

    @Unroll
    def "firstkana の Bean Validation のテスト(#firstkana --> #size)"() {
        setup:
        inquiryInput01Form.firstkana = firstkana
        Set<ConstraintViolation<InquiryInput01Form>> constraintViolations =
                validator.validate(inquiryInput01Form)

        expect:
        constraintViolations.size() == size

        where:
        firstkana || size
        ""        || 3
        "a"       || 1
        "a" * 20  || 1
        "a" * 21  || 2
        "あ"       || 0
        "あ" * 20  || 0
        "あ" * 21  || 1
        "ア"       || 1
        "A"       || 1
        "1"       || 1
        "あa"      || 1
    }

    @Unroll
    def "sex の Bean Validation のテスト(#sex --> #size)"() {
        setup:
        inquiryInput01Form.sex = sex
        Set<ConstraintViolation<InquiryInput01Form>> constraintViolations =
                validator.validate(inquiryInput01Form)

        expect:
        constraintViolations.size() == size

        where:
        sex || size
        ""  || 1
        "0" || 1
        "1" || 0
        "2" || 0
        "3" || 1
    }

    @Unroll
    def "age の Bean Validation のテスト(#age --> #size)"() {
        setup:
        inquiryInput01Form.age = age
        Set<ConstraintViolation<InquiryInput01Form>> constraintViolations =
                validator.validate(inquiryInput01Form)

        expect:
        constraintViolations.size() == size

        where:
        age    || size
        ""     || 2
        "0"    || 0
        "1"    || 0
        "999"  || 0
        "1000" || 1
        "0.1"  || 1
    }

    @Unroll
    def "job の Bean Validation のテスト(#job --> #size)"() {
        setup:
        inquiryInput01Form.job = job
        Set<ConstraintViolation<InquiryInput01Form>> constraintViolations =
                validator.validate(inquiryInput01Form)

        expect:
        constraintViolations.size() == size

        where:
        job || size
        ""  || 0
        "0" || 1
        "1" || 0
        "2" || 0
        "3" || 0
        "4" || 1
    }

}

テストを実行して全て成功することを確認します。

f:id:ksby:20170909070152p:plain

ksbysample-webapp-lending から TestHelper, HtmlResultMatchers クラスをコピーする

テストに使用したいので、ksbysample-webapp-lending から TestHelper, HtmlResultMatchers クラスをコピーします。

src/test/java の下に ksbysample.common.test.helper パッケージを作成します。

src/test/java/ksbysample/common/test/helper の下に TestHelper.java をコピーします。

src/test/java/ksbysample/common/test の下に matcher パッケージを作成します。

src/test/java/ksbysample/common/test/matcher の下に HtmlResultMatchers.java をコピーします。

HtmlResultMatchers クラスは jsoup が必要なので導入します。build.gradle の以下の点を変更します。

dependencies {
    ..........

    // dependency-management-plugin によりバージョン番号が自動で設定されないもの、あるいは最新バージョンを指定したいもの
    ..........
    testCompile("com.google.code.findbugs:jsr305:3.0.2")
    testCompile("org.jsoup:jsoup:1.10.3")

    ..........
}
  • testCompile("org.jsoup:jsoup:1.10.3") を追加します。

変更後、Gradle Tool Window の左上にある「Refresh all Gradle projects」ボタンをクリックして更新します。

HtmlResultMatchers クラスに val メソッドを追加する

今回のテストでは value 属性の値をチェックしたいのですが、今の HtmlResultMatchers クラスには value 属性を検証するためのメソッドが用意されていないので追加します。

src/test/java/ksbysample/common/test/matcher/HtmlResultMatchers.java の以下の点を変更します。

public class HtmlResultMatchers {

    ..........

    public ResultMatcher text(final String expectedText) {
        return mvcResult -> assertThat(selectFirst(mvcResult).text(), is(expectedText));
    }

    /**
     * HTML 内の cssQuery で指定された Element の value 属性の値を取得し、
     * 引数で渡された文字列と同じかチェックする
     *
     * @param expectedText 文字列の期待値
     * @return {@link ResultMatcher}
     */
    public ResultMatcher val(final String expectedText) {
        return mvcResult -> assertThat(selectFirst(mvcResult).val(), is(expectedText));
    }

    ..........

}
  • val メソッドを追加します。

テストデータを用意する

src/test/resources の下に ksbysample/webapp/bootnpmgeb/web/inquiry ディレクトリを新規作成します。

src/test/resources/ksbysample/webapp/bootnpmgeb/web/inquiry の下に InquiryInput01Form_001.yaml を新規作成し、以下の内容を記述します。

!!ksbysample.webapp.bootnpmgeb.web.inquiry.form.InquiryInput01Form
# 最大文字数のデータ、選択肢の場合には一番最後のデータをセットする
lastname: 12345678901234567890
firstname: 12345678901234567890
lastkana: あいうえおかきくけこさしすせそたちつてと
firstkana: なにぬねのはひふへほまみむめもあいうえお
sex: 2
age: 999
job: 3

InquiryInputController クラスのテストを作成する

src/main/java/ksbysample/webapp/bootnpmgeb/web/inquiry/InquiryInputController.java で Ctrl+Shift+T を押して「Create Test」ダイアログを表示してから、以下の画像の値にした後「OK」ボタンをクリックします。

f:id:ksby:20170913001740p:plain

MockMvc を使用するので Groovy + Spock ではなく Groovy + JUnit4 で作成するのですが、「Create Test」ダイアログで JUnit4 を選択すると拡張子が .java になってしまうので、Spock を選択してファイルを生成してから中身を Groovy + JUnit4 に変更します。

src/test/groovy/ksbysample/webapp/bootnpmgeb/web/inquiry/InquiryInputControllerTest.groovy が新規作成されるので、以下の内容を記述します。

package ksbysample.webapp.bootnpmgeb.web.inquiry

import ksbysample.common.test.helper.TestHelper
import ksbysample.webapp.bootnpmgeb.web.inquiry.form.InquiryInput01Form
import org.junit.Before
import org.junit.Test
import org.junit.experimental.runners.Enclosed
import org.junit.runner.RunWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.mock.web.MockHttpSession
import org.springframework.test.context.junit4.SpringRunner
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.MvcResult
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.web.context.WebApplicationContext
import org.yaml.snakeyaml.Yaml

import static ksbysample.common.test.matcher.HtmlResultMatchers.html
import static org.assertj.core.api.Assertions.catchThrowable
import static org.assertj.core.api.AssertionsForClassTypes.assertThat
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrlPattern
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status

@RunWith(Enclosed)
class InquiryInputControllerTest {

    @RunWith(SpringRunner)
    @SpringBootTest
    static class 入力画面1のテスト {
        private InquiryInput01Form inquiryInput01Form_001 =
                (InquiryInput01Form) new Yaml().load(getClass().getResourceAsStream("InquiryInput01Form_001.yaml"))

        @Autowired
        private WebApplicationContext context

        MockMvc mockMvc

        @Before
        void setup() {
            mockMvc = MockMvcBuilders.webAppContextSetup(context)
                    .build()
        }

        @Test
        void "初期表示時は画面の項目には何もセットされない"() {
            expect:
            mockMvc.perform(get("/inquiry/input/01"))
                    .andExpect(status().isOk())
                    .andExpect(html("#lastname").val(""))
                    .andExpect(html("#firstname").val(""))
                    .andExpect(html("#lastkana").val(""))
                    .andExpect(html("#firstkana").val(""))
                    .andExpect(html("input[name='sex'][checked='checked']").notExists())
                    .andExpect(html("#age").val(""))
                    .andExpect(html("select[name=job] option[selected]").notExists())
        }

        @Test
        void "項目全てに入力して入力画面2へ遷移してから戻ると以前入力したデータがセットされて表示される"() {
            expect: "項目全てに入力して「次へ」ボタンをクリックする"
            MvcResult result = mockMvc.perform(
                    TestHelper.postForm("/inquiry/input/01?move=next", inquiryInput01Form_001))
                    .andExpect(status().isFound())
                    .andExpect(redirectedUrlPattern("**/inquiry/input/02"))
                    .andReturn()
            MockHttpSession session = result.getRequest().getSession()

            and: "再び入力画面1を表示する"
            mockMvc.perform(get("/inquiry/input/01").session(session))
                    .andExpect(status().isOk())
                    .andExpect(html("#lastname").val(inquiryInput01Form_001.lastname))
                    .andExpect(html("#firstname").val(inquiryInput01Form_001.firstname))
                    .andExpect(html("#lastkana").val(inquiryInput01Form_001.lastkana))
                    .andExpect(html("#firstkana").val(inquiryInput01Form_001.firstkana))
                    .andExpect(html("input[name='sex'][checked='checked']").val(inquiryInput01Form_001.sex))
                    .andExpect(html("#age").val(inquiryInput01Form_001.age))
                    .andExpect(html("select[name=job] option[selected]").val(inquiryInput01Form_001.job))
        }

        @Test
        void "入力チェックエラーのあるデータで入力画面2へ遷移しようとするとIllegalArgumentExceptionが発生する"() {
            setup: "入力チェックエラーになるデータを用意する"
            inquiryInput01Form_001.lastname = "x" * 21

            expect: "入力画面1の「次へ」ボタンをクリックする"
            Throwable thrown = catchThrowable({
                mockMvc.perform(
                        TestHelper.postForm("/inquiry/input/01?move=next", inquiryInput01Form_001))
                        .andExpect(status().isOk())
            })
            assertThat(thrown.cause).isInstanceOf(IllegalArgumentException)
        }

    }

}

テストを実行して全て成功することを確認します。

f:id:ksby:20170913002519p:plain

次回は。。。

入力画面2を作成します。

履歴

2017/09/13
初版発行。

IntelliJ IDEA を 2017.2.2 → 2017.2.3 へバージョンアップ

IntelliJ IDEA を 2017.2.2 → 2017.2.3 へバージョンアップする

IntelliJ IDEA の 2017.2.3 がリリースされたのでバージョンアップします。

※ksbysample-webapp-lending プロジェクトを開いた状態でバージョンアップしています。

  1. IntelliJ IDEA のメインメニューから「Help」-「Check for Updates…」を選択します。

  2. IDE and Plugin Updates」ダイアログが表示されます。左下に「Update and Restart」ボタンが表示されていますので、「Update and Restart」ボタンをクリックします。

    f:id:ksby:20170908071144p:plain

  3. Plugin の update も表示されました。「Error-prone Compiler Integration」はバージョンアップすると動かなくなりますので、これだけチェックを外して「Update and Restart」ボタンをクリックします。

    f:id:ksby:20170908071303p:plain

  4. Patch がダウンロードされて IntelliJ IDEA が再起動します。

  5. IntelliJ IDEA が起動すると画面下部に「Indexing…」のメッセージが表示されますので、終了するまで待機します。

    f:id:ksby:20170908071819p:plain

  6. IntelliJ IDEA のメインメニューから「Help」-「About」を選択し、2017.2.3 へバージョンアップされていることを確認します。

  7. Gradle Tool Window のツリーを見ると「Tasks」の下に「other」しかない状態になっているので、左上にある「Refresh all Gradle projects」ボタンをクリックして更新します。

    f:id:ksby:20170908072009p:plain

  8. clean タスク実行 → Rebuild Project 実行 → build タスクを実行して、"BUILD SUCCESSFUL" のメッセージが出力されることを確認します。

    f:id:ksby:20170908072949p:plain

  9. Project Tool Window で src/test を選択した後、コンテキストメニューを表示して「Run ‘All Tests’ with Coverage」を選択し、テストが全て成功することを確認します。

    f:id:ksby:20170908073911p:plain

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

概要

記事一覧はこちらです。

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

  • 今回の手順で確認できるのは以下の内容です。
    • 入力画面1の作成
    • サーバ側の処理を実装します。また実装後に見つかった HTML, Javascript 側の問題点も修正します。

参照したサイト・書籍

  1. ModelMapper
    http://modelmapper.org/

  2. Spring Boot ModelMapper Starter
    https://github.com/rozidan/modelmapper-spring-boot-starter

目次

  1. Form クラスを作成する
  2. セッション保存用のクラスを作成する
  3. input01.html, input01.js を変更する
  4. ModdelMapper をインストールする
  5. 画面表示時と「次へ」ボタンクリック時の処理を実装する
  6. 動作確認
  7. HTML5 の autofocus 属性では項目にデータが入っている時に選択状態にしてくれない問題を修正する(Firefox の autokana の問題も「お名前(漢字)」の「名」が文字化けする問題もこれで解決!)
  8. 入力チェック前の状態ではなく入力チェックした後の状態で表示されるようにする
  9. autokana で自動入力すると maxlength 以上の文字列が入力される問題を修正する
  10. 次回は。。。
  11. メモ書き
    1. IntelliJ IDEA の正規表現チェック機能を使ってみる

手順

Form クラスを作成する

src/main/java/ksbysample/webapp/bootnpmgeb/web/inquiry の下に form パッケージを新規作成します。

src/main/java/ksbysample/webapp/bootnpmgeb/web/inquiry/form の下に InquiryInput01Form.java を新規作成し、以下の内容を記述します。

package ksbysample.webapp.bootnpmgeb.web.inquiry.form;

import ksbysample.webapp.bootnpmgeb.values.JobValues;
import ksbysample.webapp.bootnpmgeb.values.SexValues;
import ksbysample.webapp.bootnpmgeb.values.validation.ValuesEnum;
import lombok.Data;
import org.hibernate.validator.constraints.NotEmpty;

import javax.validation.constraints.Digits;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;
import java.io.Serializable;

/**
 * 入力画面1用 Form クラス
 */
@Data
public class InquiryInput01Form implements Serializable {

    private static final long serialVersionUID = -7017712118767300185L;

    @NotEmpty
    @Size(min = 1, max = 20)
    private String lastname;

    @NotEmpty
    @Size(min = 1, max = 20)
    private String firstname;

    @NotEmpty
    @Size(min = 1, max = 20)
    @Pattern(regexp = "^[\\u3041-\\u3096]+$")
    private String lastkana;

    @NotEmpty
    @Size(min = 1, max = 20)
    @Pattern(regexp = "^[\\u3041-\\u3096]+$")
    private String firstkana;

    @ValuesEnum(enumClass = SexValues.class)
    private String sex;

    @NotEmpty
    @Digits(integer = 3, fraction = 0)
    private String age;

    @ValuesEnum(enumClass = JobValues.class, allowEmpty = true)
    private String job;

}

セッション保存用のクラスを作成する

src/main/java/ksbysample/webapp/bootnpmgeb の下に session パッケージを新規作成します。

src/main/java/ksbysample/webapp/bootnpmgeb/session の下に SessionData.java を新規作成し、以下の内容を記述します。

package ksbysample.webapp.bootnpmgeb.session;

import ksbysample.webapp.bootnpmgeb.web.inquiry.form.InquiryInput01Form;
import lombok.Data;

import java.io.Serializable;

/**
 * セッションデータ用クラス
 */
@Data
public class SessionData implements Serializable {

    private static final long serialVersionUID = -2673191456750655164L;

    private InquiryInput01Form inquiryInput01Form;

}

input01.html, input01.js を変更する

src/main/resources/templates/web/inquiry/input01.html の以下の点を変更します。

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{web/common/fragments :: common_header(~{::title}, ~{::link}, ~{::style})}">
  <title>入力フォーム - 入力画面1</title>
</head>

<body class="skin-blue layout-top-nav">
<div class="wrapper">

  <!-- Content Wrapper. Contains page content -->
  <div class="content-wrapper">
    <!-- Content Header (Page header) -->
    <section class="content-header">
      <h1>
        入力画面1
      </h1>
    </section>

    <!-- Main content -->
    <section class="content">
      <div class="row">
        <div class="col-xs-12">
          <!--/*@thymesVar id="inquiryInput01Form" type="ksbysample.webapp.bootnpmgeb.web.inquiry.form.InquiryInput01Form"*/-->
          <form id="inquiryInput01Form" class="form-horizontal" method="post" action=""
                th:action="@{/inquiry/input/01/}"
                th:object="${inquiryInput01Form}">
            <!-- お名前(漢字) -->
            <div class="form-group" id="form-group-name">
              <div class="control-label col-sm-2">
                <label class="float-label">お名前(漢字)</label>
                <div class="label label-required">必須</div>
              </div>
              <div class="col-sm-10">
                <div class="row">
                  <div class="col-sm-10">
                    <input type="text" name="lastname" id="lastname" class="form-control form-control-inline"
                           style="width: 150px;" maxlength="20" value="" placeholder="例)田中" autofocus
                           th:field="*{lastname}"/>
                    <input type="text" name="firstname" id="firstname" class="form-control form-control-inline"
                           style="width: 150px;" maxlength="20" value="" placeholder="例)太郎"
                           th:field="*{firstname}"/>
                  </div>
                </div>
                <div class="row hidden js-errmsg">
                  <div class="col-sm-10">
                    <p class="form-control-static text-danger">
                      <small>ここにエラーメッセージを表示します</small>
                    </p>
                  </div>
                </div>
              </div>
            </div>

            <!-- お名前(かな) -->
            <div class="form-group" id="form-group-kana">
              <div class="control-label col-sm-2">
                <label class="float-label">お名前(かな)</label>
                <div class="label label-required">必須</div>
              </div>
              <div class="col-sm-10">
                <div class="row">
                  <div class="col-sm-10">
                    <input type="text" name="lastkana" id="lastkana" class="form-control form-control-inline"
                           style="width: 150px;" maxlength="20" value="" placeholder="例)たなか"
                           th:field="*{lastkana}"/>
                    <input type="text" name="firstkana" id="firstkana" class="form-control form-control-inline"
                           style="width: 150px;" maxlength="20" value="" placeholder="例)たろう"
                           th:field="*{firstkana}"/>
                  </div>
                </div>
                <div class="row hidden js-errmsg">
                  <div class="col-sm-10">
                    <p class="form-control-static text-danger">
                      <small>ここにエラーメッセージを表示します</small>
                    </p>
                  </div>
                </div>
              </div>
            </div>

            <!-- 性別 -->
            <div class="form-group" id="form-group-sex">
              <div class="control-label col-sm-2">
                <label class="float-label">性別</label>
                <div class="label label-required">必須</div>
              </div>
              <div class="col-sm-10">
                <div class="row">
                  <div class="col-sm-10">
                    <div class="radio-inline" th:each="sexValue : ${@vh.values('SexValues')}">
                      <label><input type="radio" name="sex" th:value="${sexValue.value}"
                                    th:text="${sexValue.text}"
                                    th:field="*{sex}"></label>
                    </div>
                  </div>
                </div>
                <div class="row hidden js-errmsg">
                  <div class="col-sm-10">
                    <p class="form-control-static text-danger">
                      <small>ここにエラーメッセージを表示します</small>
                    </p>
                  </div>
                </div>
              </div>
            </div>

            <!-- 年齢 -->
            <div class="form-group" id="form-group-age">
              <div class="control-label col-sm-2">
                <label class="float-label">年齢</label>
                <div class="label label-required">必須</div>
              </div>
              <div class="col-sm-10">
                <div class="row">
                  <div class="col-sm-10">
                    <input type="text" name="age" id="age" class="form-control form-control-inline"
                           style="width: 150px;" maxlength="3" value="" placeholder="例)30"
                           th:field="*{age}"/>
                    <p class="form-control-static-inline"></p>
                  </div>
                </div>
                <div class="row hidden js-errmsg">
                  <div class="col-sm-10">
                    <p class="form-control-static text-danger">
                      <small>ここにエラーメッセージを表示します</small>
                    </p>
                  </div>
                </div>
              </div>
            </div>

            <!-- 職業 -->
            <div class="form-group" id="form-group-job">
              <div class="control-label col-sm-2">
                <label class="float-label">職業</label>
              </div>
              <div class="col-sm-10">
                <div class="row">
                  <div class="col-sm-10">
                    <select name="job" id="job" class="form-control" style="width: 250px;">
                      <th:block th:each="jobValue,iterStat : ${@vh.values('JobValues')}">
                        <option th:if="${iterStat.first}" value="">選択してください</option>
                        <option th:value="${jobValue.value}" th:text="${jobValue.text}" th:field="*{job}">会社員</option>
                      </th:block>
                    </select>
                  </div>
                </div>
                <div class="row hidden js-errmsg">
                  <div class="col-sm-10">
                    <p class="form-control-static text-danger">
                      <small>ここにエラーメッセージを表示します</small>
                    </p>
                  </div>
                </div>
              </div>
            </div>

            <div class="text-center">
              <button class="btn bg-green js-btn-next"><i class="fa fa-arrow-right"></i> 次へ</button>
            </div>
          </form>
        </div>
      </div>
    </section>
    <!-- /.content -->
  </div>
  <!-- /.content-wrapper -->
</div>
<!-- ./wrapper -->

<!-- REQUIRED JS SCRIPTS -->
<script src="/js/inquiry/input01.js"></script>

</body>
</html>
  • form タグの上に <!--/*@thymesVar id="inquiryInput01Form" type="ksbysample.webapp.bootnpmgeb.web.inquiry.form.InquiryInput01Form"*/--> を追加します。
  • <form id="input01Form" ...<form id="inquiryInput01Form" ... に変更します。
  • form タグの末尾に th:object="${inquiryInput01Form}" を追加します。
  • 入力/選択項目のタグに th:field="*{...}"(… には入力項目に対応した変数を記述) を追加します。

form タグの id 属性を変更したので src/main/assets/js/inquiry/input01.js の以下の点を変更します。

var btnNextClickHandler = function (event) {
    ..........

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

    ..........
};
  • $("#input01Form")$("#inquiryInput01Form") に変更します。

ModdelMapper をインストールする

今回 POJO 間のデータコピーには org.springframework.beans.BeanUtils クラスではなく ModelMapper を使用してみます。インストールは ModelMapper を直接インストールするのではなく、Spring Boot の Auto Configuraion を見つけましたので Spring Boot ModelMapper Starter を使用してインストールします。

build.gradle の以下の点を変更します。

dependencies {
    ..........

    // dependency-management-plugin によりバージョン番号が自動で設定されないもの、あるいは最新バージョンを指定したいもの
    compile("com.integralblue:log4jdbc-spring-boot-starter:1.0.1")
    compile("org.flywaydb:flyway-core:4.2.0")
    compile("com.h2database:h2:1.4.192")
    compile("com.github.rozidan:modelmapper-spring-boot-starter:1.0.0")
    testCompile("org.dbunit:dbunit:2.5.3")
    testCompile("com.icegreen:greenmail:1.5.5")
    testCompile("org.assertj:assertj-core:3.8.0")
    testCompile("org.spockframework:spock-core:${spockVersion}")
    testCompile("org.spockframework:spock-spring:${spockVersion}")
    testCompile("com.google.code.findbugs:jsr305:3.0.2")

    ..........
}
  • compile("com.github.rozidan:modelmapper-spring-boot-starter:1.0.0") を追加します。

変更後、Gradle Tool Window の左上にある「Refresh all Gradle projects」ボタンをクリックして更新します。

画面表示時と「次へ」ボタンクリック時の処理を実装する

src/main/java/ksbysample/webapp/bootnpmgeb/web/inquiry/InquiryInputController.java の以下の点を変更します。

@Controller
@RequestMapping("/inquiry/input")
@SessionAttributes("sessionData")
public class InquiryInputController {

    ..........

    private final ModelMapper modelMapper;

    /**
     * コンストラクタ
     *
     * @param modelMapper {@link ModelMapper} オブジェクト
     */
    public InquiryInputController(ModelMapper modelMapper) {
        this.modelMapper = modelMapper;
    }

    /**
     * 入力画面1 初期表示処理
     *
     * @return 入力画面1の Thymeleaf テンプレートファイルのパス
     */
    @GetMapping("/01")
    public String input01(InquiryInput01Form inquiryInput01Form
            , SessionData sessionData) {
        // セッションに保存されているデータがある場合にはコピーする
        if (sessionData.getInquiryInput01Form() != null) {
            modelMapper.map(sessionData.getInquiryInput01Form(), inquiryInput01Form);
        }

        return TEMPLATE_INPUT01;
    }

    /**
     * 入力画面1 「次へ」ボタンクリック時の処理
     *
     * @return 入力画面2の URL
     */
    @PostMapping(value = "/01", params = {"move=next"})
    public String input01MoveNext(@Validated InquiryInput01Form inquiryInput01Form
            , BindingResult bindingResult
            , SessionData sessionData
            , UriComponentsBuilder builder) {
        if (bindingResult.hasErrors()) {
            throw new IllegalArgumentException("セットされるはずのないデータがセットされています");
        }

        // 入力されたデータをセッションに保存する
        sessionData.setInquiryInput01Form(inquiryInput01Form);

        return UrlBasedViewResolver.REDIRECT_URL_PREFIX
                + builder.path(UrlConst.URL_INQUIRY_INPUT_02).toUriString();
    }
  • @SessionAttributes("sessionData") を追加します。
  • private final ModelMapper modelMapper; とコンストラクタを追加します。
  • input01 メソッドに上記の引数を追加し、セッションに保存されているデータがある場合にはコピーする処理を追加します。
  • input01MoveNext メソッドに上記の引数を追加し、入力チェック処理と入力されたデータをセッションに保存する処理を追加します。

動作確認

動作確認します。npm run springboot コマンドを実行し Tomcat を起動した後、ブラウザで http://localhost:9080/inquiry/input/01/ にアクセスします。

データを入力してから、

f:id:ksby:20170902211625p:plain

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

f:id:ksby:20170902211829p:plain

「前の画面へ戻る」ボタンをクリックして入力画面1へ戻ると、

f:id:ksby:20170903211022p:plain

入力画面1へ戻りデータが表示されるのですが、以下の問題がありました。

  • 「お名前(漢字)」の「姓」にカーソルが当たるのですが選択状態で表示されません。
  • 入力チェックした後の状態ではなく入力チェック前の状態で表示されています。
  • なぜか「お名前(漢字)」の「名」だけ文字化けしています。何度か試したところ、この現象は IE だけ出て FirefoxChrome では出ず、IE でも最初から出る時と最初は出ずに入力画面2と何度か画面遷移させると出る時がありました。謎です。。。

また上記以外にもいろいろ試していて、以下の問題にも気づきました。

  • Firefox だと一番最初の「お名前(漢字)」の「姓」から1度カーソルを移動しないと autokana が機能しません。
  • 「お名前(漢字)」にかなを20文字入力すると「お名前(かな)」にもかなが20文字入力されるのですが、その後に「お名前(漢字)」にカーソルを移動して末尾の1文字を削除してから1文字追加すると「お名前(かな)」の maxlength は 20 で設定しているのに21文字入力されてしまいます。

あと、「お名前(漢字)」に何も入力せずに「お名前(かな)」を先に入力して、その後に「お名前(漢字)」にカーソルを移動すると「お名前(かな)」に入力していたデータが消えるのですが、これは autokana を入れている場合には正常な動作だと思うのでこのままにします。

HTML5 の autofocus 属性では項目にデータが入っている時に選択状態にしてくれない問題を修正する(Firefox の autokana の問題も「お名前(漢字)」の「名」が文字化けする問題もこれで解決!)

HTML5 の autofocus はフォーカスを移動してくれるだけで、選択状態にはしてくれないようです。FirefoxChrome でもダメでした。

画面初期表示時に Javascript でフォーカスをセット&選択状態にするようにします。

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

$(document).ready(function () {
    ..........

    // 「お名前(漢字)」の「姓」にフォーカスをセットする
    $("#lastname").focus().select();
});
  • $("#lastname").focus().select(); を追加します。

src/main/resources/templates/web/inquiry/input01.html の以下の点を変更します。

                    <input type="text" name="lastname" id="lastname" class="form-control form-control-inline"
                           style="width: 150px;" maxlength="20" value="" placeholder="例)田中"
                           th:field="*{lastname}"/>
  • <input type="text" name="lastname" ... から autofocus を削除します。

動作確認すると今度は入力画面2から入力画面1に戻った時に選択状態になり、ついでに Firefox でカーソルを移動しないと autokana が機能しない問題も解決していました。

f:id:ksby:20170903092237p:plain

さらになぜか「お名前(漢字)」の「名」だけ文字化けする問題も解決していました!(入力画面2と何度か画面遷移させても全く現象が出ませんでした) 最初 HTML5 の autofocus を見た時はこんな便利なものがあるのか、と思ったものでしたが、使えませんでした。残念です。。。

入力チェック前の状態ではなく入力チェックした後の状態で表示されるようにする

セッションにデータが保存されていた場合には、画面表示時に入力チェックが実行されるようにします。

src/main/java/ksbysample/webapp/bootnpmgeb/web/inquiry/form/InquiryInput01Form.java の以下の点を変更します。

@Data
public class InquiryInput01Form implements Serializable {

    ..........

    private boolean copiedFromSession = false;

}
  • private boolean copiedFromSession = false; を追加します。セッションからデータをコピーした時に true にします。

src/main/java/ksbysample/webapp/bootnpmgeb/web/inquiry/InquiryInputController.java の以下の点を変更します。

    @GetMapping("/01")
    public String input01(InquiryInput01Form inquiryInput01Form
            , SessionData sessionData) {
        // セッションに保存されているデータがある場合にはコピーする
        if (sessionData.getInquiryInput01Form() != null) {
            modelMapper.map(sessionData.getInquiryInput01Form(), inquiryInput01Form);
            inquiryInput01Form.setCopiedFromSession(true);
        }

        return TEMPLATE_INPUT01;
    }
  • input01 メソッド内に inquiryInput01Form.setCopiedFromSession(true); を追加します。

src/main/resources/templates/web/inquiry/input01.html の以下の点を変更します。

          <form id="inquiryInput01Form" class="form-horizontal" method="post" action=""
                th:action="@{/inquiry/input/01/}"
                th:object="${inquiryInput01Form}">
            <input type="hidden" name="copiedFromSession" id="copiedFromSession" th:value="*{copiedFromSession}" />
  • form タグのすぐ下に <input type="hidden" name="copiedFromSession" id="copiedFromSession" th:value="*{copiedFromSession}" /> を追加します。

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

var executeAllValidator = function (event) {
    form.forceAllFocused(form);
    [
        nameValidator,
        kanaValidator,
        sexValidator,
        ageValidator,
        jobValidator
    ].forEach(function (validator) {
        validator(event);
    });
};

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

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

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

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

$(document).ready(function (event) {
    // 「お名前(漢字)」が入力された時に、かな文字列を「お名前(かな)」に自動入力されるようにする
    $.fn.autoKana('#lastname', '#lastkana');
    $.fn.autoKana('#firstname', '#firstkana');

    // 入力チェック用の validator 関数をセットする
    $("#lastname")
        .on("blur", nameValidator)
        .on("blur", kanaAutoInputValidator);
    $("#firstname")
        .on("blur", nameValidator)
        .on("blur", kanaAutoInputValidator);
    $("#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)

    // 初期画面表示時にセッションに保存されていたデータを表示する場合には
    // 入力チェックを実行して画面の表示を入力チェック後の状態にする
    if ($("#copiedFromSession").val() === "true") {
        executeAllValidator(event);
    }

    // 「お名前(漢字)」の「姓」にフォーカスをセットする
    $("#lastname").focus().select();
});
  • var executeAllValidator = function (event) { ... } を追加します。
  • btnNextClickHandler 関数内で全ての入力チェックを実行していた処理を executeAllValidator に置き換えます。
  • $(document).ready(function () {$(document).ready(function (event) { に変更します。
  • $(document).ready(function (event) { ... } 内に if ($("#copiedFromSession").val() === "true") { ... } を追加し、#copiedFromSession の値が "true" の場合には全ての入力チェックを実行します。

動作確認します。npm run springboot コマンドを停止→実行し Tomcat を再起動した後、http://localhost:9080/inquiry/input/01/ にアクセスしてデータを入力します。

f:id:ksby:20170906064749p:plain

「次へ」ボタンをクリックして入力画面2へ遷移し、

f:id:ksby:20170906064908p:plain

「前の画面へ戻る」ボタンをクリックすると全ての項目が緑で表示されました。

f:id:ksby:20170906065041p:plain

autokana で自動入力すると maxlength 以上の文字列が入力される問題を修正する

keyup イベント発生時に maxlength の文字数を超えていたら超えた分を削除するようにします。

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

function delStringExceedingMaxlength(id) {
    $(id).val($(id).val().substring(0, $(id).attr("maxlength")));
}

$(document).ready(function (event) {
    // 「お名前(漢字)」が入力された時に、かな文字列を「お名前(かな)」に自動入力されるようにする
    $.fn.autoKana('#lastname', '#lastkana');
    $.fn.autoKana('#firstname', '#firstkana');

    // autokana で自動入力されると maxlength の文字数を超える文字が自動入力される場合があるので
    // maxlegnth の文字数を超えた分を削除する
    $("#lastname").on("keyup", function (event) {
        delStringExceedingMaxlength("#lastkana");
    });
    $("#firstname").on("keyup", function (event) {
        delStringExceedingMaxlength("#firstkana");
    });

    // 入力チェック用の validator 関数をセットする
    ..........
  • function delStringExceedingMaxlength(id) { ... } を追加します。
  • $(document).ready(function (event) { ... } 内に以下の2つの処理を追加します。
    • $("#lastname").on("keyup", function (event) { ... }
    • $("#firstname").on("keyup", function (event) { ... }

動作確認します。「お名前(漢字)」に あいうえおかきくけこさしすせそたちつてと を入力すると「お名前(かな)」にも あいうえおかきくけこさしすせそたちつてと が入力されて、

f:id:ksby:20170906233824p:plain

「お名前(漢字)」で末尾の を削除して を入力しても「お名前(かな)」は あいうえおかきくけこさしすせそたちつてと のままでした。

f:id:ksby:20170906234209p:plain

次回は。。。

サーバ側のテストを書きます。入力画面1の実装は次で最後の予定です。

メモ書き

IntelliJ IDEA の正規表現チェック機能を使ってみる

今回 src/main/java/ksbysample/webapp/bootnpmgeb/web/inquiry/form/InquiryInput01Form.java に以下の画像の正規表現(ひらがなだけかチェックする)を記述しましたが、

f:id:ksby:20170906235208p:plain

正規表現のところにカーソル移動してから Alt+Enter を押してコンテキストメニューを表示した後、「Check RegExp」を選択すると、

f:id:ksby:20170906235341p:plain

入力された文字が正規表現と一致するのか否かを検証できるダイアログが表示されます。

f:id:ksby:20170906235520p:plain

あいうえお と入力すると右下に Matches! と表示されて入力フィールドも緑色になりますが、

f:id:ksby:20170906235715p:plain

アイウエオ に変えると No match と表示されて赤色に変わります。

f:id:ksby:20170906235919p:plain

確か機能が追加されたのは 2017.2 ではなくもう少し前のはずですが、変更内容をきちんと見ていませんでした。正規表現の動作確認をしたい時はよくあるので、これは嬉しい機能です。

履歴

2017/09/07
初版発行。

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

概要

記事一覧はこちらです。

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

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

参照したサイト・書籍

  1. JQUERY】自動的にカナ入力してくれるAUTOKANA.JSプラグインはいまや必須!
    http://plugmin.co/472/

  2. autokana
    https://github.com/harisenbon/autokana

  3. How to use jQuery-Plugins w/Webpack?
    https://laracasts.com/discuss/channels/elixir/how-to-use-jquery-plugins-wwebpack

  4. Webpackを使い倒す
    http://thujikun.github.io/blog/2014/12/07/webpack/

  5. ProvidePlugin
    https://webpack.js.org/plugins/provide-plugin/

目次

  1. autokana で「お名前(漢字)」が入力されたら「お名前(かな)」を自動入力させる
  2. ProvidePlugin を利用して jQuery を各ファイルで require 不要にする
  3. 「お名前(漢字)」「お名前(かな)」をどちらも入力チェックエラーにしてから、「お名前(漢字)」を入力して「お名前(かな)」に autokana で自動入力されても「お名前(かな)」の入力チェックエラー状態が解除されないのを修正する
  4. メモ書き
    1. input01.js で関数を var xxx = function() { ... } で記述しているのは IntelliJ IDEA が見やすく表示してくれるからである

手順

autokana で「お名前(漢字)」が入力されたら「お名前(かな)」を自動入力させる

日本語入力に対するふりがな/フリガナを自動的に別フィールドに入力する autokana という jQueryプラグインがあることを知りましたので、インストールして「お名前(漢字)」に入力された文字のかな文字列を「お名前(かな)」に自動入力されるようにしてみます。

autokana は npm ではインストールできませんので、autokanaGitHub のページから jquery.autoKana.js をダウンロードします。

src/main/assets/js/lib の下に vendor/autokana ディレクトリを新規作成した後、その下に jquery.autoKana.js を配置します。

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

window.$ = window.jQuery = 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");
require("vendor/autokana/jquery.autoKana.js");

..........

$(document).ready(function () {
    // 「お名前(漢字)」が入力された時に、かな文字列を「お名前(かな)」に自動入力されるようにする
    $.fn.autoKana('#lastname', '#lastkana');
    $.fn.autoKana('#firstname', '#firstkana');

    // 入力チェック用の 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)
});
  • jQuery を require する変数を var $ =window.$ = window.jQuery = に変更します。
  • require("vendor/autokana/jquery.autoKana.js"); を追加します。
  • $(document).ready(function () { ... } の中に以下の2行を追加します。
    • $.fn.autoKana('#lastname', '#lastkana');
    • $.fn.autoKana('#firstname', '#firstkana');

動作確認します。npm run springboot コマンドを実行し、Tomcat を起動した後、http://localhost:9080/inquiry/input/01/ にアクセスします。

「お名前(漢字)」に入力すると「お名前(かな)」にかな文字列が入力されました。これは便利ですね。

f:id:ksby:20170902071806p:plain

ProvidePlugin を利用して jQuery を各ファイルで require 不要にする

jQuery を利用する時には各 js ファイルで var $ = require("admin-lte/plugins/jQuery/jquery-2.2.3.min.js"); を記述していましたが、ProvidePlugin を利用すると省略できるそうなので設定してみます。

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

var webpack = require('webpack');

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"
        ]
    },
    plugins: [
        new webpack.ProvidePlugin({
            $: "admin-lte/plugins/jQuery/jquery-2.2.3.min.js",
            jQuery: "admin-lte/plugins/jQuery/jquery-2.2.3.min.js"
        })
    ]
};
  • var webpack = require('webpack'); を追加します。
  • plugins: [ new webpack.ProvidePlugin({ ... }) ] を追加します。

src/main/assets/js の下の全ての js ファイルから require("admin-lte/plugins/jQuery/jquery-2.2.3.min.js"); の行を削除します。

npm run springboot コマンドを停止して起動し直すとエラーが出ずに起動し、入力チェックの処理や autokana も問題なく動作しました。

「お名前(漢字)」「お名前(かな)」をどちらも入力チェックエラーにしてから、「お名前(漢字)」を入力して「お名前(かな)」に autokana で自動入力されても「お名前(かな)」の入力チェックエラー状態が解除されないのを修正する

autokana で「お名前(かな)」に自動入力されるようにしたのですが、今の実装だと「お名前(漢字)」「お名前(かな)」をどちらも入力チェックエラーにしてから、

f:id:ksby:20170902080436p:plain

「お名前(漢字)」にカーソルを移動して漢字を入力し「お名前(かな)」にかな文字列が反映されても、「お名前(漢字)」の「名」から「お名前(かな)」の「姓」にカーソルが移動したタイミングでは「お名前(かな)」の入力チェックエラー状態が解除されません。

f:id:ksby:20170902081421p:plain

解除されるようにしてみます。

src/main/assets/js/lib/class/Form.js の以下の点を変更します。

..........

/**
 * 渡された id を form.focused にセットして
 * focused イベントが発生したことにする
 * @param {Form} form - Form オブジェクト
 * @param {string} id - focused イベントを発生したことにする要素の id
 */
Form.prototype.setFocused = function (form, id) {
    form.focused[id] = true;
};

/**
 * 渡された idList にセットされている id を form.focused にセットして
 * focused イベントが発生したことにする
 * @param {Form} form - Form オブジェクト
 * @param {Array} idList - focused イベントが発生したことにする要素の id の配列
 */
Form.prototype.setFocusedFromList = function (form, idList) {
    idList.forEach(function (id) {
        form.setFocused(form, id);
    })
};

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

/**
 * 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.setFocused(form, id);
        })
    })
}
  • Form.prototype.setFocused メソッドを追加します。
  • Form.prototype.setFocusedFromList メソッドを追加します。
  • Form.prototype.forceAllFocused メソッド内の処理を form.idList.forEach(function (id) { ... }form.setFocusedFromList(form, form.idList); に変更します。
  • addFocusEventListener 関数内の処理を form.focused[id] = true;form.setFocused(form, id); に変更します。

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

var kanaAutoInputValidator = function (event) {
    var idList = ["#lastkana", "#firstkana"];
    // 「お名前(漢字)」を入力して「お名前(かな)」が自動入力された時に、
    // 「お名前(かな)」の入力チェックが実行されるようにする
    if (!form.isAnyEmpty(idList)) {
        form.setFocusedFromList(form, idList);
        kanaValidator(event);
    }
};

..........

$(document).ready(function () {
    // 「お名前(漢字)」が入力された時に、かな文字列を「お名前(かな)」に自動入力されるようにする
    $.fn.autoKana('#lastname', '#lastkana');
    $.fn.autoKana('#firstname', '#firstkana');

    // 入力チェック用の validator 関数をセットする
    $("#lastname")
        .on("blur", nameValidator)
        .on("blur", kanaAutoInputValidator);
    $("#firstname")
        .on("blur", nameValidator)
        .on("blur", kanaAutoInputValidator);
    $("#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)
});
  • kanaAutoInputValidator メソッドを追加します。
  • $(document).ready(function () { ... } 内の以下の点を変更します。
    • $("#lastname")$("#firstname").on("blur", kanaAutoInputValidator) を追加します。

動作確認します。

http://localhost:9080/inquiry/input/01/ にアクセスして「お名前(漢字)」「お名前(かな)」を入力チェックエラー状態にした後、

f:id:ksby:20170902094531p:plain

マウスで「お名前(漢字)」の「姓」にカーソルを移動してから「姓」「名」を入力すると「お名前(かな)」にかな文字列が自動入力されて、かつ「お名前(漢字)」の「名」から「お名前(かな)」の「姓」にカーソルが移動した時に「お名前(かな)」も入力チェック OK の状態になります。

f:id:ksby:20170902094840p:plain

Ctrl+F5 を押してリロードした後、普通に「お名前(漢字)」を入力しても「お名前(かな)」が入力チェック OK の状態になります。

f:id:ksby:20170902095024p:plain

Ctrl+F5 を押してリロードした後、「お名前(漢字)」にかな文字列にできない英字を入力した場合には「お名前(かな)」の入力チェックは動作せず何も変わりません。

f:id:ksby:20170902095215p:plain

Ctrl+F5 を押してリロードした後、「お名前(漢字)」の「姓」だけ入力した場合には「お名前(漢字)」は入力チェックエラー状態になりますが「お名前(かな)」の入力チェックは動作せず何も変わりません。

f:id:ksby:20170902095443p:plain

問題なさそうです。次回は Form クラスを作成してセッションに保存する処理を実装します。

メモ書き

input01.js で関数を var xxx = function() { ... } で記述しているのは IntelliJ IDEA が見やすく表示してくれるからである

最初は function xxx() { ... } の形式で書いていたのですが、var xxx = function() { ... } の形式で書くと IntelliJ IDEA が関数名毎に色を変えて表示してくれるので、input01.js では var xxx = function() { ... } の形式で書くことにしました。

function xxx() { ... } の形式で書くと IntelliJ IDEA 上では以下のように関数名は全て同じ色で表示されますが、

f:id:ksby:20170902102427p:plain f:id:ksby:20170902102553p:plain

var xxx = function() { ... } の形式で書くと IntelliJ IDEA の Semantic highlighting の機能で関数名毎に色を変えて表示してくれます(変数名とみなされているため)。

f:id:ksby:20170902102902p:plain f:id:ksby:20170902103005p:plain

ただし var xxx = function() { ... } は上に書いておかないと下の関数では使用できないようなので、その点だけ注意が必要です。

履歴

2017/09/02
初版発行。

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()) { ... } の中へ移動した。