DynamicCalendarHelper と link_to_remote

DynamicCalendar ヘルパーをインストールして、次の月や前の月を XMLHttpRequest を使用して表示する機能を実装するまでのまとめを以下に。
何回やるんだ、と言われてはイカんので、なるべく端折りつつ、という事で以下の 3 つのエントリで ajax を使わない状態な DynamicCalendarHelper の盛り込みはできます。

この状態を前提として、link_to_remote で前月と次月に XMLHttpRequest で画面遷移できる状態にしてみるサンプルを作成する手順を以下に。

config/routes.rb の修正

上記コンテンツでは config/routes.rb の設定に不備があります。最低限、以下に示す map.connect が必要と思われます。(現時点では)

  map.connect "", :controller => 'calendar', :action => 'index', :year => Date.today.year, :month => Date.today.mon
  map.connect 'events/:action/:id', :controller => 'events'
  map.connect ':year/:month', :controller => 'calendar', :action => 'index'
  map.connect ':controller/:action'
  map.connect ':controller/:action/:id'

ajax の盛り込み

DynamicCalendarHelper のデフォルトの状態では、ajax が使えない状態なので、その準備が必要になります。おおまかには以下の 2 点。

  1. app/views/layouts/application.rhtml の修正
  2. scriptaculous な *.js の盛り込み

まず 1. を。

以下に示す二行を app/views/layouts/application.rhtml に挿入します。

        <%= javascript_include_tag 'prototype' %>
        <%= javascript_include_tag 'scriptaculous' %>

コピー後はこんな感じになります。

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html>
<head>
        <title>Calendar Companion</title>
        <%= stylesheet_link_tag 'calendar' %>
        <%= stylesheet_link_tag 'scaffold' %>
        <%= javascript_include_tag 'prototype' %>
        <%= javascript_include_tag 'scriptaculous' %>
</head>

RoR で実現する Ajax アプリ (写経) その 1 というエントリにて script.aculo.us から最新版を co する方法を記述していますので home にオとしておいて下さい。そこから rails プロジェクトの所定の位置にコピーします。(以下の例ではカレントディレクトリは RAILS_HOME とします)

