#213 Calendars (revised)
- Download:
- source codeProject Files in Zip (60.1 KB)
- mp4Full Size H.264 Video (23.4 MB)
- m4vSmaller H.264 Video (12.7 MB)
- webmFull Size VP8 Video (14.8 MB)
- ogvFull Size Theora Video (30.9 MB)
If we want to add a calendar to a Rails application how should we go about it? It depends on what we want the calendar to do. We might want to use one as an alternative way to browse the entires in a blogging application or as a replacement for year, month and day select menus when setting a date value on a form. In this episode we’ll show how to do both of these.
Using a Date Picker
We’ll start by adding a date picker to an application. If we have a form that we want to set a date value on we’ll generally use a date_select
to add three select menus to it. We’ll use jQuery UI to replace this with a date picker so that when the user clicks on a text field that’s set to handle dates a calendar will pop up which can be used to easily set a date. To add this functionality to our application we’ll use the jquery-ui-rails gem
. This needs to be added to the assets
group in the gemfile and we’ll need to run bundle
to install it.
# Gems used only for assets and not required # in production environments by default. group :assets do gem 'sass-rails', '~> 3.2.3' gem 'coffee-rails', '~> 3.2.1' # See https://github.com/sstephenson/execjs#readme for more supported runtimes # gem 'therubyracer', :platforms => :ruby gem 'uglifier', '>= 1.0.3' gem 'jquery-ui-rails' end
We need to include the assets for this and we’ll add these to our app’s JavaScript manifest file.
//= require jquery //= require jquery_ujs //= require jquery.ui.datepicker //= require_tree .
We can select the parts of jQuery UI we want to include so we’ve just included the date picker. We’ll do something similar for our application’s CSS file.
*= require jquery.ui.datepicker *= require_self *= require_tree .
Now that we have jQuery UI installed we can add it to the form below, which is used for editing articles.
We’ll change the published_on
field to a text field and make it a date picker with some JavaScript code. All we need to do is change the date_select
to a text_field
.
<div class="field"> <%= f.label :published_on %><br /> <%= f.text_field :published_on %> </div>
Now we’ll modify the articles
CoffeeScript file to add the code to make this field a date picker. All we need to do is find the text field by its id
when the DOM has loaded and add the date picker functionality to it.
jQuery -> $('#article_published_on').datepicker()
When we reload the page now the select menus have been replaced by a text field and we click it we’ll see the date picker.
If we select August 1st, 2012 as the date then submit the form we’ll see that there’s a problem as the article is now shown as having been published on January 8th, 2012. The issue is with how the date picker formats the date. If we select August 1st from the calendar it sets the value in the text field to 08/01/2012
and Rails will interpret this in mm/dd/yyyy
format. Fortunately jQuery UI supports a number of options that we can pass in the to the date picker including one to change the format so we’ll try setting that.
jQuery -> $('#article_published_on').datepicker dateFormat: 'yy-mm-dd'
Now when we select a date from the date picker the date is sent to the server in a format that Rails will understand and the article will be updated correctly.
A Full-Page Calendar
Now that we’ve got this date picker working correctly we’ll show you another way that we can integrate calendars into our application. Instead of displaying the articles in a list we’ll display a full-page calendar that the user can use to browse them by the date that they were published. There are several gems available for adding calendars to a Rails application and this list on the Ruby Toolbox is a good place to see what’s available. Most of the ones listed there are rather old and not well maintained so we’ll implement our own from scratch.
The index
template currently lists out the articles but we want to display a calendar here instead. We’ll write a calendar
helper method which accepts a block and which we can pass a date. This way the block is executed for each day displayed in the calendar and we can output any information we want such as the current day.
<div id="articles"> <% calendar do |date| %> <%= date.day %> <% end %> </div>
Now we just have to create this calendar helper method. Quite a bit of code is needed to do this but it’s all fairly straightforward.
module CalendarHelper def calendar(date = Date.today, &block) Calendar.new(self, date, block).table end class Calendar < Struct.new(:view, :date, :callback) HEADER = %w[Sunday Monday Tuesday Wednesday Thursday Friday Saturday] START_DAY = :sunday delegate :content_tag, to: :view def table content_tag :table, class: "calendar" do header + week_rows end end def header content_tag :tr do HEADER.map { |day| content_tag :th, day }.join.html_safe end end def week_rows weeks.map do |week| content_tag :tr do week.map { |day| day_cell(day) }.join.html_safe end end.join.html_safe end def day_cell(day) content_tag :td, view.capture(day, &callback), class: day_classes(day) end def day_classes(day) classes = [] classes << "today" if day == Date.today classes << "notmonth" if day.month != date.month classes.empty? ? nil : classes.join(" ") end def weeks first = date.beginning_of_month.beginning_of_week(START_DAY) last = date.end_of_month.end_of_week(START_DAY) (first..last).to_a.in_groups_of(7) end end end
There’s about fifty lines of code here which isn’t bad considering what it does. First we define the calendar
method that we use in the view template. Whenever we have a lot of logic that needs to go inside a helper we should consider moving it into a separate class and defining that class inside the helper module like we’ve done here. Our Calendar
class inherits from a new Struct
which is a convenient way do define accessors for the various attributes. The first attribute here is the view and it’s important to pass this in as we don’t have access to the helper methods in our class. We can easily delegate to the view for any helper methods we want to access in the calendar or maybe override method_missing
in here.
Most of this class is made of methods that generate HTML elements for the calendar table. We generate the header in a method called header
and fill it with the days of the week. Below that we generate each row for a week in week_rows
and generate a cell for each day in that week in the day_cell
method. In day_cell
we capture the block that we passed in to the calendar helper method. This is where the block is executed and we pass the given date to it and a couple of CSS classes. Finally we have a weeks
method to generate an array of weeks and fill it with date objects. This is what we loop through in week_rows
when we generate each row.
The weeks
method is the core functionality of generating a calendar and it’s quite easy to do thanks to ActiveSupport which gives us methods like beginning_of_month
and beginning_of_week
. These make it incredibly easy to generate a calendar. What’s good about this solution is that we can easily customize it to fit the needs of our application. We can add more CSS classes if we need to, change the starting date, add internationalization to the day names or what ever else we want. Best of all it doesn’t have any external dependencies so we can really customize it to fit our application.
Let’s see it in action. When we reload the page now we’ll have an HTML table filled with the days for the current month.
Obviously we want to improve the look of this calendar and we can do this by adding some CSS. There’s too much to show here, but you can find the stylesheet we’re using on Github. When we reload the page now the calendar looks much better.
Our calendar is currently empty. We want to fill it with the articles that were published on each day. To do this we’ll modify the ArticlesController
’s index
action. This currently fetches all the articles, but we want to group them based on the date that they were published.
def index @articles = Article.all @articles_by_date = @articles.group_by(&:published_on) end
This will generate a hash with a key that is the published_on
date. This makes it easy to list those articles out in the relevant cell of our calendar. We’ll display the articles for each day shown in the calendar in a list.
<div id="articles"> <%= calendar do |date| %> <%= date.day %> <% if @articles_by_date[date] %> <ul> <% @articles_by_date[date].each do |article| %> <li><%= link_to article.name, article %></li> <% end %> </ul> <% end %> <% end %> </div>
When we reload the page now the calendar shows the articles on the relevant date.
The last thing we’ll add to this page is something to show what the current month is and also links for swapping between months. To keep track of the current date we’ll add a instance variable to the index
action.
def index @articles = Article.all @articles_by_date = @articles.group_by(&:published_on) @date = Date.today end
We can now display this date in the view. We’ll also pass it into the calendar
helper method so that the calendar displays the month contained in the variable.
<div id="articles"> <h2 id="month"><%= @date.strftime("%B %Y") %></h2> <%= calendar @date do |date| %> <%= date.day %> <% if @articles_by_date[date] %> <ul> <% @articles_by_date[date].each do |article| %> <li><%= link_to article.name, article %></li> <% end %> </ul> <% end %> <% end %> </div>
Now we need to add links on either side of the date so that the user can change the month that’s displayed. We can use ActiveSupport’s prev_month
and next_month
methods to pass the relevant parameters to the two links.
<h2 id="month"> <%= link_to "<", date: @date.prev_month %> <%= @date.strftime("%B %Y") %> <%= link_to ">", date: @date.next_month %> </h2>
Now in the controller we need to check for this date parameter and use it if it’s been passed in.
def index @articles = Article.all @articles_by_date = @articles.group_by(&:published_on) @date = params[:date] ? Date.parse(params[:date]) : Date.today end
When we reload the page now we have links for moving between months.