改良中...効率よく挿入、削除、コピーする。

1:多の複数モデルかつ多明細を、1アクションで同時に保存するサンプルで、挿入、削除、コピーも可能になり、前回、「これなら満足」と書いたが、実はまだまだ満足してはいけない...。
現状では、挿入、削除、コピーのアクションリンクをクリックした後、明細行のすべてを再描画している状態だ。この状況では、100明細あると、1行追加しただけなのに101行分再描画することになる。無駄が多い...。JavaScriptヘルパーにはちゃんと1行だけ追加するヘルパメソッドも用意されている。
また、検証エラーが表示されている時に挿入、削除、コピーを行うと、明細行すべてをEditページとして再描画するため、それまで表示されていた検証エラーが消えてしまう。ユーザーは、検証エラーが解消されたと勘違いしてしまう可能性もあり、好ましい動作ではない。表示されているエラーはそのままの状態をキープして、submitボタンを押した時だけ検証されるようにした方が、操作に統一感があって良いはずだ。
以上のことを考えながら、変化した明細行だけ再描画する方式に変更してみた。

  • 明細行全体を指していた<tbody id="journals_form">タグを削除した。
<%# ビュー: app/views/models/slips/_form.rhtml %>

<!--[form:slip]-->
  <p><label for="slip_number">伝票No.</label><br />
    <%= text_field 'slip', 'number', :autocomplete=>'off'  %>
    <%= error_messages_on 'slip', 'number' %>
  </p>

  <p><label for="slip_executed_on">実行日</label><br />
    <%= text_field 'slip', 'executed_on', :autocomplete=>'off'  %>
    <%= error_messages_on 'slip', 'executed_on' %>
  </p>

  <p><label for="slip_total_yen">合計金額</label><br />
    <%= yen_field 'slip', 'total_yen'  %>
    <%= error_messages_on 'slip', 'total_yen' %>
  </p>

  <%= error_messages_on 'slip', 'base' %>

  <table class="journal">
    <%= render :partial=>'journals/header' %>
    <%= render :partial=>'journals/form', :collection=>@journals %>
  </table>
<!--[eoform:slip]-->
  • 代わりに、1明細ごとに<tbody id="<%= @journal.index %>">タグを設定しておく。
  • パラメーターの送信範囲(:submitオプション)を上記@journal.indexに限定した。
  • 明細No.には、<span>タグでid属性を設定しておいた。
  • ナンバリング処理のためのクラス属性を追加した。(class="journal_number"、:class=>'journal_position')
<%# ビュー: app/views/models/journals/_form.rhtml %>
<% @journal = form || @effect_item %>
<% @journal_count += 1 rescue @journal_count = 1 %>

<tbody id="<%= @journal.index %>"><%#<--- tbodyとそのid属性の設定 %>
<!--[form:journal]-->
<tr valign="top" id="<%#= @journal.index %>">
  <th>
    <%= link_to_remote "", {:submit=>@journal.index, :url=>{:action=>'insert_row', :index=>@journal.index}}, {:title=>"1行挿入"} %>
    <%= link_to_remote "", {:submit=>@journal.index, :url=>{:action=>'delete_row', :index=>@journal.index}}, {:title=>"1行削除"} %>
    <%= link_to_remote "", {:submit=>@journal.index, :url=>{:action=>'copy_row', :index=>@journal.index}}, {:title=>"1行コピー"} %>
  </th>
  <th align="right">
    <span class="journal_number" id="journal_<%= @journal.index %>_number"><%= @journal_count %></span><%#<--- 明細行No.にspanタグでclass属性、id属性を追記 %>
    <%= hidden_field 'journal', 'position', :index=>@journal.index, :value=>@journal_count, :class=>'journal_position'  %><%#<--- class属性を追記 %>
    <%= hidden_field 'journal', 'index', :index=>@journal.index %>
  </th>
  <td align="_center">
    <%= text_field 'journal', 'comment', :index=>@journal.index, :size=>40  %>
    <%= error_messages_on 'journal', 'comment' %>
  </td>
  <td align="_center">
    <%= yen_field 'journal', 'yen', :index=>@journal.index  %>
    <%= error_messages_on 'journal', 'yen' %>
  </td>
