改良中...明細行を挿入、削除、コピーしたい!

伝票入力していると、ユーザーは必ず明細行の挿入、削除、コピーをしたくなるものだ。ほとんど内容が同じ行を繰り返し入力する気にはなれないし、入力忘れがあったらその位置に挿入したくなるし、不要な行は削除したくなる。自然な要望だ。そもそもコンピューターを使えば挿入、削除、コピーが簡単にできることがウリのはず。
ところが、いざその機能を実装しようとすると、結構手強い。既にデータベースに登録されたデータを扱う場合には、Railsにはacts_as_listという拡張機能がある。それが利用できれば手軽に実現できそうなのだ。しかし、今回は以下のような状況で、果たして利用できるのか?

  • 伝票入力中のデータはまだデータベスに保存されていない。
  • 編集中の伝票には、未入力の行もある。未入力の行もその状態を保持して、挿入、削除、コピーできるようにしたい。
  • そしてデータベースに保存する時は、入力されている行だけ保存する。未入力の行は保存しない。

自分なりにやってみたが、acts_as_listの利用は諦めた...。(idが設定されていない状況で使う方法ってあるのだろうか?)journalモデル、slipモデルとslipsコントローラーにメソッドを追加して、以下のようにやってみた。

index値を保持する

現状では、ページが更新される度に明細行(journalインスタンス)のindex値が変わってしまう。挿入、削除、コピーを実現するためには、一度設定されたindex値は、入力中は保持しておく必要がある。(...と思う。indexがidの代わりになる。)

  • indexがnilの時だけ、新規indexを設定するように変更した。
# モデル: app/models/journal.rb
class Journal < ActiveRecord::Base
...(中略)...
  def initialize(*attr)
    super
    # 以下の形式でユニークなインデックス値を設定する。
    # 例: j948696358
    @index ||= "j#{Time.now.hash.abs}"
  end

  def index
    id || @index
  end
  • hidden_fieldにより、常にindex値をパラメーターとして送信することにした。(すると、ビューとコントローラーの間でindex値のキャッチボールを繰り返すことになり、その結果、index値が保持されることになる。)
<%# ビュー: app/views/journals/_form.rhtml %>
...(中略)...
<%= hidden_field 'journal', 'index', :index=>@journal.index %>
...(中略)...

挿入、削除、コピーのリンクを作る

  • 明細行の_form.rhtmlには、挿入(+)・削除(ー)・コピー(▼)のリンクも追加して、以下のようになった。
  • :submit=>'slip'は、この伝票全体の情報を送信することになる。(無駄が多いので、あとで調整するかもしれない。)
  • :title属性を設定しておくと、マウスがリンク上にある時、ツールチップとして文字列が表示される。(対応しているブラウザで)
<%# ビュー: app/views/journals/_form.rhtml %>
<% @journal = form %>
<% @journal_count += 1 rescue @journal_count = 1 %>

<!--[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>
  <th align="right">
    <%#= text_field 'journal', 'position', :index=>@journal.index, :value=>@journal_count, :size=>4, :readonly=>true  %>
    <%= @journal_count %>
    <%= hidden_field 'journal', 'position', :index=>@journal.index, :value=>@journal_count  %>
    <%= 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]-->
  • 明細の列タイトルにも空の1列を追加しておく。(ないと表示がズレてしまうので。)
<%# ビュー: app/views/journals/_header.rhtml %>

<!--[header:journal]-->
<tr>
  <th></th>
  <th>No.</th>
  <th>摘要</th>
  <th>金額</th>
</tr>
<!--[eoheader:journal]-->

slipsコントローラーで処理する

挿入+、削除ー、コピー▼のリンクをクリックした時のパラメーターを受け取って、slipsコントローラーでは以下のように処理する。

  • 受け取ったパラメーターから、伝票、明細行の状態を復元(before_filterで、for_editing_rowメソッドを呼び出している。)
  • Slipモデルで、実際の挿入・削除・コピーの処理を行う。
  • render :updateで明細行の部分(journals_form)だけ再描画する。
# コントローラー: 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

public
  def insert_row
    @slip.insert_journal(params[:id])
    render :update do |page|
      page.replace_html 'journals_form', :partial=>'journals/form', :collection=>@slip.editing_journals
    end
  end

  def copy_row
    @slip.copy_journal(params[:id])
    render :update do |page|
      page.replace_html 'journals_form', :partial=>'journals/form', :collection=>@slip.editing_journals
    end
  end

  def delete_row
    @slip.delete_journal(params[:id])
    render :update do |page|
      page.replace_html 'journals_form', :partial=>'journals/form', :collection=>@slip.editing_journals
    end  
  end
end
  • 再描画する部分のid属性 journals_form は、<tbody id="journals_form">タグで囲った以下の範囲。
<%# ビュー: app/views/slips/_form.rhtml %>

<!--[form:slip]-->
...(中略)...
  <table class="journal">
    <%= render :partial=>'journals/header' %>
    <tbody id="journals_form">
      <%= render :partial=>'journals/form', :collection=>@journals %>
    </tbody>
  </table>
<!--[eoform:slip]-->

slipモデルで処理する

  • コントローラーから呼び出され、実際に挿入・削除・コピーを処理するコードは以下のようにしてみた。
