フォームコンポーネント

Wicket のフォームコンポーネントは、HTML フォームをタイプセーフな Java オブジェクトとして扱うための強力な仕組みです。入力値の型変換、バリデーション、モデルへの反映がフレームワークによって自動的に管理されるため、開発者はビジネスロジックに集中できます。

Wicket 10 のポイント: Wicket 10 では Jakarta EE 10 に対応し、jakarta.servlet 名前空間が使用されます。また、Java 17 以上が必須となり、レコードクラスやシールクラスとの親和性も高まっています。

Form の基本

Wicket の Form コンポーネントは、HTML の <form> タグに対応し、フォーム内のすべての入力コンポーネントを統括する親コンポーネントです。フォームが送信されると、Wicket は以下の処理を自動的に行います。

重要: onSubmit() はすべてのバリデーションが成功した場合にのみ呼び出されます。バリデーションエラーが1つでもあると onError() が呼び出され、FeedbackPanel を通じてエラーメッセージが表示されます。

フォームの構造

最も基本的なフォームは、Form クラスの匿名サブクラスを作成し、onSubmit() メソッドをオーバーライドします。フォーム内に配置された入力コンポーネントは、フォームの子コンポーネントとして add() で追加します。

CompoundPropertyModel の活用

CompoundPropertyModel を使用すると、フォームコンポーネントの wicket:id がそのままモデルオブジェクトのプロパティ名として解決されます。これにより、各コンポーネントに個別のモデルを設定する必要がなくなり、コードが大幅に簡潔になります。

以下は、ログインフォームの完全な実装例です。CompoundPropertyModel により、wicket:id="username" が自動的に LoginPage.username フィールドに紐付けられます。

LoginPage.java
public class LoginPage extends WebPage {
    private String username;
    private String password;

    public LoginPage() {
        Form<Void> form = new Form<>("loginForm") {
            @Override
            protected void onSubmit() {
                // バリデーション成功時のログイン処理
                AuthService auth = getApplication()
                    .getMetaData(AuthService.KEY);
                if (auth.authenticate(username, password)) {
                    setResponsePage(HomePage.class);
                } else {
                    error("ユーザー名またはパスワードが正しくありません");
                }
            }

            @Override
            protected void onError() {
                // バリデーション失敗時の処理
                // FeedbackPanel が自動的にエラーを表示する
            }
        };
        // CompoundPropertyModel でフィールドと自動バインド
        form.setDefaultModel(new CompoundPropertyModel<>(this));
        add(form);

        // wicket:id がプロパティ名に対応
        form.add(new TextField<>("username").setRequired(true));
        form.add(new PasswordTextField("password"));
        form.add(new Button("submit"));

        // エラーメッセージ表示用
        form.add(new FeedbackPanel("feedback"));
    }
}
LoginPage.html
<!DOCTYPE html>
<html xmlns:wicket="http://wicket.apache.org">
<head>
    <title>ログイン</title>
</head>
<body>
    <h1>ログイン</h1>

    <div wicket:id="feedback"></div>

    <form wicket:id="loginForm">
        <div>
            <label>ユーザー名:
                <input wicket:id="username" type="text"/>
            </label>
        </div>
        <div>
            <label>パスワード:
                <input wicket:id="password" type="password"/>
            </label>
        </div>
        <button wicket:id="submit" type="submit">
            ログイン
        </button>
    </form>
</body>
</html>

注意: CompoundPropertyModel を使う場合、wicket:id はモデルオブジェクトのフィールド名 (正確には getter/setter のプロパティ名) と一致している必要があります。例えば wicket:id="username"getUsername() / setUsername()、またはフィールド username に対応します。

TextField

TextField は、HTML の <input type="text"> に対応する最も基本的な入力コンポーネントです。型パラメータを指定することで、入力された文字列を指定した Java 型に自動変換できるのが大きな特徴です。

型変換の仕組み

Wicket の TextField はジェネリクスの型パラメータを活用し、ユーザーが入力した文字列を自動的に指定された型に変換します。変換に失敗した場合は、バリデーションエラーとして処理されます。

TextFieldExamplePage.java
// 文字列型 - 変換不要
TextField<String> nameField =
    new TextField<>("name", Model.of(""));

// 整数型 - 文字列から Integer への自動変換
TextField<Integer> ageField =
    new TextField<>("age", Model.of(0), Integer.class);

// Double型 - 小数点を含む数値の自動変換
TextField<Double> priceField =
    new TextField<>("price", Model.of(0.0), Double.class);

// LocalDate型 - 日付文字列から LocalDate への変換
TextField<LocalDate> dateField =
    new TextField<>("birthDate", Model.of(LocalDate.now()),
        LocalDate.class);

