JSF で JQueryUI Sortable を使う

f:id:Naotsugu:20161201234053p:plain

最近すぐに忘れてしまうので、細かいことでも書いておこう。


テーブルの一覧画面の並び順や項目名、表示/非表示などをユーザが自由にカスタマイズする機能追加の対応があった。 ユーザ独自のビューとして画面で見たり、ダウンロードして他のシステムに投入したりできるようにするというもの。

カスタマイズボタンからモーダルダイアログを開き、ダイアログでドラッグして並び順を変えて保存できるようにする。並び替えは JQueryUI の Sortable でやることにした。

Sortable

Sortable は以下のようなテーブルに対して、

<table id="sortable">
  <thead>
    <tr><th>A</th><th>B</th></tr>
  </thead>
  <tbody>
    <tr><td>1</td><td>2</td></tr>
    <tr><td>3</td><td>4</td></tr>
    <tr><td>5</td><td>6</td>
    </tr>  
  </tbody>    
</table><200b>

以下のようにするだけ。

$("#sortable tbody").sortable({});

または、tbody に id を振って、

<table>
  <thead>
    <tr><th>A</th><th>B</th></tr>
  </thead>
  <tbody id="sortable">
    <tr><td>1</td><td>2</td></tr>
    <tr><td>3</td><td>4</td></tr>
    <tr><td>5</td><td>6</td>
    </tr>  
  </tbody>    
</table><200b>

以下のようにすれば良い。

$("#sortable").sortable();

Sortable でドラッグ時にテーブルの横幅が縮んでしまう

stackoverflowのjquery UI Sortable with table and tr width にある通り、helper を指定してあげれば良い。

以下のような関数を用意しておき、

var fixHelperModified = function(e, tr) {
    var $originals = tr.children();
    var $helper = tr.clone();
    $helper.children().each(function(index)
    {
      $(this).width($originals.eq(index).width())
    });
    return $helper;
};

以下のようにオプション指定する。

$("#sortable").sortable({
    helper: fixHelperModified 
});

この helper はドラッグ中の表示方法を指定し、文字列か関数を渡せる。 デフォルトは "original" でドラッグ要素をマウスカーソルに合わせて動かし、"clone" を指定すると要素を複製して表示する ghosting エフェクトが適用される。

ということで、元要素の幅を設定する関数を指定することで解決できる。

Sortable の結果をPOSTする

Sortable の オプションで sortable("serialize" [options])sortable("toArray") とすることで並び替えた順番が得られる。

serialize は並べ替えた順番で、Sortable アイテムに設定されたIDからフォーム送信やajax送信できるようにシリアライズする。 IDに、"-"(ハイフン)、"="(等号)、"_"(アンダースコア)があるかどうかを判別し、その文字で分割してハッシュ文字列を生成するとのことで、 並べ替えた要素のIDが、 item-1, item-3, item-4, item-2 の場合は item[]=1&item[]=3&item[]=4&item[]=2 となる。

toArray は並べ替えた後のアイテムのIDの配列を返す。 Sortable アイテムのIDが、item-1, item-3, item-4, item-2 の場合は ["item-1", "item-3", "item-4", "item-2"] となる。

なので単純に以下のように post したいところ。

$("#sortable").sortable({
  update: function (event, ui) {
    var data = $(this).sortable('serialize');

    $.ajax({
      data: data,
      type: 'POST',
      url: '/your/url/here'
  });
}
});

話はそれるが、sortable のイベントは Why does the update event in jquery sortable seem to run twice when testing for ui.senderjQuery UI Sortable Position で語られているように2つのリストで並び替えした場合などで update イベントが2回呼ばれるようなので、stop イベントの使い分けには注意が必要かもしれない。

だけど今回 JSF なので

ご存知の通り JSF で独自に ajax 扱うのはいばらの道なので違う方法を考える。

かなり簡略化するが、今回は以下のような形で定義を保存する。

@Entity
public class ColumnDefinition {
    @Id
    private Long id;
    private String name;
    private int itemIndex;
    private boolean denote;

    // getter/setter

}

もちろん ColumnDefinition を束ねる親も居るが省略。

そして xhtml は以下のようなイメージ。

<table class="table table-bordered form-inline" cellpadding="4">
    <thead>
        <tr>
            <th></th>
            <th>Item</th>
            <th></th>
        </tr>
    </thead>
    <tbody id="idSortable">
        <ui:repeat value="#{BackingBean.definitions}" var="_definition">
        <tr>
            <td class="handle"></td>
            <td><h:inputText value="#{_definition.name}"/></td>
            <td><h:selectBooleanCheckbox value="#{_definition.denote}"/></td>
        </tr>
        </ui:repeat>
    </tbody>
</table>
</div>

CSS で並べ替えのハンドルを付けたいので handle クラスも定義。

.handle:after { 
    cursor: pointer;
    content: '≡'; 
    display: block; 
    width: 100%; 
    text-align: center; 
    text-indent: 0; 
    color: #999999;
    font-size: 24px; 
    font-weight: normal;
    margin-top: 5px;
}

