DEV Community

Roland Studer
Roland Studer

Posted on

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 %>
Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode

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  
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode

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

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

This post was originally posted on rstuder.ch

Top comments (1)

Collapse
 
jarrett profile image
Jarrett Lusso

There is a mistake in the code above. The TableComponent#initialize method should take a rows parameter and set @rows.