DEV Community

Jeffrey Hicks
Jeffrey Hicks

Posted on

Builder Pattern

Some years ago I wrote page_for a UI generation library that lets you do stuff like

= table_for @products do |t|
  - t.col :name
  - t.col :price
  - t.col :popularity do |product|
     = product.likes.count
Enter fullscreen mode Exit fullscreen mode

While I've loved this library for years, it's a bit outdated

  • Uses bootstrap instead of tailwind

  • Uses jquery instead of Turbo 8

I'm going to use this blog post to collect my research on how I could approach this problem again, but possibly using components instead of helpers and partials.

Research

<%= render TabsComponent.new do |component| %>
  <% component.capture_for :tabs, "Tab 1" %>
  <% component.capture_for :tabs, "Tab 2" %>
  <% component.capture_for :tabs, "Tab 3" %>

  <% component.capture_for :panels do %>
    Tab panel 1
  <% end %>
  <% component.capture_for :panels do %>
    Tab panel 2
  <% end %>
  <% component.capture_for :panels do %>
    Tab panel 3
  <% end %>
<% end %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

Results

I ended up going with no dependencies like ViewComponents. Instead, I took inspiration from JumpStart Pro's ideas.

Here is a tab component used in a slim view:

= render TabsBuilder.new do |t|
  - t.tab "Tab 1" do
    | Hello
  - t.tab "Tab 2" do
    | World
Enter fullscreen mode Exit fullscreen mode

Here is the meat of the TabsBuilder Component. I call them Builders instead of Components.

class TabsBuilder < ApplicationBuilder
  attr_accessor :tabs
  before_filter :set_selected

  def initialize
    @tabs = []
  end

  def tab(title, &block)
    tab_key = key_for(title)
    @tabs << OpenStruct.new(
      title: title,
      content: lambda { capture(&block) },
      tab_key: tab_key,
      selected: false,
      query_params: query_params(tab_key)
    )
  end

  def set_selected
    @tabs.each do |tab|
      tab.selected = tab.tab_key == selected_key
    end
  end

  # ...
Enter fullscreen mode Exit fullscreen mode

Here is the full ApplicationBuilder base class.

  • We get the precious view_context when in the view above we call render TabsBuilder.new. Behind the scenes, rails calls render_in on that new object and passes the view_context.

  • Some conveniences are added so we can find the partial by convention

  • I redacted the before_filter functionality for brevity.

class ApplicationBuilder
  attr_accessor :view_context

  # Triggered by Rails' render call
  def render_in(view_context, &block)
    @@before_filters.each { |method| send(method) }
    @view_context = view_context
    block.call(self)
    render
  end

  def capture(&block)
    @view_context.capture(&block)
  end

  def render
    @view_context.render partial_path, builder: self
  end

  def partial_path
    "builders/#{builder_path}"
  end

  def builder_path
    self.class.name.delete_suffix("Builder").underscore
  end
end
Enter fullscreen mode Exit fullscreen mode

And finally here is the partial in /view/builders/_tabs.html.slimI

  • Styling removed for brevity.
- builder.tabs.each do |tab|
  =link_to tab.title, tab.query_params

= builder.selected_tab.content.call
Enter fullscreen mode Exit fullscreen mode

I like how I was able to defer rendering the content of a tab until it's known that it was selected. Lazy.

Top comments (0)