</tr>
<!--[eoform:journal]-->
</tbody>
  • 明細行全体の描画をやめて、指定した位置に挿入、または削除するように変更した。
  • その結果、for_editing_rowメソッドは不要になった。
  • Slipモデルのinsert_journal、copy_journal、delete_journalメソッドも不要になった。
  • その代わりに、明細行No.を再設定する処理numbering_rowを追加した。
# コントローラー: app/controllers/slips_controller.rb
class SlipsController < ApplicationController
  #before_filter :for_editing_row, :only=>[:insert_row, :copy_row, :delete_row]
...(中略)...
private
  # 挿入、削除、コピーの前処理
  #def for_editing_row
  #  @slip = Slip.new(params[:slip])
  #  @slip.make_journals(params[:journal])
  #end

  def render_insert(position)
    render :update do |page|
      #page.replace_html 'journals_form', :partial=>'journals/form', :collection=>@slip.editing_journals
      # 明細行全体の再描画をやめ、指定した位置に挿入するように変更した。
      page.insert_html position, params[:index], render(:partial=>'journals/form')
      highlight_row(page, @effect_item.index, :duration=>2)
      numbering_row(page, @effect_item.position - 1)
    end
  end

public
  def insert_row
    # コントローラーで操作対象の明細行(current_journal)を求めて...
    current_journal = params[:journal].map {|index, attr| Journal.new(attr)}.first
    #@effect_item = @slip.insert_journal(params[:index])
    # 挿入する明細行をSlipモデルを通さずに作成する。
    @effect_item = Journal.new(:position=>current_journal.position)
    render_insert(:before)
  end

  def copy_row
    # コントローラーで操作対象の明細行(current_journal)を求めて...
    current_journal = params[:journal].map {|index, attr| Journal.new(attr)}.first
    #@effect_item = @slip.copy_journal(params[:index])
    # コピーする明細行をSlipモデルを通さずに作成する。
    @effect_item = Journal.new(current_journal.attributes)
    render_insert(:after)
  end

  def delete_row
    #@effect_item = @slip.delete_journal(params[:index])
    # 削除する明細行をSlipモデルを通さずに作成する。
    @effect_item = params[:journal].map {|index, attr| Journal.new(attr)}.first
    render :update do |page|
      highlight_row(page, @effect_item.index, :duration=>2, :startcolor=>"'#666666'")
      page.delay(1) do
        #page.replace_html 'journals_form', :partial=>'journals/form', :collection=>@slip.editing_journals
        # 明細行全体の再描画をやめ、指定した位置を削除するように変更した。
        page.remove params[:index]
        numbering_row(page, @effect_item.position - 1)
      end
    end  
  end
end
  • 必要なposition以降の明細行No.を再設定するnumbering_rowヘルパメソッドを追加した。
# ヘルパー: app/helpers/slips_helper.rb
module SlipsHelper
  # 1行すべてをハイライト処理する
  def highlight_row(page, id, js_options={})
    tags = %w{th td input textarea select}
    tags.each do |tag|
      page.select("##{id} #{tag}").each {|element| page.visual_effect(:highlight, element, js_options)}
    end
  end

  # 明細行No.を再設定する
  def numbering_row(page, num=0)
    page << <<-end
      positions = $$('.journal_position');
      numbers = $$('.journal_number');
      for (i = #{num}; i < positions.length; i++){
        positions[i].value = i + 1;
        Element.update(numbers[i], i + 1);
      }
    end
  end
end


以上の変更で、必要な部分だけ再描画する方式で挿入、削除、コピーが可能になった。明細行が増えても、軽快に操作できる!この変更で感じたことは、複雑な処理を必要としなければ、編集中の明細行(モデル)の状態保存には、ページ内のHTMLで十分ということ。(今回の例で考えれば、明細行全体の再描画に必要だった@slip.editing_journlasをアクションごとに再構築する処理は省略した。JavaScriptをもっと深く知れば、複雑な処理であってもページ内のHTMLを操作して処理できると思う。)

明細行の合計金額を自動計算する必要がある場合

では、複雑な処理ではないが、明細行の合計金額を自動計算する行がある場合を考えてみる。以下のように、合計金額を表示する行を_footer.rhtmlとして追加してみた。

<%# ビュー: app/views/models/slips/_form.rhtml %>

<!--[form:slip]-->
...(中略)...
  <table class="journal">
    <%= render :partial=>'journals/header' %>
    <%= render :partial=>'journals/form', :collection=>@journals %>
    <%= render :partial=>'journals/footer' %>
  </table>