form.add(nameField);
form.add(ageField.setRequired(true));
form.add(priceField);
form.add(dateField);
TextFieldExamplePage.html
<form wicket:id="form">
    <div>
        <label>名前:
            <input wicket:id="name" type="text"/>
        </label>
    </div>
    <div>
        <label>年齢:
            <input wicket:id="age" type="number"/>
        </label>
    </div>
    <div>
        <label>価格:
            <input wicket:id="price" type="text"/>
        </label>
    </div>
    <div>
        <label>生年月日:
            <input wicket:id="birthDate" type="date"/>
        </label>
    </div>
    <button type="submit">送信</button>
</form>

ヒント: TextField<Integer> にユーザーが "abc" と入力した場合、型変換エラーが自動的に発生し、FeedbackPanel に「'abc' は整数ではありません」というエラーメッセージが表示されます。この変換エラーメッセージはプロパティファイルでカスタマイズ可能です。

EmailTextField の利用例
// Wicket 10 には EmailTextField も用意されている
import org.apache.wicket.markup.html.form.EmailTextField;

EmailTextField emailField = new EmailTextField("email", Model.of(""));
emailField.setRequired(true);
form.add(emailField);
// HTML5 の type="email" が自動的に設定される

PasswordTextField

PasswordTextField は、パスワード入力に特化したテキストフィールドです。HTML では <input type="password"> としてレンダリングされ、入力値がマスクされます。

デフォルトの動作として、以下の特徴があります。

PasswordExample.java
// 基本的な使い方
PasswordTextField password =
    new PasswordTextField("password", Model.of(""));
form.add(password);

// エラー後もパスワードを保持したい場合
PasswordTextField passwordKeep =
    new PasswordTextField("password", Model.of(""));
passwordKeep.setResetPassword(false);
form.add(passwordKeep);

// パスワード確認フィールドとの一致チェック
PasswordTextField passwordConfirm =
    new PasswordTextField("passwordConfirm", Model.of(""));
form.add(passwordConfirm);

// EqualPasswordInputValidator で一致を検証
form.add(new EqualPasswordInputValidator(
    password, passwordConfirm
));
PasswordExample.html
<form wicket:id="form">
    <div>
        <label>パスワード:
            <input wicket:id="password"
                   type="password"/>
        </label>
    </div>
    <div>
        <label>パスワード (確認):
            <input wicket:id="passwordConfirm"
                   type="password"/>
        </label>
    </div>
    <button type="submit">登録</button>
</form>

注意: setResetPassword(false) を使用すると、フォーム再表示時にパスワード値が HTML のレスポンスに含まれます。セキュリティ要件に応じて慎重に判断してください。一般的には、デフォルトの setResetPassword(true) のまま使用することが推奨されます。

TextArea

TextArea は、HTML の <textarea> タグに対応する複数行テキスト入力コンポーネントです。ブログ記事の本文、ユーザーのプロフィール、コメントなど、長いテキストの入力に使用します。

TextAreaExample.java
// 基本的な TextArea
TextArea<String> bioField =
    new TextArea<>("bio", Model.of(""));
form.add(bioField);

// 文字数制限付き TextArea
TextArea<String> commentField =
    new TextArea<>("comment", Model.of(""));
commentField.setRequired(true);
commentField.add(
    StringValidator.maximumLength(500)
);
form.add(commentField);

// CompoundPropertyModel を使う場合は
// モデルの指定が不要
// form.setDefaultModel(
//     new CompoundPropertyModel<>(bean));
// form.add(new TextArea<>("bio"));
TextAreaExample.html
<form wicket:id="form">
    <div>
        <label>自己紹介:</label>
        <textarea wicket:id="bio"
                  rows="5"
                  cols="40"></textarea>
    </div>
    <div>
        <label>コメント:</label>
        <textarea wicket:id="comment"
                  rows="3"
                  cols="40"></textarea>
    </div>
    <button type="submit">送信</button>
</form>

注意: HTML テンプレートの <textarea> タグ内にプレースホルダーテキストを記述しても、Wicket が実行時にモデルの値で上書きします。プレースホルダーを表示したい場合は、HTML5 の placeholder 属性を使用してください。例: <textarea wicket:id="bio" placeholder="自己紹介を入力..."></textarea>

CheckBox

CheckBox は、HTML の <input type="checkbox"> に対応するコンポーネントです。Boolean 型のモデルと紐付けられ、チェック状態 (true/false) を管理します。

CheckBoxExample.java
// 基本的な CheckBox
CheckBox agreeCheck =
    new CheckBox("agree", Model.of(false));
