Tuesday, March 21, 2006

Rails 's Date Widget Plugin

ปกติใน rails, ถ้าเราต้องการ entry ข้อมูลที่เป็น Date
rails จะมี helper ที่ชื่อ date_select

date_select("period", "start_date")

ซึ่งได้หน้าตาของ UI ออกมาอย่างนี้



ดูแล้วไม่ถูกใจ user ชาวไทยอย่างแน่นอน
ผมก็เลยหาทางเปลี่ยนไปใช้ jsCalendar แทน
หน้าตาที่ได้ ก็จะเปลี่ยนไปเป็นแบบนี้



โดย jsCalendar มีวิธีการใช้ง่ายๆดังนี้
<input type="text" id="data" name="period[dp_start_date]"/>
<button id="trigger">...</button>
<script type="text/javascript">
Calendar.setup(
{
inputField : "data",
ifFormat : "%d/%m/%y",
button : "trigger"
}
);
</script>


ที่นี้ ถ้าอยากนำมาใช้ใน rails
จะทำอย่างไรให้ดูเป็น rails-style
(เขียนน้อยๆ, default เยอะๆ)

ประเด็นของการทำ widget เอง คงแยกเป็น 2 เรื่อง คือ
  • การ render
  • การ parse request parameter


กรณีของการ render เราจะทำ helper method ขึ้นมาใหม่ตัวหนึ่ง
ให้ชื่อว่า date_picker
มีลักษณะการใช้งานดังนี้
<%= date_picker "period", "start_date" %>

(จะเห็นว่ามีวิธีการใช้ที่เหมือนกับ date_select ของ Rails เลย)

ส่วนการ parse parameter นั้น
จะออกแบบให้ transparent กับ code เดิม
โดยต้องการให้ใช้กับ code ที่ gen จาก scaffold ได้เลย
(code ที่อยู่ใน controller class)

ของเดิม เวลา date_select submit ข้อมูลกลับมา
ข้อมูลที่กลับมาจะอยู่ในรูปแบบนี้

param-name param-value
===========================
start_date(1i) 2006
start_date(2i) 03 (เดือน)
start_date(3i) 20 (วัน)

เราก็จะเลียนแบบวิธีการส่ง parameter แบบเดิมนี้ (rails เรียกว่า multiparameter_attributes)
โดยแทรก filter เข้าไป
เพื่อทำการดักแปลงข้อมูลที่ submit มาจาก form

การใช้งาน filter ออกแบบให้ใช้คำสั่งแบบนี้้
class ApplicationController < ActionController::Base
date_picker_filter
end


เมื่อตกลงใจเรื่อง design ได้แล้ว ก็มาถึงคำถามว่า
จะ implement เป็น plugin ได้อย่างไร

เริ่มด้วยการตั้งชื่อให้ plugin เราก่อน
โดยใช้ชื่อว่า datepicker

directory structure ของ plugin เป็นแบบนี้

+project-name
+app
+...
+vendor
+plugins
+datepicker
+lib
datepicker.rb
init.rb

file init.rb เป็น file ที่จะถูก rails เรียกใช้
มีเนื้อหาดังนี้

require 'datepicker'
ActionController::Base.send :include, DatePicker

การทำงานก็คือ สั่งให้ Class ActionController::Base include
Module Datepicker ของเรา (Mixin)

ใน module Datepicker
จะเริ่มด้วย method self.included
method นี้เป็น callback method
ซึ่งจะถูกเรียกใช้เมื่อมีการเรียก include module ของเรา

logic ที่ Datepicker ทำ ก็คือ
  • สั่ง extend controller ด้วย module ClassMethods
    add class method date_picker_filter ให้เรียกใช้จาก controller ได้
  • เพิ่ม helper date_picker ที่เรียกใช้จากใน view หรือใน controller ได้

module DatePicker

def self.included(controller)
controller.extend(ClassMethods)
controller.helper_method(:date_picker)
end

module ClassMethods
def date_picker_filter(options = {})
before_filter do |c|
ParamUpdator.new(options).update(c.params)
end
end
end

...

def date_picker(object, method, options = {})
...
end


ใน method date_picker_filter
จะมีการ add filter โดยใช้คำสั่ง before_filter)
และใช้ class ParamUpdator ในการ scan และแปลงวันที่ที่อยู่ใน params object

class ParamUpdator
def initialize(options={})
@prefix = options[:prefix] || 'dp_'
@be = options[:ad] || false # buddhist era
@delim = options[:delim] || '/'
@prefix_regexp = Regexp.new("^#{@prefix}")
@delim_regexp = Regexp.new(@delim);
end

def update(hash)
hash.each do |key, value|
if value.class.to_s =~ /^Hash/
update(value)
else
if @prefix_regexp =~ key
update_param(hash, key, value)
end
end
end
end

def update_param(hash, key, value)
darys = parseDate(value)
realName = key[(@prefix.length)..(key.length)]
hash["#{realName}(1i)"] = darys[0]
hash["#{realName}(2i)"] = darys[1]
hash["#{realName}(3i)"] = darys[2]
hash.delete(key)
end

def parseDate(value)
darys = value.split(@delim_regexp);
if darys[2].length <= 2
darys[2] = (darys[2].to_i + (@be ? 1957 : 2000)).to_s
end
darys.reverse
end

end #end ParamUpdator

ประเด็นที่น่าสนใจในส่วนของการแปลง parameter ก็คือ
object params ไม่ได้เป็น instance ของ Hash
แต่เป็น instance ของ HashWithIndifferentAccess

ส่วน date_picker ที่ใช้ render ก็มีหน้าตาแบบนี้

def date_picker(object, method, options = {})
prefix = options[:prefix] || "dp_"
format = options[:format] || "%d/%m/%y"
size = options[:size] || "10"
inputClazz = options[:input_class] || "date_input"
triggerClazz = options[:trigger_class] || "date_trigger"
be = options[:be] || false

maxsize = options[:maxsize] || format.length
id = "#{object}_#{method}"
name = "#{object}[#{prefix}#{method}]"
obj = self.instance_variable_get "@#{object}"
value = obj == nil ? "" : (date_to_string(format, obj.send(method), be))

<<EOS
<input type="text" size="#{size}" maxsize="#{maxsize}"
id="#{id}" name="#{name}"
class="#{inputClazz}" value="#{value}"/>
<button id="trigger_#{id}" class="#{triggerClazz}">...</button>
<script>
Calendar.setup(
{
inputField : "#{id}",
ifFormat : "#{format}",
button : "trigger_#{id}"
}
);
</script>
EOS

end

ประเด็นที่น่าสนใจ ก็คือการ get value จาก Model instance
ที่ใช้ method instance_variable_get
กับ assumption ที่ว่า model instance จะอยู่ในรูป instance variable

เหลือที่ยังไม่ได้ทำ ก็คือ ส่วนของการ validate
ที่จะ implement ด้วย javascript ที่ฝั่ง browser เลย

Related link from Roti

1 comment:

Anonymous said...

Hi,

I was wondering if you could post an English translation of the text about Rails' JavaScript-based DatePicker.

Just looking at the code examples gets me very far, but not far enough...

Thank you very much!

Onno