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:
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 %>
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.