Ruby on Rails Options
Ruby on Rails Coding Development
November 8, 2022 - Robert C.

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.

YOU MAY ALSO LIKE

Software developer writing code
September 14, 2022 - Robert C.

Bootstrap: Accessibility and the Grid

Ruby on Rails options and configurations
November 1, 2022 - Robert C.

Options and Configurations in Ruby on Rails