form.add(agreeCheck);

// 必須チェック (チェック必須)
CheckBox termsCheck =
    new CheckBox("terms", Model.of(false));
termsCheck.setRequired(true);
form.add(termsCheck);

// CheckBoxMultipleChoice - 複数選択
List<String> hobbies = Arrays.asList(
    "読書", "映画", "スポーツ", "旅行", "料理"
);
CheckBoxMultipleChoice<String> hobbyChoice =
    new CheckBoxMultipleChoice<>(
        "hobbies",
        new ArrayList<>(),  // 選択済みリスト
        hobbies              // 選択肢リスト
    );
form.add(hobbyChoice);
CheckBoxExample.html
<form wicket:id="form">
    <div>
        <label>
            <input wicket:id="agree"
                   type="checkbox"/>
            同意する
        </label>
    </div>
    <div>
        <label>
            <input wicket:id="terms"
                   type="checkbox"/>
            利用規約に同意する (必須)
        </label>
    </div>
    <div>
        <label>趣味:</label>
        <span wicket:id="hobbies">
            <!-- チェックボックスが自動生成 -->
        </span>
    </div>
    <button type="submit">送信</button>
</form>

ヒント: CheckBoxMultipleChoice は、複数の項目から複数選択が可能なチェックボックスグループを自動生成します。選択された値は List としてモデルに保持されます。HTML テンプレートでは <span> タグ1つだけで済むため、テンプレートがシンプルに保たれます。

RadioChoice と RadioGroup

ラジオボタンによる単一選択を実装するには、RadioChoiceRadioGroup + Radio の2つのアプローチがあります。

RadioChoice

RadioChoice は、選択肢のリストからラジオボタンを自動生成するシンプルなコンポーネントです。少ない選択肢でカスタムレイアウトが不要な場合に適しています。

RadioChoiceExample.java
// シンプルな RadioChoice
List<String> colors = Arrays.asList(
    "赤", "緑", "青"
);
RadioChoice<String> colorChoice =
    new RadioChoice<>(
        "color",
        Model.of("赤"),  // デフォルト値
        colors
    );
form.add(colorChoice);

// セパレータのカスタマイズ
// デフォルトは <br/> で区切られる
colorChoice.setSuffix("&nbsp;&nbsp;");
// 横並びに変更
RadioChoiceExample.html
<form wicket:id="form">
    <div>
        <label>色を選択:</label>
        <span wicket:id="color">
            <!-- ラジオボタンが自動生成 -->
        </span>
    </div>
    <button type="submit">送信</button>
</form>

RadioGroup + Radio

レイアウトを自由にカスタマイズしたい場合は、RadioGroupRadio の組み合わせを使用します。各 Radio コンポーネントを HTML テンプレート上で自由に配置できます。

RadioGroupExample.java
// RadioGroup + Radio でカスタムレイアウト
RadioGroup<String> sizeGroup =
    new RadioGroup<>("size", Model.of("M"));
form.add(sizeGroup);

sizeGroup.add(new Radio<>("small",
    Model.of("S")));
sizeGroup.add(new Radio<>("medium",
    Model.of("M")));
sizeGroup.add(new Radio<>("large",
    Model.of("L")));
sizeGroup.add(new Radio<>("xlarge",
    Model.of("XL")));
RadioGroupExample.html
<form wicket:id="form">
    <div wicket:id="size">
        <h4>サイズを選択</h4>
        <div class="size-options">
            <label>
                <input wicket:id="small"
                       type="radio"/>
                S - 小さめ
            </label>
            <label>
                <input wicket:id="medium"
                       type="radio"/>
                M - 普通
            </label>
            <label>
                <input wicket:id="large"
                       type="radio"/>
                L - 大きめ
            </label>
            <label>
                <input wicket:id="xlarge"
                       type="radio"/>
                XL - 特大
            </label>
        </div>
    </div>
    <button type="submit">送信</button>
</form>

使い分けの指針: 選択肢が固定的で数が少なく、シンプルな表示で十分な場合は RadioChoice を使いましょう。選択肢ごとにアイコンや説明文を付けたい、あるいはグリッドレイアウトにしたいなど、表示をカスタマイズしたい場合は RadioGroup + Radio を使いましょう。

DropDownChoice は、HTML の <select> タグに対応するドロップダウン選択コンポーネントです。多数の選択肢から1つを選ぶ場面で使用します。

DropDownExample.java
// 基本的な DropDownChoice
List<String> cities = Arrays.asList(
    "東京", "大阪", "名古屋", "福岡"
);
DropDownChoice<String> dropdown =
    new DropDownChoice<>(
        "city",
        Model.of(""),
        cities
    );