<!--[eoform:slip]-->
<%# ビュー: app/views/models/journals/_footer.rhtml %>
<tbody id="journals_footer">
<!--[footer:journal]-->
<tr>
  <th></th>
  <th colspan="2" align="right">
    計:
  </th>
  <th>
    <%= yen_field :slip, :journals_total_yen, :disabled=>true %>
  </th>
</tr>
<!--[eofooter:journal]-->
</tbody>
  • 以前のように伝票全体の範囲をパラメーターとして送信する。(:submit=>'slip')
<%# ビュー: app/views/models/journals/_form.rhtml %>
<% @journal = form || @effect_item %>
<% @journal_count += 1 rescue @journal_count = 1 %>

<tbody id="<%= @journal.index %>"><%#<--- tbodyとそのid属性の設定 %>
<!--[form:journal]-->
<tr valign="top" id="<%#= @journal.index %>">
  <th>
    <%= link_to_remote "", {:submit=>'slip', :url=>{:action=>'insert_row', :index=>@journal.index}}, {:title=>"1行挿入"} %>
    <%= link_to_remote "", {:submit=>'slip', :url=>{:action=>'delete_row', :index=>@journal.index}}, {:title=>"1行削除"} %>
    <%= link_to_remote "", {:submit=>'slip', :url=>{:action=>'copy_row', :index=>@journal.index}}, {:title=>"1行コピー"} %>
  </th>
...(中略)...
  • for_editing_rowメソッドを復活させ、以前のように明細行を@slip.editing_journalsとして再構築する。
  • 合計金額を再描画するpage.replace 'journals_footer', :partial=>'journals/footer'を追加した。
# コントローラー: app/controllers/slips_controller.rb
class SlipsController < ApplicationController
  before_filter :for_editing_row, :only=>[:insert_row, :copy_row, :delete_row]
...(中略)...
private
   挿入、削除、コピーの前処理
  def for_editing_row
    @slip = Slip.new(params[:slip])
    @slip.make_journals(params[:journal])
  end

  def render_insert(position)
    render :update do |page|
      page.insert_html position, params[:index], render(:partial=>'journals/form')
      highlight_row(page, @effect_item.index, :duration=>2)
      numbering_row(page, @effect_item.position - 1)
      page.replace 'journals_footer', :partial=>'journals/footer'
    end
  end

public
  def insert_row
    @effect_item = @slip.insert_journal(params[:index])
    render_insert(:before)
  end

  def copy_row
    @effect_item = @slip.copy_journal(params[:index])
    render_insert(:after)
  end

  def delete_row
    @effect_item = @slip.delete_journal(params[:index])
    render :update do |page|
      highlight_row(page, @effect_item.index, :duration=>2, :startcolor=>"'#666666'")
      page.delay(1) do
        page.remove params[:index]
        numbering_row(page, @effect_item.position - 1)
        page.replace 'journals_footer', :partial=>'journals/footer'
      end
    end  
  end
end
  • numbering_rowメソッドに対応させるため、挿入した行にposition情報を持たせるようにした。(変更はオレンジ色の部分のみ)
# モデル: app/models/slip.rb
class Slip < ActiveRecord::Base
...(中略)...
  # index値で指定されたJournalインスタンスを返す
  def journal_at(index)
    @editing_journals.find {|item| item.index == index}
  end

  def copy_journal(params_index)
    current_journal = journal_at(params_index)
    insert_index = current_journal.position
    insert_journal = Journal.new(current_journal.attributes)
    @editing_journals.insert(insert_index, insert_journal)
    insert_journal
  end

  def insert_journal(params_index)
    current_journal = journal_at(params_index)
    insert_index = current_journal.position - 1
    insert_journal = Journal.new(:position=>current_journal.position)
    @editing_journals.insert(insert_index, insert_journal)
    insert_journal
  end

  def delete_journal(params_index)
    current_journal = journal_at(params_index)
    delete_index = current_journal.position - 1
    @editing_journals.delete_at(delete_index)
  end
end


たかが合計金額*1のために、処理をSlipモデルに依頼して大分コード量が増えてしまうが、こうしておけば、もっと複雑な処理が必要になった時にもその対応が楽になる。

*1:金額がカンマ区切りの文字列になっていたりするので、今の自分にとっては、たかが合計金額と言ってもJavaScriptで計算するのは簡単ではない。