giant robots smashing into other giant robots

We are thoughtbot. We make web & mobile apps.

Tagged:

Comments (View)

fundamentals: TDD a date helper

I want to format a date range in a view for our training app. This is a job for helpers. A fundamental skill for Rails developers is writing the helper using TDD. This process takes about 10 minutes and results in a confidence-building regression suite.

Working outside-in, we start in the interface:

<%= format_date_range(course.date_range) %>

Tests fail, “format_date_range” doesn’t exist.

script/generate helper date_time

Produces:

# app/helpers/date_time_helper.rb
module DateTimeHelper
end

# test/unit/helpers/date_time_helper_test.rb
require 'test_helper'

class DateTimeHelperTest < ActionView::TestCase
end

Incidentally, former thoughtbot intern Eugene Bolshakov wrote the Rails patch that creates the helper test stub generator.

First case:

should "format date range on same day" do
  eight_oclock = DateTime.new(2009, 10, 12, 8)
  nine_oclock  = DateTime.new(2009, 10, 12, 9)
  date_range   = eight_oclock..nine_oclock
  expected     = "October 12, 2009"

  assert_equal expected, format_date_range(date_range)
end

My style is flat test structure, intention-revealing temporary variables, respect an 80-character line limit.

Make it pass:

def format_date_range(date_range)
  first = date_range.first
  last  = date_range.last
  if same_day?(first, last)
    "#{first.to_s(:month_day)}, #{first.year}"
  end
end

private

def same_day?(date_one, date_two)
  date_one.day == date_two.day
end

More temporary variables. Simple private method is there for a more expressive question in the conditional.

Next case:

should "format date range on different days of same month" do
  monday     = DateTime.new(2009, 10, 12)
  tuesday    = DateTime.new(2009, 10, 13)
  date_range = monday..tuesday
  expected   = "October 12-13, 2009"

  assert_equal expected, format_date_range(date_range)
end

Same pattern. Make it pass:

def format_date_range(date_range)
  # ...
  elsif same_month?(first, last)
    "#{first.to_s(:month_day)}-#{last.day}, #{last.year}"
  end
end

private

def same_month?(date_one, date_two)
  date_one.month == date_two.month
end

Same pattern. Date and time formatting already has a home in config/initializers/time_formats.rb, so we’re using our existing date format, month_day. Here’s what my typical initializer looks like:

{ :short_date  => "%x",                 # 04/13/10
  :long_date   => "%a, %b %d, %Y",      # Tue, Apr 13, 2010
  :longer_date => "%B %d, %Y %H:%M %Z", # April 13, 2010 11:20
  :index       => "%Y/%m/%d %H:%M",     # 2010/04/13 11:20
  :standard    => "%B %d, %Y",          # April 13, 2010
  :month_day   => "%B %d",              # April 13
  :abbr_month  => "%b"                  # Apr
}.each do |key, value|
  ActiveSupport::CoreExtensions::Time::Conversions::DATE_FORMATS.update(
    key => value
  )
end

Final case:

should "format date range on days of different months" do
  october    = DateTime.new(2009, 10, 31)
  november   = DateTime.new(2009, 11, 1)
  date_range = october..november
  expected   = "October 31-November 01, 2009"

  assert_equal expected, format_date_range(date_range)
end

Make it pass:

else
  "#{first.to_s(:month_day)}-#{last.to_s(:month_day)}, #{last.year}"
end

There’s room for improvement: that leading zero sucks. We could use %e instead of %d, which replaces the leading zero with a space:

expected   = "October 31-November  1, 2009"

That makes the test look a little ridiculous, but since the output will be HTML, the extra space is fine.

Make it pass:

:month_day   => "%B %e"

Complete source code.

Sneak peak of the app in progress, taking advantage of the date helper:

Sneak peak of training.thoughtbot.com's new date helper

Tagged:

Comments (View)

The Accept: header vs. #caches_page

In this example we’re going to have an XML API on FontsController#index. A GET to /fonts.xml will produce a list of every Font in the database, along with all its information (name, thumbnail, a list of ligatures, price, license, and so on).

This is a long list. Luckily it’s just for the API consumers. The normal HTML people just request /fonts and this gives them a paginated view of the lovely fonts on our system.

So to speed it up we do some simple caching. In FontsController, at the top, we add this:

caches_page :index, :if => Proc.new {|c|
  c.request.format.xml?
}

And magically requests to /fonts.xml are cached to public/fonts.xml. Lovely! Thanks, Rails!

The Problem

So what happens when someone requests /fonts with an Accept: text/xml header? You can try it like this:

curl -H 'Accept: text/xml' http://ihearthelvetica.local/fonts

No XMLFontsController#index uses #respond_to, so it sends back the XML as requested. However, #caches_page saves the XML to public/fonts.html! Now when a user requests /fonts from their Web browser, they’re getting back a mess of XML!

No good.

The Workaround

This is a problem deep in the Rails caching code. As a workaround, try this on for size:

class ApplicationController < ActionController::Base
  before_filter :fix_caching_extension_for_xml

private

  def fix_caching_extension_for_xml
    if request.format.xml?
      ActionController::Base.page_cache_extension = '.xml'
    end
  end
end

This manually sets the extension for XML requests to .xml, so that it saves it to the right place.

The Fix

The other option is to fix this in Ruby on Rails itself. Download the patch attached to the ticket I’ve opened and apply it to an edge version of Rails. The patch has tests and is more generalized, so if the workaround fails to solve your problem the patch might.

Leave a comment on the Lighthouse ticket if the patch works for you, or if you’ve encountered this problem. Together, we can.