バッキングビーンは以下のようなイメージ。

@ManagedBean
@ViewScoped
public class BackingBean implements Serializable {

    private List<ColumnDefinition> definitions;

    @PostConstruct
    public void init() {
        definitions = sortedCopy(service.findDifinition(・・・))
    }
}

テキストボックスに並び順を入れて javascript で更新する

テキストボックスを disabled 表示して、並び替えのタイミングでJSから index 値を書き換えることにする。

styleClass="sortableIndex" をつけたテキストボックスを disabled にして表示して、

<table class="table table-bordered form-inline" cellpadding="4">
    <thead>
        <tr>
            <th></th>
            <th>No</th>
            <th>Item</th>
            <th></th>
        </tr>
    </thead>
    <tbody id="idSortable">
        <ui:repeat value="#{BackingBean.definitions}" var="_definition">
        <tr>
            <td class="handle"></td>
            <td><h:inputText value="#{_definition.itemIndex}" styleClass="sortableIndex" disabled="true"/></td>
            <td><h:inputText value="#{_definition.name}"/></td>
            <td><h:selectBooleanCheckbox value="#{_definition.denote}"/></td>
        </tr>
        </ui:repeat>
    </tbody>
</table>
</div>

Sortable の stop イベントで itemIndex を更新してあげる。

$("#sortable").sortable({
  stop: function () {
    $(".sortableIndex").each(function(idx) {
        $(this).val(idx);
    });
  }
});

これで行けるかとおもったけど、そうだった、disabled が付いているとブラウザはサーバに値を post しない。 values of disabled inputs will not be submited?

readonly 属性で対応することもできるけど、フォーカスが当たってしまうので却下。参考情報としてはDisabled form inputs do not appear in the request あたり。

あとは、post する前に disabled 属性を解除して値を入れてから post したり、テキストボックスの値を取って動的に hidden 要素を追加するなども考えられるが、JSF ではあまりやりたくない。参考情報としては how to POST/Submit an Input Checkbox that is disabled? あたり。

hidden を使う

h:inputHidden を使うことにする。以下のように h:inputHidden にして、項番はテキスト表示して、

<h:inputHidden value="#{_definition.itemIndex}" styleClass="sortableIndex"/>
<span><h:outputText value="#{_definition.itemIndex}"/></span>

JS 側で並び替え時に値を洗い替えする。

stop: function () {
    $(".sortableIndex").each(function(idx) {
        $(this).val(idx);
    $(this).next().text(idx);
    });
}

といきたかったが、h:inputHidden タグには styleClass 属性がない。。

直接 input タグで書いたところで、JSF なのでバッキングビーン側にはリストアされない。

<input type="hidden" value="#{_definition.itemIndex}" class="sortableIndex">

無理やり jsfc で書いてみても、

<input type="hidden" jsfc="h:inputHidden" class="sortableIndex">

レンダリングされた結果は以下のようになり、class 属性は完全に無視される。

<input type="hidden">

h:inputHidden を ID で拾う

カスタムタグを作成することが頭によぎったけど、以下のように id 指定して、

<h:inputHidden value="#{_definition.itemIndex}" id="hiddenSortableIndex"/>

ID値の後方一致でJQueryで頑張ることにした。

$("[id$=hiddenSortableIndex]")

JSFはコンポーネントのIDをタグのネスト構造によって書き換えるけど、プレフィックスとして頭に親のIDを付与するので、後方一致で見ておけば大丈夫と思う。

最終的には

以下のようなイメージになった。

<table class="table table-bordered form-inline" cellpadding="4">
    <thead>
        <tr>
            <th></th>
            <th>No</th>
            <th>Item</th>
            <th></th>
        </tr>
    </thead>
    <tbody id="idSortable">
        <ui:repeat value="#{BackingBean.definitions}" var="_definition">
        <tr>
            <td class="handle"></td>
            <td>
                <h:inputHidden value="#{_definition.itemIndex}" id="hiddenSortableIndex"/>
                <span><h:outputText value="#{_definition.itemIndex}"/></span>
            </td>
            <td><h:inputText value="#{_definition.name}"/></td>
            <td><h:selectBooleanCheckbox value="#{_definition.denote}"/></td>
        </tr>
        </ui:repeat>
    </tbody>
</table>
</div>

javascript 側は

$( function() {

    function fixHelperModified(e, tr) {
        var $originals = tr.children();
        var $helper = tr.clone();
        $helper.children().each(function(index) {
            $(this).width($originals.eq(index).width());
        });
        return $helper;
    };

    $("#idSortable").sortable({
        helper: fixHelperModified,
        cursor: "move", 
        stop: function () {
            $("[id$=hiddenSortableIndex]").each(function(idx) {
                $(this).val(idx);
                $(this).next().text(idx);
            });
        }
    });
});

しょーもないことで時間使った。。