Tables in HTML are defined row-wise. It is often annoying having to define a column’s header separate from the values.

For example, in a Rails view, if we use some logic to exclude a column we must repeat the logic for the header and the values.

<table>
  <tr>
    <th>Name</th>
    <% unless no_dates %>
      <th>Date</th>
    <% end %>
  </tr>
  <% for record in @records %>
    <tr>
      <td><%= record.name %></td>
      <% unless no_dates %>
        <td><%= record.date %></td>
      <% end %>
    </tr>
  <% end %>
</table>

But, since we’re not really writing HTML; we’re generating it with Ruby, we can arrange things in a better way. We can put some lambdas to good use here and take advantace of the nice Ruby syntax to write our table as:

<%= table_by_columns(@records) do %>
  <% table_column('Name') { |record| record.name } %>
  <% table_column('Date') { |record| record.date } unless no_dates %>
<% end %>

To achieve this convenient syntax we just need a couple of helpers. We define table_by_columns passing a block to it where table_column calls are issued to define the table columns.

To define the column values a block is passed which receives the record for which the column value is to be generated.

The data for each column is kept and used by table_by_columns to generate the table after the block executes.

module TableByColumnsHelper

  def table_by_columns(records, *cls, &blk)
    raise "Tables cannot be nested" unless @table_listing_columns.nil?
    @table_listing_columns = []
    blk.call
    columns = @table_listing_columns
    @table_listing_columns = nil
    if columns.present?
      content_tag :table, class: cls do
        content_tag :tr do
          columns.each do |header, value_method|
            concat content_tag(:th, header)
          end
        end
        records.each do |record|
          concat(content_tag(:tr) do
            columns.each do |header, value_method|
              concat content_tag(:td, value_method[record])
            end
          end)
        end
      end
    end
  end

  def table_column(header, &blk)
    raise "table_column out of table_by_columns block" if @table_listing_columns.nil?
    @table_listing_columns << [header, blk]
  end

end

Ugh, did you notice the odd parentheses around the second :tr?

That’s a glitch of Ruby’s otherwise nice syntax. (without them the do would have been attached to concat; we could have used curly braces for the block, but that’s inconsistent with the rest of the code) We’ll do something about it in the next snippets.

Now that we have this nice helpers we can have even more convenience easily. Say that when consecutive rows repeat the same value for a column we’d prefer to have a merged single cell for them using rowspan.

Instead of:

Table with repeating values

We might prefer:

Table with merged cells

We can solve this with our helpers by adding some option to the columns we want to merge in that way:

<%= table_by_columns(@records) do %>
  <% table_column('Name', grouping: true) { |record| record.name } %>
  <% table_column('Date') { |record| record.date } %>
<% end %>

The problem to do this is we cannot generate a td until we know how many consecutive rows will have the same value. We also prefer not to read the whole collection and keep data around, since there may be a lot of records.

An easy way to solve this is using this look_ahead_iterator gem that allows us to prefetch values from an enumerable. The LookAheadIterator acts as an iterator wrapper that caches prefetched values. In the worst case we could end up prefetching the whole collection, but in typical use we expect to need caching only a few values.

Here’s the code to implement this cell-merging. I’ll throw in that concat_content_tag method to get rid of those odd parentheses we encounter in our first try!