// 未選択を許可 (「選択してください」表示)
dropdown.setNullValid(true);
form.add(dropdown);

// 未選択時のデフォルトラベルを変更
dropdown.setNullValid(true);
// properties ファイルで設定:
// nullValid=-- 都市を選択 --
DropDownExample.html
<form wicket:id="form">
    <div>
        <label>都市:
            <select wicket:id="city">
                <!-- optionは自動生成される -->
                <option>東京</option>
                <option>大阪</option>
            </select>
        </label>
    </div>
    <button type="submit">送信</button>
</form>

IChoiceRenderer

文字列リスト以外のオブジェクトを選択肢にする場合は、IChoiceRenderer を使用して表示テキストと内部値の対応を定義します。ChoiceRenderer はそのシンプルな実装クラスです。

IChoiceRenderer を使ったオブジェクト選択
// Prefecture クラス
public class Prefecture implements Serializable {
    private Long id;
    private String name;
    private String region;
    // getter/setter 省略
}

// DropDownChoice でオブジェクトを扱う
List<Prefecture> prefectures = prefectureService.findAll();

DropDownChoice<Prefecture> prefDropdown =
    new DropDownChoice<>(
        "prefecture",
        Model.of(new Prefecture()),
        prefectures,
        new ChoiceRenderer<>("name", "id")
        //  表示プロパティ ↑      ↑ ID プロパティ
    );
prefDropdown.setNullValid(true);
form.add(prefDropdown);

// カスタム IChoiceRenderer の実装例
DropDownChoice<Prefecture> customDropdown =
    new DropDownChoice<>(
        "prefectureCustom",
        Model.of(new Prefecture()),
        prefectures,
        new IChoiceRenderer<Prefecture>() {
            @Override
            public Object getDisplayValue(Prefecture pref) {
                return pref.getName() + " (" + pref.getRegion() + ")";
            }

            @Override
            public String getIdValue(Prefecture pref, int index) {
                return String.valueOf(pref.getId());
            }

            @Override
            public Prefecture getObject(String id,
                    IModel<? extends List<? extends Prefecture>> choices) {
                return choices.getObject().stream()
                    .filter(p -> String.valueOf(p.getId()).equals(id))
                    .findFirst().orElse(null);
            }
        }
    );
form.add(customDropdown);

ヒント: ChoiceRenderer<>("name", "id") は、表示用にオブジェクトの getName() を、内部値として getId() を使用します。単純なケースではこれで十分ですが、表示をより複雑にカスタマイズしたい場合は IChoiceRenderer を直接実装します。

ListMultipleChoice

ListMultipleChoice は、HTML の <select multiple> に対応するコンポーネントで、複数の選択肢から複数の項目を同時に選択できます。選択結果は List としてモデルに保持されます。

ListMultipleChoiceExample.java
// 選択肢のリスト
List<String> skills = Arrays.asList(
    "Java", "Kotlin", "Spring",
    "Wicket", "JPA", "Docker",
    "Kubernetes", "AWS"
);

// 選択済みの値を保持するモデル
IModel<List<String>> selectedModel =
    Model.ofList(new ArrayList<>());

ListMultipleChoice<String> skillChoice =
    new ListMultipleChoice<>(
        "skills",
        selectedModel,
        skills
    );
// 表示行数を設定
skillChoice.setMaxRows(5);
form.add(skillChoice);

// onSubmit 内での値取得
@Override
protected void onSubmit() {
    List<String> selected =
        skillChoice.getModelObject();
    // selected には選択されたスキルが
    // リストとして格納される
}
ListMultipleChoiceExample.html
<form wicket:id="form">
    <div>
        <label>スキル (複数選択可):</label>
        <select wicket:id="skills"
                multiple="multiple">
            <!-- option は自動生成 -->
            <option>Java</option>
            <option>Kotlin</option>
        </select>
        <p class="hint">
            Ctrl+クリックで複数選択
        </p>
    </div>
    <button type="submit">送信</button>
</form>

注意: ListMultipleChoice<select multiple> として表示されるため、操作性がやや限定的です。より直感的な UI が必要な場合は、CheckBoxMultipleChoice の使用を検討してください。チェックボックス形式のほうがユーザーにとって分かりやすい場合が多いです。

FileUploadField

FileUploadField は、ファイルアップロード機能を提供するコンポーネントです。ファイルアップロードを有効にするには、フォームに setMultiPart(true) を設定する必要があります。

