Handsontableで非同期ページネーション

投稿者: | 2020年7月16日

handsontableで作成した表にkaminariページネーションを組み込みます。今回のサンプルでは、検索ボタンを押下し複数モデルをJOINした検索結果をajaxで受け、その結果が2ページ以上になる場合、次へボタンを活性化させます。

完成形

テスト1~テスト10まで、10項目があるが、次のページ(まだ2件ほど)が存在する。
次へを押下した場合。項目がテスト11、12に切り替わった。

kaminariの既存サンプルコードへの不満と課題

ページネーション化にあたり、kaminariというgemをインストールしています。ぐぐればいくらでもサンプルコードは出てきますが、主だったものは下記のようなものです。

コントローラ
class HogeController < ApplicationController
  def index
    @hoge= Hoge.page(params[:page] || 1).per(10)
  end
end
index.html.erb
<div>
  <% @hoge.each do |h| %>
(略)
  <% end %>
  <%= paginate @hoge %>
</div>

しかしこれでは初歩的過ぎて使い物にならないケースが出てきます。

  1. 一覧として返すものの中には、単一のモデルを単純に検索すればいいものだけではない。
  2. ページネーション部分をクリックした時の指定がない。つまり、これは「Model」から「URL」が引けるルーティングが前提だろう。url: hogehoge_path(foo_id)のように自前でAction先を決定するケースでは同様にURLを決めてやる必要がある。
  3. ページネーションを出したいのは、常に初期表示時ではない。検索ボタン押下後に出したい。
  4. 次へをクリックした時に、「検索条件」をサーバに渡してやる必要がある。

1は複数モデルをJOINした検索結果に件数制限をかける課題であり、2~4は「paginate @hoge」部分に隠ぺいされている部分をどうにか自前で何とかしなければならない課題と言えます。

複数モデルをJOINした検索結果にページネーション

modelの下に、relationというディレクトリを切り、クラスを配置しました。普通のモデルと混ぜたくなかったので名前は何でもよいです。

また、application.rbにパスを追加しました。

    Rails.application.config.eager_load_paths += Dir[Rails.application.config.root.join('app','models','relation')]

以下はRelationのソースコードですが、念のため記事用に編集済です。kaminari_objectの中にトータルページやカレントページの情報が入っています。この情報は最後に画面側で使います。

class RelationManHourTable
    #マトリクス形式の画面なので
    #縦(日付)はページ送りなしの固定にして,
    #横(工数項目)をページ送りにする.
    def initialize( from, to, page)
        @page = page.to_i
        @limit = 10
        #↓検索条件
        @cond_from = from
        @cond_to   = to
    end
    def current_page
        @page
    end
    def total_pages
        total = '9999'  #秘密.検索結果の件数を得る。
        return ( total.to_f / @limit ).ceil
    end
    def limit_value
        @limit
    end
    #@pageは1スタート.
    def offset
        (@page - 1) * @limit
    end
    def all
        my_execute( to_sql, to_cond_map )
    end
    def to_sql
        sql = []
        sql << " select"
#(略)
        sql << " from"
#(略)
        sql << "   left outer join"
        sql << "     ("
        sql << "      select"
#(略)
        sql << "      from"
        sql << "       HogeHeaders" #編集済
        sql << "      where"
#(略)
        sql << "      order by id"
        sql << "      limit :limit offset :offset"
        sql << "     ) hoge"
        sql << " where"
        sql << "     foo.ymd >= :from"
        sql << " and foo.ymd <= :to"
        sql << " order by foo.ymd, hoge.id"
        return sql.join
    end
    def to_cond_map
        return { from: @cond_from, to: @cond_to, limit: limit_value, offset: offset }
    end

    def my_execute( sql, cond_map )
        sani_sql = []
        sani_sql.push( sql )
        sani_sql.push( cond_map )
        return ActiveRecord::Base.connection.execute(ActiveRecord::Base.send(:sanitize_sql_array, sani_sql))
    end
end
コントローラ
def hoge
  from = params[:from]
  to   = params[:to]
  page = params[:page] || 1
  tables = RelationManHourTable.new( from, to, page )
  #sqlの実行結果をhandsontableにマッピングするマップ&配列の形式に変換する.
  result = convertHandsonData( tables.all )

  #実行結果をhandsontableにマッピング用にidと論理名のペアを渡す
  man_hours = HogeHeader.where([ "xxxxx", {from: from, to: to}]).page(page).per(10).order("id")
  headers = convertHandsonHead( man_hours )
  render json: {message: "success", status: :OK, data: result.to_json, headers: headers.to_json, kaminari: convert_kaminari_object(tables) }
end

def convert_kaminari_object( kaminari )
  return {
    current_page: kaminari.current_page,
    total_pages: kaminari.total_pages,
    has_next: kaminari.total_pages > kaminari.current_page,
    has_prev: kaminari.current_page > 1
  }
end

画面側にページネーション追加

次はhtmlとjs側です。cssのclass指定は一部編集で省略しています。

検索ボタンに検索条件をPOSTし、そのレスポンスを受け取ったら「paginate_area」に「次へ」と「前へ」のページネーションを追加するようにしてあります。