module TableByColumnsHelper

    def concat_content_tag(*args, &blk)
      concat content_tag(*args, &blk)
    end

    def table_by_columns(records, *cls, &blk)
      raise "Tables cannot be nested" unless @table_listing_columns.nil?
      options = cls.extract_options!
      @table_listing_columns = []
      blk.call
      columns = @table_listing_columns
      @table_listing_columns = nil
      nested_grouping = options[:nested_grouping]
      if columns.present?
        column_grouping = columns.any? { |h, v, options, row_count| row_count }
        content_tag :table, class: cls do
          content_tag :tr do
            columns.each do |header, value_method, options, row_count|
              concat_content_tag :th, header
            end
          end
          records = LookAheadIterator.new(records, stop: true) if column_grouping
          records.each do |record|
            concat_content_tag :tr do
              columns.each do |column|
                header, value_method, options, num = column
                value = value_method[record]
                if num.nil?
                  concat_content_tag :td, value
                else
                  # grouping column
                  num -= 1
                  if num == 0
                    loop do
                      num += 1
                      other_record = records.look_ahead(num)
                      other_value = other_record && value_method[other_record]
                      break if !other_value || other_value != value
                    end
                    concat_content_tag :td, value, rowspan: num
                  end
                  column[3] = num
                end
              end
            end
          end
        end
      end
    end

    # Passing the option +grouping: true+ to a column
    # causes its rows to be grouped (with colspan).
    def table_column(header, options = {}, &blk)
      raise "table_column out of table_by_columns block" if @table_listing_columns.nil?
      row_count = options[:grouping] ? 1 : nil
      @table_listing_columns << [header, blk, options, row_count]
    end

  end

We had to complicate a bit those helpers, but our views are still nice and clean.

There’s still one thing we can improve, though. Let’s say we’re using our cell-merging in two columns:

<%= table_by_columns(@records) do %>
  <% table_column('Name', grouping: true) { |record| record.name } %>
  <% table_column('Date', grouping: true) { |record| record.date } %>
<% end %>

This could result in something like this:

Table with uneven rows

This may not be what we want. Usually, specially if columns appear in sorting order, we’d prefer merged cells not to run across preceding column’s cells:

Table with uneven rows

We’ll add an option to avoid that case:

<%= table_by_columns(@records, nested_grouping: true) do %>
  <% table_column('Name', grouping: true) { |record| record.name } %>
  <% table_column('Date', grouping: true) { |record| record.date } %>
<% end %>

And finally we’ll fix our helpers to support the new option:

module TableByColumnsHelper

    def concat_content_tag(*args, &blk)
      concat content_tag(*args, &blk)
    end

    def table_by_columns(records, *cls, &blk)
      raise "Tables cannot be nested" unless @table_listing_columns.nil?
      options = cls.extract_options!
      @table_listing_columns = []
      blk.call
      columns = @table_listing_columns
      @table_listing_columns = nil
      nested_grouping = options[:nested_grouping]
      if columns.present?
        column_grouping = columns.any? { |h, v, options, row_count| row_count }
        content_tag :table, class: cls do
          concat_content_tag :tr do
            columns.each do |header, value_method, options, row_count|
              concat_content_tag :th, header
            end
          end
          base_nested_max_num = nil
          records = LookAheadIterator.new(records, stop: true) if column_grouping
          records.each do |record|
            concat_content_tag :tr do
              nested_max_num = base_nested_max_num
              columns.each do |column|
                header, value_method, options, num = column
                value = value_method[record]
                if num.nil?
                  concat_content_tag :td, value
                else
                  # grouping column
                  num -= 1
                  if num == 0
                    loop do
                      num += 1
                      other_record = records.look_ahead(num)
                      other_value = other_record && value_method[other_record]
                      break if !other_value || other_value != value
                    end
                    num = [num, nested_max_num].min if nested_max_num
                    concat_content_tag :td, value, rowspan: num
                  end
                  column[3] = num
                  nested_max_num = [nested_max_num || num, num].min if nested_grouping
                end
              end
            end
            base_nested_max_num -= 1 if base_nested_max_num
          end
        end
      end
    end

    # Passing the option +grouping: true+ to a column
    # causes its rows to be grouped (with colspan).
    def table_column(header, options = {}, &blk)
      raise "table_column out of table_by_columns block" if @table_listing_columns.nil?
      row_count = options[:grouping] ? 1 : nil
      @table_listing_columns << [header, blk, options, row_count]
    end

  end

Here’s is a more complete example extracted from actual production code (from an old app of mine): https://gist.github.com/jgoizueta/3558501b02f4448b11e60fc9d41666d9