重要: ファイルアップロードを使用するフォームでは、必ず form.setMultiPart(true) を呼び出してください。これにより、HTML の <form> タグに enctype="multipart/form-data" が自動的に付与されます。この設定を忘れると、ファイルデータが送信されません。

FileUploadPage.java
public class FileUploadPage extends WebPage {
    private final FileUploadField fileUpload;

    public FileUploadPage() {
        Form<Void> form = new Form<>("uploadForm") {
            @Override
            protected void onSubmit() {
                FileUpload upload =
                    fileUpload.getFileUpload();
                if (upload != null) {
                    // ファイル名を取得
                    String fileName =
                        upload.getClientFileName();
                    // ファイルサイズを取得
                    long size = upload.getSize();
                    // Content-Type を取得
                    String contentType =
                        upload.getContentType();

                    // ファイルを保存
                    try {
                        File dest = new File(
                            getUploadDir(), fileName);
                        upload.writeTo(dest);
                        info("アップロード完了: "
                            + fileName);
                    } catch (IOException e) {
                        error("アップロード失敗: "
                            + e.getMessage());
                    }
                }
            }
        };
        // multipart 設定 (必須)
        form.setMultiPart(true);
        // 最大アップロードサイズ設定
        form.setMaxSize(Bytes.megabytes(10));
        add(form);

        fileUpload = new FileUploadField("file");
        form.add(fileUpload);
        form.add(new FeedbackPanel("feedback"));
    }

    private File getUploadDir() {
        File dir = new File(
            getApplication().getServletContext()
                .getRealPath("/uploads"));
        dir.mkdirs();
        return dir;
    }
}
FileUploadPage.html
<!DOCTYPE html>
<html xmlns:wicket="http://wicket.apache.org">
<head>
    <title>ファイルアップロード</title>
</head>
<body>
    <h1>ファイルアップロード</h1>

    <div wicket:id="feedback"></div>

    <form wicket:id="uploadForm">
        <div>
            <label>ファイル:
                <input wicket:id="file"
                       type="file"/>
            </label>
        </div>
        <p class="hint">
            最大ファイルサイズ: 10MB
        </p>
        <button type="submit">
            アップロード
        </button>
    </form>
</body>
</html>
複数ファイルアップロード
// 複数ファイルのアップロード
FileUploadField multiUpload =
    new FileUploadField("files");
form.add(multiUpload);

// onSubmit 内で複数ファイルを処理
@Override
protected void onSubmit() {
    List<FileUpload> uploads = multiUpload.getFileUploads();
    for (FileUpload upload : uploads) {
        String fileName = upload.getClientFileName();
        try {
            File dest = new File(getUploadDir(), fileName);
            upload.writeTo(dest);
            info("アップロード完了: " + fileName);
        } catch (IOException e) {
            error("失敗: " + fileName + " - " + e.getMessage());
        }
    }
}

// HTML 側は multiple 属性を追加
// <input wicket:id="files" type="file" multiple="multiple"/>

Button

Button コンポーネントは、フォーム送信ボタンを表します。フォーム内に1つのボタンだけがある場合は、FormonSubmit() をオーバーライドするだけで十分です。しかし、1つのフォームに複数のボタンが必要な場合は、各 ButtononSubmit() をオーバーライドして異なる処理を実装できます。

複数ボタン

MultiButtonPage.java
public class MultiButtonPage extends WebPage {
    public MultiButtonPage() {
        Form<Void> form = new Form<>("form") {
            @Override
            protected void onSubmit() {
                // デフォルトの送信処理
                // (Button の onSubmit が優先される)
            }
        };
        add(form);

        form.add(new TextField<>("name",
            Model.of("")));

        // 保存ボタン
        form.add(new Button("save") {
            @Override
            public void onSubmit() {
                // 保存処理を実行
                String name = (String) form
                    .get("name")
                    .getDefaultModelObject();
                saveToDatabase(name);
                info("保存しました");
            }
        });

        // 下書き保存ボタン
        // (バリデーションをスキップ)
        Button draftBtn = new Button("draft") {
            @Override
            public void onSubmit() {
                // 下書き保存
                saveDraft();
                info("下書きを保存しました");
            }
        };
        draftBtn.setDefaultFormProcessing(false);
        form.add(draftBtn);

        // キャンセルボタン
        Button cancelBtn = new Button("cancel") {
            @Override
            public void onSubmit() {
                setResponsePage(HomePage.class);
            }
        };
        cancelBtn.setDefaultFormProcessing(false);
        form.add(cancelBtn);
    }
}
MultiButtonPage.html
<form wicket:id="form">
    <div>
        <label>名前:
            <input wicket:id="name"
                   type="text"/>
        </label>
    </div>

    <div class="button-group">
        <button wicket:id="save"
                type="submit"
                class="btn-primary">
            保存
        </button>

        <button wicket:id="draft"
                type="submit"
                class="btn-secondary">
            下書き保存
        </button>

        <button wicket:id="cancel"
                type="submit"
                class="btn-cancel">
            キャンセル
        </button>
    </div>