# モデル: app/models/slip.rb
...(中略)...
  # 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)
  end

  def insert_journal(params_index)
    current_journal = journal_at(params_index)
    insert_index = current_journal.position - 1
    insert_journal = Journal.new
    @editing_journals.insert(insert_index, 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
  • 上記コードで、Journal.new(current_journal.attributes)を実行した時に、'yen' => nilや数値 の場合があり、エラーになってしまったので、文字列の場合だけ処理するようにした。
# モデル: app/models/slip.rb
class Journal < ActiveRecord::Base
...(中略)...
  def yen=(value)
    # nilや数値の場合エラーが発生するので、文字列だけ処理するようにした。
    # value.to_s.gsub(/,/, '')でも良いと思う。
    self[:yen] = (value.class == String ? value.gsub(/,/, '') : value)
  end

javascript_include_tag :defaultsを追記

  • そうそう、いつもながら肝心なことを忘れていた。JavaScriptを使うなら、ライブラリを利用できるようにしとかないと...。<head>タグの最後に、以下のように追記した。
# レイアウト: app/views/layouts/slip.rhtml
...(中略)...
<head>
  <meta http-equiv="content-type" content="text/html;charset=UTF-8" />
  <title>Slips: <%= controller.action_name %></title>
  <%= stylesheet_link_tag 'scaffold' %>
  <%= javascript_include_tag :defaults %>
</head>
...(中略)...


以上の変更をして、試してみる。追加+、削除ー、コピー▼のリンクをクリックすると、その動作がちゃんと処理される!出来た...と言いたいところだが、ちょっと不満足なところがある。挿入、削除、コピーの動作が地味すぎて何が起こったのか分かりにくいのだ...。もうちょっと視覚的に分かり易いフィードバックが必要だ。

ビジュアルエフェクトでフィードバックする

そんな時のためにビジュアルエフェクトがある。ここでは以下のようにhighlightを使ってみた。

  • copy_jurnal、insert_journal、delete_journalはコピー、挿入、削除したアイテムを返すようにした。(修正箇所はオレンジ)
# モデル: app/models/slip.rb
...(中略)...
  # 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
    @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)
    # delete_atは削除したアイテムを返してくれる。
  end
  • モデルでコピー、挿入、削除したアイテムに対し、ビジュアルエフェクトを実行する。(修正箇所はオレンジ)
# コントローラー: 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

public
  def insert_row
    @effect_item = @slip.insert_journal(params[:id])
    render :update do |page|
      page.replace_html 'journals_form', :partial=>'journals/form', :collection=>@slip.editing_journals
      page.visual_effect :highlight, @effect_item.index, :duration=>2
    end
  end

  def copy_row
    @effect_item = @slip.copy_journal(params[:id])
    render :update do |page|
      page.replace_html 'journals_form', :partial=>'journals/form', :collection=>@slip.editing_journals
      page.visual_effect :highlight, @effect_item.index, :duration=>2
    end
  end

  def delete_row
    @effect_item = @slip.delete_journal(params[:id])
    render :update do |page|
      page.visual_effect(:highlight, @effect_item.index, :duration=>2, :startcolor=>"'#666666'")
      page.delay(1) do
        page.replace_html 'journals_form', :partial=>'journals/form', :collection=>@slip.editing_journals
      end
    end  
  end
end

以上で試してみる。しかし...ビジュアルエフェクトは機能しているが、あまり目立たない...。問題は、ハイライトで発光する部分が、id属性で指定したタグの背景色に限定されているからのようだ。(この場合、明細行trタグにはセルやフォームがキッチリ配置されていて、背景がほとんど見えない状態なのだ。)

JavaScriptで行全体を発光するフィードバックにする

それなら、td、th、input、textarea、selectタグの背景も発光するように指定すれば良さそう。しかし、Railsだけで制御しようとすると、すべてのタグをid属性で管理する方法しか思いつかない。それはちょっと面倒くさい...。悩んでいると、prototype.jsには、$$("セレクタ")メソッドがあることに気付く。cssセレクタを指定するように、$$('#j1234 input')とすれば、id属性"j1234"内のinputダグの配列を取得することが出来る。それを利用してみることにする。

  • コントローラー内にpage << "JavaScript"で書いてしまうとコードの見通しが悪くなるので、ヘルパーメソッドhighlight_rowとして定義してみた。
  • $$('セレクタ')メソッドで取得した配列を、ruby側に渡す方法が分からなかったので、すべてJavaScriptでの処理になった。
# ヘルパ: app/helpers/slips_helper.rb
module SlipsHelper
  def highlight_row(page, id, js_options={})
    page << "cells = $$('##{id} th')"
    page << "cells = cells.concat($$('##{id} td'))"
    page << "cells = cells.concat($$('##{id} input'))"
    page << "cells = cells.concat($$('##{id} textarea'))"
    page << "cells = cells.concat($$('##{id} select'))"

    page << "for (i = 0; i < cells.length; i++)"
    page << "new Effect.Highlight(cells[i], #{options_for_javascript(js_options)})"
  end
end


上記JavaScriptは、Rubyと融合させて、以下のように書けることがわかった!

module SlipsHelper
  # 行全体をハイライト処理する
  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
end
  • visual_effect :highlightは、すべてhighlight_rowに置き換えた。
  • ついでに、insert_rowとcopy_rowのrender :update部分が重複しているので、render_insertメソッドを定義してみた。
# コントローラー: 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
    render :update do |page|
      page.replace_html 'journals_form', :partial=>'journals/form', :collection=>@slip.editing_journals
      highlight_row(page, @effect_item.index, :duration=>2)
    end
  end

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

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

  def delete_row
    @effect_item = @slip.delete_journal(params[:id])
    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
      end
    end  
  end
end


以上で、実行してみる。行全体が発光するようになり、追加、削除、コピーの動きがよく分かるようになった!これなら満足。