第13章: ModalDialog とポップアップ検索

業務システムでは、取引先、商品、部門、法人番号などを別ウインドウで検索し、 選択した値を元の入力画面へ戻す「ポップアップ検索」がよく使われます。 Wicket 10 では ModalDialog を使うことで、このような UI を Wicket コンポーネントとして実装できます。

ModalDialog の用途

ModalDialog は、ページ上にモーダル領域を表示し、その中に任意の Wicket コンポーネントを差し込むためのコンポーネントです。 業務画面では、次のような用途で特に便利です。

単純な確認だけなら JavaScript の confirm() で足ります。 しかし、検索条件、検索結果、選択ボタン、Ajax 更新を含む画面では、Wicket コンポーネントとして作る方が保守しやすくなります。

依存関係

ModalDialogwicket-core ではなく wicket-extensions に含まれます。 Maven プロジェクトでは次の依存関係を追加します。

<dependency>
    <groupId>org.apache.wicket</groupId>
    <artifactId>wicket-extensions</artifactId>
    <version>10.8.0</version>
</dependency>

Java 側では次のクラスを使います。

import org.apache.wicket.extensions.ajax.markup.html.modal.ModalDialog;
import org.apache.wicket.extensions.ajax.markup.html.modal.theme.DefaultTheme;

基本構成

モーダルを開く親ページには、ModalDialog 本体と、モーダルを開く Ajax コンポーネントを配置します。 ダイアログの中身は Panel として別クラスに切り出すと再利用しやすくなります。

親ページ HTML

<form wicket:id="form">
    <input type="text" wicket:id="txtCode" />
    <button type="button" wicket:id="btnSearch">検索</button>
    <div wicket:id="searchDialog"></div>
</form>

親ページ Java

private ModalDialog searchDialog;
private TextField<String> txtCode;

txtCode = new TextField<>("txtCode", new PropertyModel<>(bean, "code"));
txtCode.setOutputMarkupId(true);
form.add(txtCode);

searchDialog = new ModalDialog("searchDialog");
searchDialog.add(new DefaultTheme());
searchDialog.closeOnEscape().trapFocus();
form.add(searchDialog);

AjaxButton btnSearch = new AjaxButton("btnSearch") {
    @Override
    protected void onSubmit(AjaxRequestTarget target) {
        SearchPanel panel = new SearchPanel(
            ModalDialog.CONTENT_ID,
            searchDialog,
            (ajaxTarget, selectedCode) -> {
                bean.setCode(selectedCode);
                txtCode.setModelObject(selectedCode);
                ajaxTarget.add(txtCode);
            });
        searchDialog.open(panel, target);
    }
};
btnSearch.setDefaultFormProcessing(false);
form.add(btnSearch);

元画面の入力欄を Ajax で更新する場合は setOutputMarkupId(true) が必要です。 これを忘れると target.add(txtCode) してもブラウザ側を更新できません。

ポップアップ検索の実装

検索ダイアログは Panel として実装します。 検索条件、結果一覧、選択ボタンを持ち、選択時に親ページへ値を返します。

検索パネル HTML

<wicket:panel>
    <div class="popup-search">
        <a href="#" wicket:id="btnClose">閉じる</a>
        <input type="text" wicket:id="txtKeyword" />
        <a href="#" wicket:id="btnExecuteSearch">検索</a>

        <table wicket:id="resultContainer">
            <tr wicket:id="listViewResult">
                <td><span wicket:id="lblCode"></span></td>
                <td><span wicket:id="lblName"></span></td>
                <td><a href="#" wicket:id="btnSelect">選択</a></td>
            </tr>
        </table>
    </div>
</wicket:panel>

検索パネル Java

public class SearchPanel extends Panel {
    private static final long serialVersionUID = 1L;

    private final ModalDialog dialog;
    private final SelectHandler selectHandler;
    private String keyword;
    private List<Candidate> results = new ArrayList<>();
    private WebMarkupContainer resultContainer;
    private ListView<Candidate> listViewResult;

    public SearchPanel(String id, ModalDialog dialog, SelectHandler selectHandler) {
        super(id);
        this.dialog = dialog;
        this.selectHandler = selectHandler;
        initForm();
    }