</form>

重要: setDefaultFormProcessing(false) を設定したボタンは、クリックされてもフォームのバリデーションと モデル更新をスキップします。キャンセルボタンや下書き保存など、バリデーションなしで処理したいボタンに設定します。このボタンがクリックされた場合、ButtononSubmit() は呼ばれますが、FormonSubmit() は呼ばれません。

バリデーション

Wicket のバリデーションシステムは、入力値の検証を宣言的に行う仕組みを提供します。各フォームコンポーネントに対してバリデータを追加 (add) するだけで、フォーム送信時に自動的に検証が実行されます。

組み込みバリデータ

Wicket は一般的な検証パターンに対応する組み込みバリデータを多数提供しています。

組み込みバリデータの一覧と使用例
// 必須チェック - 値の入力を必須にする
textField.setRequired(true);

// 文字列長のバリデーション
textField.add(StringValidator.minimumLength(3));      // 最低3文字
textField.add(StringValidator.maximumLength(100));     // 最大100文字
textField.add(StringValidator.lengthBetween(3, 100));  // 3〜100文字
textField.add(StringValidator.exactLength(8));         // 丁度8文字

// メールアドレス形式のバリデーション
emailField.add(EmailAddressValidator.getInstance());

// 数値範囲のバリデーション
ageField.add(RangeValidator.minimum(0));       // 0以上
ageField.add(RangeValidator.maximum(150));      // 150以下
ageField.add(RangeValidator.range(0, 150));     // 0〜150

// 正規表現パターンのバリデーション
phoneField.add(new PatternValidator("\\d{2,4}-\\d{2,4}-\\d{4}"));
// 電話番号形式: 03-1234-5678

// URL形式のバリデーション
urlField.add(new UrlValidator());

カスタムバリデータ

組み込みバリデータでは対応できない独自の検証ロジックは、IValidator インターフェースを実装して作成します。

カスタムバリデータの実装
// インライン実装 (ラムダやメソッド参照も可能)
textField.add(new IValidator<String>() {
    @Override
    public void validate(IValidatable<String> validatable) {
        String value = validatable.getValue();
        if (!value.startsWith("W")) {
            validatable.error(
                new ValidationError("Wで始まる必要があります"));
        }
    }
});

// 再利用可能なバリデータクラス
public class JapanesePhoneValidator implements IValidator<String> {
    private static final Pattern PHONE_PATTERN =
        Pattern.compile("^0\\d{1,4}-\\d{1,4}-\\d{3,4}$");

    @Override
    public void validate(IValidatable<String> validatable) {
        String phone = validatable.getValue();
        if (!PHONE_PATTERN.matcher(phone).matches()) {
            ValidationError error = new ValidationError();
            error.addKey("JapanesePhoneValidator");
            // プロパティファイルでメッセージを定義
            validatable.error(error);
        }
    }
}

// 使用例
phoneField.add(new JapanesePhoneValidator());

// フォームレベルのバリデーション (複数フィールドにまたがる検証)
// IFormValidator の使用
form.add(new AbstractFormValidator() {
    @Override
    public FormComponent<?>[] getDependentFormComponents() {
        return new FormComponent[]{ startDate, endDate };
    }

    @Override
    public void validate(Form<?> form) {
        LocalDate start = startDate.getConvertedInput();
        LocalDate end = endDate.getConvertedInput();
        if (start != null && end != null && start.isAfter(end)) {
            error(endDate, "dateRange");
            // properties: dateRange=終了日は開始日より後の日付を指定してください
        }
    }
});

FeedbackPanel

FeedbackPanel は、バリデーションエラーメッセージやフィードバックメッセージ (info, warning, error) を表示するためのコンポーネントです。

FeedbackPanel の使用例
// ページ全体のフィードバック
add(new FeedbackPanel("feedback"));

// 特定コンポーネントのフィードバックのみ表示
add(new FeedbackPanel("nameFeedback",
    new ComponentFeedbackMessageFilter(nameField)));

// フォーム内のフィードバックのみ表示
form.add(new FeedbackPanel("formFeedback",
    new ContainerFeedbackMessageFilter(form)));

