BLOG
Extending the Options Class in Ruby on Rails
In the last post about options and configurations in Rails, we discussed how to use options and configurations to give your code more depth in the design. This is an extension of that, quite literally. We’ll be reviewing how to extend the options class a bit more.
So the goal of the options class was to take all the options and turn them into something useful. We essentially want a user to pass in as little information as needed to generate the correct markup, but we want to set some useful defaults. But those defaults need to be something that can be overridden.
A Ruby on Rails Helper Example
Our old helper alert
can really help here.
def alert_message(text = '', level: 'warning', header: nil, icon: true, dismissible: true, &block)
alert = BootstrapHelpers::Alert.new(self)
icon = alert.generate_icon(icon, level)
icon = nil unless block.nil?
content = capture(alert, &block) unless block.nil?
bs_render template: 'alert/alert', content: content, locals: { icon: icon, dismissible: dismissible, level: level, header: header, content: content, text: text }
end
This method isn’t too bad, but we have a lot of logic in the method declaration it’s self. We also don’t allow any arbitrary options either. Let’s clean that up a bit.
def alert_message(text = '', options = {}, &block)
options = BootstrapHelpers::Options.new(options)
level = options.extract(:level, 'warning')
header = options.extract(:header)
icon = options.extract(:icon, true)
dismissible = options.extract(:dismissible, true)
alert = BootstrapHelpers::Alert.new(self)
icon = alert.generate_icon(icon, level)
icon = nil unless block.nil?
content = capture(alert, &block) unless block.nil?
bs_render template: 'alert/alert', content: content, locals: { options: options, icon: icon, dismissible: dismissible, level: level, header: header, content: content, text: text }
end
There now the method declaration is cleaner, of course the method is much more verbose, and were passing a ton of things into the template. We should fix that too.
def alert_message(text = '', options = {}, &block)
unless block.nil?
options.merge! text if text.is_a? Hash
options[:icon] = false
text = nil
end
alert = BootstrapHelpers::Alert.new(self)
bs_render template: 'alert/alert', klass: alert, block: block, options: options, locals: { text: text }
end
This makes for a much cleaner method. For now, the unless the block is used to help set different defaults if a block is provided. Essentially if your using a block then the default icon markup is not likely to work so that we will leave that to your block.
<%= alert_message 'You should really read this alert.' %>
The important part is that we can call a message like this (or much more complicated examples in the todo application repo) and get markup like this:
<div style="" class="alert alert-warning fade show alert-dismissible " id="1568c8b6-bb81-44e4-a919-19c3a01b20e8" role="alert">
<p>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-exclamation-triangle-fill" viewBox="0 0 16 16">
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"></path>
</svg>
You should really read this alert.
</p>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
This is reduction in complexity is what were after. All without having to create a totally custom options parser. It just takes the right options and works.
Modifications to the Options Class
We had to make a few modifications to the options class. Nothing major but let’s go over them.
def extract(key, default: nil)
return instance_variable_get("@#{key}") unless instance_variable_get("@#{key}").nil?
return default unless @named_args.key? key
value = @named_args.delete key
instance_variable_set("@#{key}", value)
return default if value.nil?
value
end
What is interesting about this class is the fact that we set an instance variable then delete the key/value pair from the @named_args hash. Because named_args uses the double spat (**), everything gets stuffed into that hash. We may not want those options passed along to the parser and turned into css classes.
For some bonus points, why are these two lines not the same?
return instance_variable_get("@#{key}") unless instance_variable_get("@#{key}").nil?
return instance_variable_get("@#{key}") unless instance_variable_get("@#{key}")
The second line will create a subtle bug, and is a good example of the dangers of a guard clause used incorrectly.
def to_s(include_classes: [])
process unless @processed
include_classes = include_classes.join(' ') if include_classes.is_a? Array
"style=\"#{styles.join(';')}\" class=\"#{include_classes} #{classes.join(' ')}\" id=\"#{id}\"".html_safe
end
def process
@processed = true
parse_out_classes(@named_args)
end
These two together make a kind of semaphore. Essentially making sure that
parse_out_classes
is only called one time. That essentially makes to_s
the end of the call chain. This has the effect of removing some of the processing from the parse_out_classes
. It’s not perfect, but it helps reduce a ton of double processing. Anything that might be left over can be excluded from the BootstrapHelpers.configuration.excluded_attributes
configuration directive if need be. For example:
options = BootstrapHelpers::Options.new({class: 'dog cat mouse animal', size: 12, lg: {display: false}, sm: {display: true}, icon: true, small: 'big', food: 'pizza'})
options.to_s
# => style="" class=" col-12 d-lg-none d-sm-block small-big food-pizza dog cat mouse animal" id="635fd820-7c8f-4708-befa-5bf41555359b"
# notice the food and small css classes that came out. This is useful but not
# in this case, and it may cause a problem for css classes to get assigned like
# that
options = BootstrapHelpers::Options.new({class: 'dog cat mouse animal', size: 12, lg: {display: false}, sm: {display: true}, icon: true, small: 'big', food: 'pizza'})
food = options.extract(:food, default: 'pie')
small = options.extract(:small, default: false)
options.to_s
# => style="" class=" col-12 d-lg-none d-sm-block dog cat mouse animal" id="ec6cc076-7cd8-4f34-a8b5-2e56c0b8ef99"
# because we used these options (food and small) we know not to pass them along
# as classes.
There are other ways to accomplish the same thing, but this keeps us from processing many times for the same thing. Remember, most of the calls will use the options class, so saving time like this is easy and seems useful. We don’t want to over-optimize though. This is a good compromise.
Ideas for Next Time
The only thing I don’t like that much is the semaphore. While it doesn’t exactly make a secret handshake of calls, it does change the output depending on the order in which you call to_s
and extract
. That said the only way to ‘fix’ it is either whitelist keys, or make the Options
class aware of what attributes its caller may think are protected.
The first is not too bad, but the second totally defeats the purpose of the class. I will have to decide rather to white list, live with it, or come up with another way to tell the Options
class that some named arguments don’t belong in the to_s method.