Home Cleaner Code with the "token_list" Helper
Post
Cancel

Cleaner Code with the "token_list" Helper

This article is part of my effort to document some of what I learned during the development of my podshare.fm application. In this article, I want to highlight the token_list helper method.

I’m pleasantly surprised when I come across a new feature or helper method that I’m not familiar with and token_list is one of those seemingly obscure methods that can really help remove conditionals from your view logic. To illustrate this, I’ll create a ViewComponent that wraps around the Bootstrap Alert component (the same principles could be applied to a Flowbite alert component as well.) Additionally, we’ll explore adding some validation and tests to the component.

A Basic ViewComponent

To start, the initial ViewComponent could look like this:

1
2
3
4
<!-- alert_component.html.erb -->
<div class="<%= classes %>" role="alert">
  <%= content %>
</div>

With the component initially emitting hard-coded class names:

1
2
3
4
5
6
# alert_component.rb
class AlertComponent < ViewComponent::Base
  def classes
    "alert alert-info"
  end
end

Rendering the component would be as simple as:

1
2
3
<%= render AlertComponent.new do %>
  Hello, there!
<% end %>

Resulting in:

Initial alert

Adding Different Styles

However, since the Bootstrap component supports a number of alert styles, we’ll allow the desired style to be passed in and add some validation to ensure only valid styles are supplied.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class AlertComponent < ViewComponent::Base
  include ActiveModel::Validations
  attr_accessor :style

  validates :style, inclusion: { in: %i(primary secondary success danger warning info light dark),
                                 message: "is not a valid style" }

  def initialize(style: :info)
    @style = style

    raise ArgumentError, errors.full_messages.join(", ") unless valid?
    
    super
  end

  def classes
    "alert alert-#{style}"
  end
end

Now, the style is interpolated into the string returned from classes. Not a big deal, but when we need to conditionally add classes, it starts to get messy.

Making the Alert dismissable

The component also allows for a “dismissable” button to be added so the user can close the alert. We’ll add a dismissable parameter to enable this feature:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class AlertComponent < ViewComponent::Base
  include ActiveModel::Validations
  attr_accessor :style, :dismissable
  alias_method :dismissable?, :dismissable

  validates :style, inclusion: { in: %i(primary secondary success danger warning info light dark),
                                 message: "is not a valid style" }

  def initialize(style: :info, dismissable: false)
    @style = style
    @dismissable = dismissable

    raise ArgumentError, errors.full_messages.join(", ") unless valid?
    
    super
  end

  def classes
    result = "alert alert-#{style}"
    result << " alert-dismissable" if dismissable?
    result
  end
end

Then in the view:

1
2
3
4
<div class="<%= classes %>" role="alert">
  <%= content %>
  <%= button_tag("", type: "button", class: "btn-close", data: { "bs-dismiss": "alert" }, "aria-label": "Close") if dismissable? %>
</div>

As you can see, we’ve now had to add the conditional to our classes method to only emit the alert-dismissable class if the feature is enabled. The method went from 1 to 3 lines and looks pretty gross.

Cleaning Up with token_list

This is where token_list can help clean things up:

1
2
3
def classes
  token_list("alert alert-#{style}", "alert-dismissable": dismissable?)
end

We can pass any number of string or array arguments along with a hash that contains a boolean for the value. The helper evaluates the boolean to determine if the key should be emitted as part of the final string. So if dismissable? returns true the output would be: alert alert-info alert-dismissable.

Adding Additional Classes

To make this component a little more reusable, it would be nice to be able to pass in some additional classes. To be consistent with other view helpers, I like to be able to pass additional classes in an argument named class. However, because class also Ruby keyword, it needs to be passed in a hash argument.

This would also be a great time to split default_classes and additional_classes to their own methods:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class AlertComponent < ViewComponent::Base
  include ActiveModel::Validations
  attr_accessor :style, :dismissable, :additional_classes

  validates :style, inclusion: { in: %i(primary secondary success danger warning info light dark),
                                 message: "is not a valid style" }

  def initialize(style: :info, dismissable: false, **options)
    @style = style
    @dismissable = dismissable
    @additional_classes = options[:class]

    raise ArgumentError, errors.full_messages.join(", ") unless valid?
    
    super
  end

  def dismissable?
    @dismissable
  end

  def default_classes
    "alert alert-#{style}"
  end

  def classes
    token_list(default_classes, additional_classes, "alert-dismissable": dismissable?)
  end
end

Now we’re passing three arguments to token_list: the default classes, additional classes and the hash with the conditional alert-dismissable class.

If you checkout out the documentation, there is an alias for token_list called class_names. Since we’re using this to generate CSS class names, it might make more sense for us to use the alias:

1
2
3
def classes
  class_names(default_classes, additional_classes, "alert-dismissable": dismissable?)
end

Adding this additional parameter allows me to do something a little more complicated and render an info banner that prompts the user to install the progressive web app. To do this, I need to pass an additional hstack class that uses flex to layout the child elements:

1
2
3
4
5
6
7
8
<%= render AlertComponent.new(style: :info, dismissable: true, class: "hstack") do %>
  <svg class="bi me-2 d-none d-md-inline"><use xlink:href="#info-circle-fill"></use></svg>
  <span class="content">Share directly from your podcast player.</span>
  <%= link_to "#", class: "action btn btn-secondary ms-auto mb-0", data: { action: "install-banner#install" } do %>
    <svg class="bi"><use xlink:href="#download"></use></svg>
    Install Now
  <% end %>
<% end %>

Install banner

Don’t Forget the Tests!

And of course don’t forget to add your tests. For good measure, here’s the spec for this component:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
require "rails_helper"

describe AlertComponent, type: :component do
  let(:additional_classes) { "" }
  let(:dismissable) { false }
  subject(:alert) { AlertComponent.new(dismissable:, class: additional_classes) }

  its(:classes) { is_expected.to eq("alert alert-info") }

  context "given a different style" do
    subject(:alert) { AlertComponent.new(style: :success, dismissable:, class: additional_classes) }
    its(:classes) { is_expected.to eq("alert alert-success") }
  end

  describe "validations" do
    it do
      should validate_inclusion_of(:style)
        .in_array(%i(primary secondary success danger warning info light dark))
        .with_message("is not a valid style")
    end
  end

  context "with additional classes" do
    let(:additional_classes) { "mb-2 hstack" }
    its(:classes) { is_expected.to eq("alert alert-info mb-2 hstack") }
  end

  context "when dismissable" do
    let(:dismissable) { true }
    its(:classes) { is_expected.to eq("alert alert-info alert-dismissable") }

    it "renders the dismiss button" do
      render_inline(subject)
      expect(page).to have_button(class: "btn-close")
    end
  end
end

If you’re using the shoulda-matchers gem as shown in the tests above with the validate_inclusion_of method, you’ll need to make sure these matchers are included for component tests by adding the following line to your rails_helper.rb file:

1
config.include Shoulda::Matchers::ActiveModel, type: :component

Conclusion

Hopefully, you’ll find more uses for token_list (or class_names) to help remove conditionals from your view logic.