// フィードバックメッセージの種類
info("処理が完了しました");      // 情報メッセージ
success("保存に成功しました");   // 成功メッセージ
warn("入力内容を確認してください"); // 警告メッセージ
error("エラーが発生しました");    // エラーメッセージ
FeedbackPanel の HTML
<!-- ページ全体のフィードバック -->
<div wicket:id="feedback"></div>

<form wicket:id="form">
    <!-- フォーム内フィードバック -->
    <div wicket:id="formFeedback"></div>

    <div>
        <label>名前:
            <input wicket:id="name"
                   type="text"/>
        </label>
        <!-- フィールド単位のフィードバック -->
        <span wicket:id="nameFeedback"
              class="field-error">
        </span>
    </div>

    <button type="submit">送信</button>
</form>

ヒント: ComponentFeedbackMessageFilter を使うと、特定のフィールドに関連するエラーメッセージだけをそのフィールドの横に表示できます。これにより、ユーザーはどのフィールドにエラーがあるかを直感的に把握できます。

Bean Validation

Wicket 10 では、wicket-bean-validation モジュールを使用することで、Jakarta Validation (旧 Bean Validation) のアノテーションをフォームコンポーネントのバリデーションに直接利用できます。

Bean Validation の設定と使用
// 1. pom.xml に依存関係を追加
// <dependency>
//     <groupId>org.apache.wicket</groupId>
//     <artifactId>wicket-bean-validation</artifactId>
//     <version>10.x.x</version>
// </dependency>

// 2. Application クラスで初期化
public class MyApplication extends WebApplication {
    @Override
    protected void init() {
        super.init();
        new BeanValidationConfiguration().configure(this);
    }
}

// 3. エンティティクラスに Jakarta Validation アノテーション
public class UserForm implements Serializable {
    @NotNull
    @Size(min = 3, max = 50)
    private String username;

    @NotNull
    @Email
    private String email;

    @Min(0)
    @Max(150)
    private Integer age;

    @NotBlank
    @Size(max = 500)
    private String bio;
    // getter/setter 省略
}

// 4. フォームコンポーネントに @PropertyValidator を追加
Form<UserForm> form = new Form<>("form",
    new CompoundPropertyModel<>(new UserForm()));
add(form);

// Bean Validation アノテーションを自動適用
form.add(new TextField<>("username")
    .add(new PropertyValidator<>()));
form.add(new EmailTextField("email")
    .add(new PropertyValidator<>()));
form.add(new TextField<>("age", Integer.class)
    .add(new PropertyValidator<>()));
form.add(new TextArea<>("bio")
    .add(new PropertyValidator<>()));
// @NotNull は自動的に setRequired(true) と同等に動作
// @Size は StringValidator と同等に動作

注意: Bean Validation を使用する場合、バリデーションメッセージは Jakarta Validation の仕組みで解決されます。日本語メッセージを設定するには、ValidationMessages_ja.properties ファイルをクラスパスのルートに配置してください。

フォーム処理の流れ

Wicket のフォーム処理は、明確に定義されたライフサイクルに従って実行されます。この流れを理解することは、複雑なフォームを設計する上で非常に重要です。

処理ステップ

フォームが送信されると、Wicket は以下のステップを順番に実行します。

フォーム処理ライフサイクル
// ステップ 1: フォーム送信を受信
//   ブラウザからの POST リクエストを受け取り、
//   どのフォームが送信されたかを判定する

// ステップ 2: 入力値の取得 (rawInput)
//   各 FormComponent の inputChanged() が呼ばれ、
//   リクエストパラメータから生の入力値を取得する
//   → component.getRawInput() でアクセス可能

// ステップ 3: 型変換 (Conversion)
//   各 FormComponent の convertInput() が呼ばれ、
//   文字列の入力値を型パラメータに基づいて変換する
//   例: "25" → Integer(25)
//   変換失敗時は ConversionException が発生し、
//   バリデーションエラーとして記録される
//   → component.getConvertedInput() でアクセス可能

// ステップ 4: バリデーション (Validation)
//   各 FormComponent の validate() が呼ばれる:
//     a. required チェック (値が空でないか)
//     b. 型変換の成否チェック
//     c. 追加された IValidator の実行
//   次に Form レベルの IFormValidator が実行される

// ステップ 5: 分岐
//   ┌─ バリデーション成功の場合 ─────────────────┐
//   │ ステップ 5a: モデル更新                     │
//   │   各 FormComponent の updateModel() が     │
//   │   呼ばれ、変換済みの値をモデルに書き込む      │
//   │                                            │
//   │ ステップ 5b: onSubmit() 呼び出し            │
//   │   Button の onSubmit() が呼ばれ、           │
//   │   続いて Form の onSubmit() が呼ばれる       │
//   └────────────────────────────────────────────┘
//
//   ┌─ バリデーション失敗の場合 ─────────────────┐
//   │ ステップ 5c: onError() 呼び出し             │
//   │   Button の onError() が呼ばれ、            │
//   │   続いて Form の onError() が呼ばれる        │
//   │   FeedbackPanel がエラーメッセージを表示      │
//   │   モデルは更新されない (安全)                │
//   └────────────────────────────────────────────┘