$ cp ~/scriptaculous/lib/*.js todo/public/javascripts/.
$ cp ~/scriptaculous/src/*.js todo/public/javascripts/.

app/helpers/calendar_helper.rb の修正

ajax の盛り込みには DOM オブジェクトにするための id 属性の盛り込みが必要です。以下に calendar メソドの全文を引用。ただし、この時点で追加するのは

  • table タグの直前の div タグ
  • /table タグの直後の /div タグ

の二点のみ。

app/helpers/calendar_helper.rb

  def calendar(options = {}, &block)
    raise ArgumentError, "No year given" unless defined? options[:year]
    raise ArgumentError, "No month given" unless defined? options[:month]

    block                        ||= lambda {|d| nil}
    options[:table_class       ] ||= "calendar"
    options[:month_name_class  ] ||= "monthName"
    options[:other_month_class ] ||= "otherMonth"
    options[:day_name_class    ] ||= "dayName"
    options[:day_class         ] ||= "day"
    options[:abbrev            ] ||= (0..2)

    # initialize range
    first = Date.civil(options[:year], options[:month], 1)
    last = Date.civil(options[:year], options[:month], -1)

    # draw of table
    cal = <<EOF
<div  id="#{options[:table_class]}" >
<table class="#{options[:table_class]}" >
  <thead>
    <tr class="#{options[:month_name_class]}">
      <th colspan="7">#{Date::MONTHNAMES[options[:month]]}</th>
    </tr>
    <tr class="#{options[:day_name_class]}">
EOF
    Date::DAYNAMES.each {|d| cal << "      <th>#{d[options[:abbrev]]}</th>\n"} # day names
    cal << "    </tr>
  </thead>
  <tbody>
    <tr>\n"
    0.upto(first.wday - 1) {|d| cal << "      <td class=\"#{options[:other_month_class]}\"></td>\n"} unless first.wday == 0 # empty cells
    first.upto(last) do |cur|
      cell_text, cell_attrs = block.call(cur) # allow block to render contents of table cells
      cell_text  ||= cur.mday
      cell_attrs ||= {:class => options[:day_class]} # allow user to define attributes of table cells
      cell_attrs = cell_attrs.map {|k, v| "#{k}=\"#{v}\""}.join(' ')
      cal << "      <td #{cell_attrs}>#{cell_text}</td>\n"
      cal << "    </tr>\n    <tr>\n" if cur.wday == 6 # start new table row
    end
    last.wday.upto(5) {|d| cal << "      <td class=\"#{options[:other_month_class]}\"></td>\n"} unless last.wday == 6 # empty cells
    cal << "    </tr>\n  </tbody>\n</table></div>"
  end
end

基本的には ID をカレンダに対して、という修正のみになります。

app/views/calendar/index.rhtml の修正

以下の修正を盛り込みます。

  • link_to を link_to_remote に修正
  • HOME への link_to を修正

上記二点の修正を盛り込んだものを以下に。

app/views/calendar/index.rhtml

<h1>Dynamic Calendar Example</h1>
<table noborder>
<tr><td><%= link_to_remote 'before',
                    { :url => { :controller => 'calendar',
                                :action => 'before'
                              },
                      :update => 'calendar',
                    },
                    :class => 'menu_link' %>

</td><td><%= link_to 'HOME', {  :controller => 'calendar',
                                :action => 'index'
                             },
                    :class => 'menu_link' %>

</td><td><%= link_to_remote 'next',
                    { :url => { :controller => 'calendar',
                                :action => 'after'
                              },
                      :update => 'calendar',
                    },
                    :class => 'menu_link' %>
</td></tr>
</table>
<%= render :partial => 'cal', :locals => { :year => @year,
                                           :month => @month,
                                           :databinder => @databinder
                                         }
%>

menu_link な CSS はパクリ物なんで引用は略。

controller の修正

上記修正にて after と before というアクションを盛り込んでいるので、controller に追加。諸種の事由より全部引用。

app/controllers/calendar_controller.rb

class CalendarController < ApplicationController

private
  def getDatabinder
    events = Event.find(:all)

    lambda do |d|
      cell_text = "#{d.mday}<br />"
      cell_attrs = {:class => 'day'}
      events.each do |e|
        if e.startdate == d
          cell_text << e.name << "<br />"
          cell_attrs[:class] = 'specialDay'
        end
      end
      [cell_text, cell_attrs]
    end
  end

public
  def index
    @year = session[:year] = @params[:year].to_i
    @month = session[:month] = @params[:month].to_i
    @databinder = getDatabinder
  end

  def before
    session[:year] = (session[:month] == 1 ? session[:year] - 1 : session[:year])
    session[:month] = (session[:month] == 1 ? 12 : session[:month] - 1)

    render :partial => 'cal', :locals => { :year => session[:year],
      :month => session[:month],
      :databinder => getDatabinder }
  end

  def after
    session[:year] = (session[:month] == 12 ? session[:year] + 1 : session[:year])
    session[:month] = (session[:month] == 12 ? 1 : session[:month] + 1)

    render :partial => 'cal', :locals => { :year => session[:year],
      :month => session[:month],
      :databinder => getDatabinder }
  end

end

# session[:month] == 1 ではなく session[:month] <= 1 かなぁ。
# ま、いいや。

integration 試験の盛り込み

とりあえずこの時点では、/calendar/index から 前月、次月への遷移が正常であればヨシ、とゆー事で試験としてはこんなモノで良いのでしょうか (まだログインとかないし)。

test/integration/calendar_test.rb

require File.dirname(__FILE__) + '/../test_helper'

class CalendarTest < ActionController::IntegrationTest
  def test_before_next
    open_session do |sess|

      get "/"

      assert_equal assigns(:session)[:year], Date.today.year
      assert_equal assigns(:session)[:month], Date.today.month

      assert_equal 200, status
      assert_template 'index'

      get "/calendar/before/"

      assert_equal assigns(:session)[:year], Date.today.year
      assert_equal assigns(:session)[:month], Date.today.month - 1

      assert_equal 200, status
      assert_template '_cal'

      get "/"

      assert_equal assigns(:session)[:year], Date.today.year
      assert_equal assigns(:session)[:month], Date.today.month

      assert_equal 200, status
      assert_template 'index'

      get "/calendar/after"

      assert_equal assigns(:session)[:year], Date.today.year
      assert_equal assigns(:session)[:month], Date.today.month + 1

      assert_equal 200, status
      assert_template '_cal'

    end
  end
end

自宅と職場でファイルのやりとりするのがマジでツラいので、そろそろ svn に登録したい。(tar.bz2 で 2MB くらいになる)
あ、あとヘルパーの試験についても調べておいた方が良いな。

追記

プレビュー見るに、routes.rb のあたりは理解が微妙だなぁ。