css
.paginate_area{
    padding: 10px;
    margin-bottom: 10px;
}
.paginate_element{
    margin: 0px 1px 0px 0px;
    padding: 6px 12px;
    border: solid 1px #bbbbbb;
    border-bottom: solid 2px #bbbbbb;
    background-color: #a0a0a0;
    color: #555555;
}
.paginate_active{
    background-color: #eeeeee;
    color: #0f0f0f;
    &:hover {
        text-decoration: underline;
    }
}
html
<%= link_to '検索', table_search_path(@id), method: "POST", class: "btn btn-primary", id: "searchBtn" %>
(略)
<div id="man_hour_area">
    <div id="man_hour_tables"></div>
    <div id="crud_command">
      <%= link_to '反映', table_edit_path(@id), method: "PATCH", 
        class: "btn-success", id: "commitBtn" %>
    </div>
    <div id="paginate_area" class="paginate_area"></div>
</div>
js
var getter = function () {
  //検索条件 FROM/TOの日付を作っているだけです。
  var from = moment( $("#cond_from").val());
  var toType = $("#cond_type").val();
  //['月末まで', 'END_MONTH'],['30日以内', '30'],['45日以内', '45']
  var to = null;
  if( toType == 'END_MONTH' ){
    to = from.clone().endOf('months')
  }else{
    to = from.clone().add( toType, 'days' )
  }
  var result = {
    from: from.format(),
    to: to.format()
  }
  return result;
};
var putsSearchResult = function (resJson) {
  $("#man_hour_area").show();
  $("#paginate_area").handsonPaginate(
    eval(resJson.kaminari), $("#searchBtn").attr('href'), $("#searchBtn").attr('data-method'), 
    getter, undefined, putsSearchResult );
    resetHot(eval(resJson.data), eval(resJson.headers)
  );
}
$('#searchBtn').commitFunction(getter, undefined, putsSearchResult);
js
//詳細は省略。headersとdataは検索結果。handsontableを
//再設定する.
var resetHot = function (data, headers) {
  //headersの分,colHeadに後で追加する.
  var colHead = ['', '', '', '報告済', '日付', '曜日', '備考', '開始', '終了', '除外', '時間数'];
    (略)
  hot.updateSettings({
    data: data,
    (略)
  });
}
js
$.fn.handsonPaginate = function( kaminari, action, method, getDataFunc, beforeFunc = undefined, successFunc = undefined ){
    var page = kaminari.current_page;
    $(this).empty();
    var parent_id = $(this).attr('id');
    var next_id = parent_id + '_next';
    var prev_id = parent_id + '_prev';
    var nextElement = '<span class="paginate_element" id="' + next_id +'">次へ</span>';
    var prevElement = '<span class="paginate_element" id="' + prev_id +'">前へ</span>';
    $(this).append( prevElement );
    $(this).append( nextElement );
    var paginateFunction = function( page, action, method, getDataFunc, beforeFunc = undefined, successFunc = undefined ){
        var newDataFunc = function(){
            var tmp = getDataFunc();
            tmp["page"] = page;
            return tmp;
        }
        if (beforeFunc === undefined) {} else {
            //エラーメッセージがnull以外ならばpostせずに終わる.
            var errmsg = beforeFunc()
            if (errmsg != null) {
                errorMessageFunction( errmsg );
                return;
            }
        }
        postAjax(action, method, JSON.stringify(newDataFunc()), successFunc);
    }
    if( kaminari.has_next ){
        $("#" + next_id).addClass("paginate_active");
        $("#" + next_id).bind('click', function(e) {
            e.preventDefault();
            e.stopPropagation();
            paginateFunction( page +1 , action, method, getDataFunc, beforeFunc, successFunc );
        });
    }
    if( kaminari.has_prev ){
        $("#" + prev_id).addClass("paginate_active");
        $("#" + prev_id).bind('click', function(e) {
            e.preventDefault();
            e.stopPropagation();
            paginateFunction( page - 1 , action, method, getDataFunc, beforeFunc, successFunc );
        });
    }
}
js(その他こまごま)
var errorMessageFunction = function( errmsg ){
    var dialog = $('<div></div>').text(errmsg);
    dialog.dialog({
        modal: true,
        title: "エラーがあります.",
        buttons: [{
            text: 'OK',
            click: function() { $(this).dialog('close'); }
        }]
    });
}
var postAjax = function(action, method, data, callBack) {
    $.ajax({
        url: action,
        type: method,
        data: data,
        dataType: 'json',
        contentType: 'application/json; charset=utf-8',
        async: true,
        processData: true,
        cache: false
    }).fail(function(xhr, status, error) {
        var dialog = $('<div></div>').text(xhr.responseJSON.message);
        dialog.dialog({
            modal: true,
            title: "エラーがあります.",
            buttons: [{
                text: 'OK',
                click: function() { $(this).dialog('close'); }
            }]
        });
    }).done(function(d) {
        if (callBack === undefined) {} else {
            callBack(d);
        }
    });
}