    private void initForm() {
        add(new AjaxLink<Void>("btnClose") {
            @Override
            public void onClick(AjaxRequestTarget target) {
                dialog.close(target);
            }
        });

        add(new TextField<>("txtKeyword", new PropertyModel<>(this, "keyword")));

        add(new AjaxLink<Void>("btnExecuteSearch") {
            @Override
            public void onClick(AjaxRequestTarget target) {
                results = search(keyword);
                listViewResult.setList(results);
                listViewResult.removeAll();
                target.add(resultContainer);
            }
        });

        resultContainer = new WebMarkupContainer("resultContainer");
        resultContainer.setOutputMarkupId(true);
        add(resultContainer);

        listViewResult = new ListView<>("listViewResult", results) {
            @Override
            protected void populateItem(ListItem<Candidate> item) {
                Candidate candidate = item.getModelObject();
                item.add(new Label("lblCode", candidate.code()));
                item.add(new Label("lblName", candidate.name()));
                item.add(new AjaxLink<Void>("btnSelect") {
                    @Override
                    public void onClick(AjaxRequestTarget target) {
                        selectHandler.onSelect(target, candidate.code());
                        dialog.close(target);
                    }
                });
            }
        };
        resultContainer.add(listViewResult);
    }

    @FunctionalInterface
    public interface SelectHandler extends Serializable {
        void onSelect(AjaxRequestTarget target, String selectedCode);
    }
}

モーダル内の検索や選択は、外側フォームの送信と混ざらないように AjaxLink を使うと扱いやすくなります。 AjaxButton を使う場合は setDefaultFormProcessing(false) を明示して、 元画面のバリデーションを走らせないようにします。

シリアライズの注意点

Wicket はページインスタンスをセッションやページストアへ保存するため、ページが保持するコンポーネントやフィールドは シリアライズ可能である必要があります。ポップアップ検索でよく問題になるのは、選択時コールバックです。

@FunctionalInterface
public interface SelectHandler extends Serializable {
    void onSelect(AjaxRequestTarget target, String selectedCode);
}

ラムダ式をフィールドに保持する場合でも、代入先の関数型インターフェースが Serializable を継承していれば、 Wicket のシリアライズチェックに通せます。

NotSerializableException が出た場合は、スタックトレースの Field hierarchy を読み、どのフィールドに非 Serializable オブジェクトが残っているか確認します。 DAO、EntityManager、Service 実体などを Panel のフィールドに持つのも避けましょう。

Bootstrap との衝突

Wicket の ModalDialog は内部で modal-dialog という CSS クラスを出力します。 Bootstrap も同じクラス名を使っており、Bootstrap 側の指定によってクリックやスクロールが効かなくなることがあります。 代表例は pointer-events: none の衝突です。

.modal-dialog-overlay {
    position: fixed;
    inset: 0;
    overflow: auto;
    pointer-events: auto;
    z-index: 1055;
}

.modal-dialog-overlay .modal-dialog {
    pointer-events: auto !important;
    max-width: none;
}

Bootstrap を併用する場合は、Wicket の ModalDialog 用 CSS を明示しておくと安全です。 クリックできない、入力欄にマウスでフォーカスできない、モーダル内でスクロールできない、といった症状は CSS 衝突を疑います。

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

  • 問題
    検索ボタンを押すと元画面の必須入力エラーが出る。
    原因
    モーダル内ボタンが外側フォームの通常送信として扱われ、元画面のバリデーションが実行されている。
    対策
    AjaxLink を使うか、AjaxButtonsetDefaultFormProcessing(false) を設定する。
  • 問題
    選択しても元画面の入力欄が更新されない。
    原因
    更新対象コンポーネントに markup id が出力されていない、または Ajax レスポンスに追加していない。
    対策
    対象入力欄へ setOutputMarkupId(true) を設定し、選択時に target.add(component) する。
  • 問題
    ページ保存時に NotSerializableException が発生する。
    原因
    検索パネルが保持するラムダ、サービス、DAO などが Serializable ではない。
    対策
    コールバック用インターフェースは Serializable を継承する。永続化リソースはフィールドに保持しない。
  • 問題
    入力欄をクリックできず、モーダル内スクロールも効かない。
    原因
    Bootstrap の .modal-dialog と Wicket の .modal-dialog が衝突している。
    対策
    ModalDialog 配下の pointer-eventsoverflow を CSS で明示的に上書きする。

まとめ

観点ポイント
依存関係wicket-extensions を追加する
構成親ページに ModalDialog、中身は Panel に分離する
値の返却Serializable なコールバックで親ページへ通知する
Ajax 更新更新対象に setOutputMarkupId(true) を設定する
CSSBootstrap 併用時は .modal-dialog の衝突に注意する