2. 8. 2023

Supercharged table component built with ViewComponent

When searching for examples of table components built with the ViewComponent gem, I was surprised to find none. After some inquiries, I came across examples that worked like this:

<%= render Table.new do |table| %>
  <%= table.with_header { "Name" } %>
  <%= table.with_header { "Description" } %>
  <% @products.each do |product| %>
    <%= table.with_row do |row| %>
      <%= row.with_cell { link_to product.name, product } %>
      <%= row.with_cell { product.description } %>
    <% end %>
  <% end %>
<% end %>

This approach is pragmatic, but personally, I prefer to think of tables in terms of columns. So, let’s supercharge our table component to enable something like this:

<%= render TableComponent.new(data: @products) do |table| %>
  table.column("Name") { |product| link_to product.name, product }
  table.column("Description") { |product| truncate(product.description) } 
<% end %>

Introducing the table component

Let’s dive into the implementation:

class TableComponent < ViewComponent::Base
  attr_reader :columns
  def initialize(data:)
    @data = data
    @columns = []
  end

  def column(label, &blockยง)
    @columns << Column.new(label, &block)
  end  

  private

  # By calling content, we ensure that the view component calls the block, and @columns get populated
  def before_render
    content 
  end

  class Column # a value object to hold the column definition
    attr_reader :label, :td_block

    def initialize(label, &block)
      @label = label
      @td_block = block
    end
  end
end  

The corresponding erb file keeps things simple, omitting thead, tbody, or any styling:

<table>
  <% @columns.each do |column| %>
    <th><%= column.label %></th>
  <% end %>
  <% @rows.each do |row| %>
    <tr>
      <% @columns.each do |column| %>
        <td>
          <%= view_context.capture(row, &column.td_block) %>
          <%# the capture ensures, that we do not only return the return of the block, but all the html from the block, see below %>
        </td>
      <% end %>
    </tr>
  <% end %>
</table>

You may notice three peculiarities here that require explanation:

Why not use slots?

The main challenge is that the ViewComponent gem does not have an official way to render a table cell multiple times with different data each time. Meaning you can’t pass that while rendering a slot like this:

<%= @rows.each do |row| %>
  <%= column(row) %>
<% end %>

There is no way to pass arbitrary data from the component (in our example, the rows of the table) to a slot block while rendering it. Therefore, we handle column definitions ourselves instead of using slots.

Using before_render to ensure the block is called

When you use the TableComponent like this, and don’t call content at some point.

<%= render TableComponent.new(data: @products) do |table| %>
  table.column("Name") { |product| link_to product.name, product }
<% end %>

The ViewComponent gem won’t actually call the block, and the column method calls won’t happen. We use the before_render lifecycle method to ensure the block is called.

What is this view_context.capture(row, &column.td_block) doing?

If in erb you define a column like this:

<%= render TableComponent.new(data: @products) do |table| %>
  <%= table.column("Name") do |product| %>
    <div><%= link_to product.name, product } %></div>
    <div><%= truncate(product.description)%></div>
  <% end %>
<% end %>

The block will actually return only the last string, which is </div>. To capture the entire block’s HTML content, we use view_context.capture(row, &column.td_block). This way, we ensure that all the HTML from the block is included.

So there you have it, an easy-to-use but highly flexible table component.

But this is just the beginning. You will be able to create more methods to handle special requirements and create title columns, image columns, and columns with action links.

<%= render TableComponent.new(data: @products) do |table| %>
  table.image_column :image, :featured_image
  table.title_column "Name", &:name
  table.column("Description", &:description)
  table.actions_column(:edit, :destroy)
<% end %>

You have the beginning of an abstraction here, that can be further extended. Stay tuned for more!

This post was written entirely by Roland Studer, but revised with the help of chat gpt.