ライフサイクル図

フォーム処理フロー図
  [ブラウザ] ──── POST送信 ────→ [Wicket サーバー]
                                       │
                                  (1) フォーム送信受信
                                       │
                                  (2) 入力値取得
                                       │ rawInput
                                       ▼
                                  (3) 型変換
                                       │ convertedInput
                                       ▼
                                  (4) バリデーション
                                       │
                              ┌────────┴────────┐
                              │                  │
                          成功 ▼              失敗 ▼
                     (5a) モデル更新      (5c) onError()
                              │                  │
                     (5b) onSubmit()      FeedbackPanel に
                              │           エラー表示
                              ▼                  │
                      ビジネスロジック              ▼
                         実行             フォーム再表示
                              │          (入力値は保持)
                              ▼
                      ページ遷移 or
                      フォーム再表示

注意: バリデーションが失敗した場合、モデルは一切更新されません。これは Wicket の重要な安全機構です。不正な値がモデル (= ビジネスオブジェクト) に混入することを防ぎます。バリデーション失敗時の入力値は getRawInput() で取得でき、フォーム再表示時にユーザーの入力内容が保持されます。

ライフサイクルをフックする実装例
Form<UserForm> form = new Form<>("form",
        new CompoundPropertyModel<>(new UserForm())) {

    @Override
    protected void onValidate() {
        super.onValidate();
        // すべてのバリデーション実行後に呼ばれる
        // フォーム全体にまたがるカスタム検証が可能
    }

    @Override
    protected void onSubmit() {
        UserForm user = getModelObject();
        // バリデーション成功後の処理
        userService.save(user);
        setResponsePage(UserListPage.class);
    }

    @Override
    protected void onError() {
        // バリデーションエラー時の処理
        // FeedbackPanel は自動的にメッセージを表示するため、
        // 通常は追加の処理は不要
        warn("入力内容にエラーがあります。修正してください。");
    }

    @Override
    protected void beforeUpdateFormComponentModels() {
        super.beforeUpdateFormComponentModels();
        // モデル更新直前のフック
        // ここで追加のロジックを実行可能
    }
};

まとめ: Wicket のフォームコンポーネントは、型安全性、自動バリデーション、モデルバインディングといった強力な機能を提供します。Spring MVC の @ModelAttribute@Valid に相当する機能が、コンポーネントベースのアーキテクチャとして統合されています。HTML テンプレートとの明確な分離により、デザイナーとの協業も容易です。次のチャプターでは、リピータを使ったリストやテーブルの表示について学びます。

実務でハマりやすい注意点

  • 問題
    Ajax submit 後に、さっき入力した TextField の値が空に戻ってしまう。入力確認画面などで特に混乱を招く。
    原因
    フォーム部品にモデルが正しく紐付いておらず、再描画時に保持先がない。見た目は入力できても状態が保存されていない。
    対策
    入力項目はすべてモデル付きで明示的に生成する。送信後に再描画される経路でも値が残るか確認する。
  • 問題
    一度選んだプルダウンを「未選択」に戻せない。条件検索や任意入力フォームで使い勝手が悪くなる。
    原因
    DropDownChoice 側で null 選択が許可されていない。選択肢に戻り値なしの状態が存在しないため。
    対策
    未選択を要件として許容するなら setNullValid(true) を設定する。バリデーション要件と合わせて設計する。
  • 問題
    同じ画面なのに操作順で結果が変わる。再現しづらく、バグ調査の難易度が上がる。
    原因
    同じモデルを複数の入力部品が共有し、どの部品が先に更新されるかで最終値が変わる。更新順に依存した設計になっている。
    対策
    入力単位でモデルを分離する。共有が必要な場合は更新順を明示的に固定し、テストで順序依存を検証する。
  • 問題
    submit 先が想定と違ったり、バリデーションが不安定になる。フォーム操作が複雑な画面で発生しやすい。
    原因
    フォームを入れ子にしており HTML 仕様に反している。ブラウザごとに解釈差も出やすく、挙動が読みにくくなる。
    対策
    フォーム境界はフラットに保つ。見た目のまとまりは Panel や Fragment で作り、フォーム構造と分離する。