Better Rails view helpers, or how to blocks and awesome too
20 Apr 2016
It's nice to have flexible view helpers. There are 3 ways I can think of doing it:
Use A Context :D:D!!! (the gooda way)
Similar to a form builder in Rails, we can use a 'sub view' context.
module InformationCardHelper
Context = Struct.new(:h, :heading_data, :body_data, :overlay_data) do
def overlay(&block)
overlay_data[:content] = h.capture(&block)
end
def heading(&block)
heading_data[:content] = h.capture(&block)
end
def body(&block)
body_data[:content] = h.capture(&block)
end
end
def information_card(type, opts = {}, &block)
context = Context.new(self,
{content: "default content"},
{content: ""},
{content: ""})
opts_css = opts.fetch(:wrapper, {}).fetch(:class, nil)
id = opts.fetch(:wrapper, {}).fetch :id, ""
css = ["#{type}-information-card information-card", opts_css].compact.join " "
capture { block.call context }
content_tag(:div, nil, class: css, id: id) do
capture do
concat content_tag :div, nil, class: "overlay", &-> do
content_tag(:div, context.overlay_data[:content], class: "nested-divs-and-whatnot")
end
concat content_tag:div, nil, class: "text-content", &-> do
capture do
concat content_tag(:div, context.heading_data[:content], class: "card-heading")
concat content_tag(:hr)
concat content_tag(:div, context.body_data[:content], class: "card-body")
end
end
end
end
end
end
And
= information_card "red", class: "awesome" do |c|
- c.overlay do
= link_to "some/other/path"
i.alert-icon>
| Really important overlay!
- c.heading do
= t(".verified_title")
- c.body do
= link_to "some/path" do
= t(".verified_html")
It's understandable, it's flexible, it's encapsulated, it's sexy and I love how ruby lets me do this kind of stuff so easily.
Every problem that the alternatives further down have is addressed.
In information_card there is capture { block.call context }
which calls the main 'do' block containing the context that is passed in, and the calls to overlay, heading etc.
None of the '=' in that block actually prints to the view's output buffer. All of that is caught by capture and thrown out. But it allows the context object to capture each block and store the strings the blocks produce (again via capture).
Unfortunately this is necessary because '=' doesn't return anything, it has a side effect of mutating the 'buffer' that is relevant, which is usually the main view buffer. Capture will wrap up '=' so that it prints into a temporary buffer, and returns that as a string.
Finally we build the output for the method that gets sent to the view buffer via the '=' in = information_card "red", class: "awesome" do |c|
And we play a similar trick to build our content via content_tags via capture. It's possible to do
content_tag(:div, nil, class: css, id: id) do
buf = concat content_tag :div, nil, class: "overlay", do
content_tag(:div, context.overlay_data[:content], class: "nested-divs-and-whatnot")
end
buf += content_tag:div, nil, class: "text-content" do
buf = content_tag(:div, context.heading_data[:content], class: "card-heading")
buf += content_tag(:hr)
buf += content_tag(:div, context.body_data[:content], class: "card-body")
buf
end
buf
end
But i prefer to use capture and concat. Concat will add anything passed to it to the view buffer in scope, so we get it to add to a temporary buffer via capture, which returns the concatenated buffer as it's return value, letting content_tags work proprely.
Incidentally, if you do
concat content_tag :div, nil, class: "stuff" do
"other stuff"
end
ruby will think the block is for concat, NOT content_tag. But we can tell ruby to send a lambda as a block argument to content_tag by using & and ->, ie
concat content_tag :div, nil, class: "stuff", &-> do
"other stuff"
end
which will work well :D
Alternativ: Partials (a sucky way)
= render partial: "some_partial", locals: {alert: alert, type: "red", opts: {class: "awesome"}}
then in _some_partial.html.slim its plausible to do something along the lines of
- type_class = "#{type}-information-card"
- css_class = defined?(opts) && opts.has_key(:class) ? opts[:class] : "default"
- css = [type_class, css_class].join " "
.information_card class= css
.text-content
.card-heading
= alert.title
hr
.card-body
= alert.description
And if you want to do something fancy like throwing in a link in the body you can create a decorator class and throw that into the render partial instead.
class AlertDescriptionDecorator < SimpleDelegator
include ActionView::Helpers::UrlHelper
include ActionView::Helpers::TagHelper
def description
content_tag :div, nil, class: "awesome-looking-link-container" do
link_to super, "some/path"
end
end
end
= render partial: "some_partial", locals: {alert: AlertDescriptionDecorator.new(alert), type: "red", opts: {class: "awesome"}}
Now do that every time you need to pass in something different. This is annoying, veering off course semantically. Why should we have to build a decorator specialized to Alert handle link_to for one use case? It's a small step in an arguably less easy to understand and maintain direction.
Alternative: Naive Helper Methods (a less sucky, still sucky way)
What about
def information_card(type, opts = {}, &block)
opts_css = opts.fetch(:wrapper, {}).fetch(:class, "default")
css = ["#{type}-information-card information-card", opts_css].compact.join " "
content_tag(:div, nil, class: css) do
content_tag(:div, nil, class: "text-content") do
block.call self
end
end
end
def information_card_heading(&block)
content_tag(:div, nil, class: "card-heading", &block) +
content_tag(:hr)
end
def information_card_body(&block)
content_tag(:div, nil, class: "card-body", &block)
end
= information_card "orange-content", class: "awesome" do
= information_card_heading do
= alert.title
= information_card_body do
= alert.body
Now it's trivial for us to do things like
= information_card "orange-content", class: "awesome" do
= information_card_heading do
= alert.title
= information_card_body do
.awesome-looking-link-container
= link_to "some/path" do
= alert.body
So information_card_heading and information_card_body are a bit verbose in naming, we can't define body and heading; imagine if there were 5 different card helpers each needing heading & body. Each module would have def body; ...; end, overriding each other. A little ugly, relying on method names to sort out namespacing issues.
And the 'flexibility' actually kinda sucks, we are coupled!
What if we changed the card one day to have an overlay that needed to be outside the 'text-content' div for css purposes? Something more like
.information_card.red-infomation-card.awesome
.overlay
.nested-divs-and-whatnot
= local_variable_containing_text || "default text"
.text-content
.card-heading
= alert.title
hr
.card-body
= alert.description
You could try to do something like
= information_card "orange-content", class: "awesome" do
= information_card_overlay do
| Some stuff
= information_card_heading do
= alert.title
= information_card_body do
.awesome-looking-link-container
= link_to "some/path" do
= alert.body
Which looks ok but breaks, information_card helper is wrapping all of that stuff in the 'text-content' div. But overlay should be outside.
One way to solve this is to just manually do it;
= information_card "orange-content", class: "awesome" do
= information_card_overlay do
| Some stuff
.text-content
= information_card_heading do
= alert.title
= information_card_body do
.awesome-looking-link-container
= link_to "some/path" do
= alert.body
Which slowly degrades any encapsulation, and requires you to go back and modify each usage of information_card.
Another way is to work out some fancy pants string regex/html parse stuff in the information_card helper to do this automatically which is an activity I hope most people are unwilling to engage in. In this use case it might not be so hard but the practice is not good. We always want to implement a sensible data type and a straight forward routine rather than a dumb data type and a clever (read less understandable and maintainable) routine.
Using a partial more or less solves this, but since the partial has locals being passed to it, any HTML in the overlay content needs to be passed in like so
render partial: 'some_partial', locals: {alert: alert, type: 'red', class: "awesome", overlay: "<div class='ugly'>#{sanitize my_overlay_content}</div>"}
# or
render partial: 'some_partial', locals: {alert: alert, type: 'red', class: "awesome", overlay: (render partial: "complex_overlay")}
Here you pass a string of html, completely throwing away the usefulness of your templating language (slim in this case) or you create yet another file that contains your content.
If I were hard pressed to do it this way, the second option is preferable to me, and I've done this kind of thing before.
But could you imagine having to do it this way for each input in a form, rather than using a form builder? Could you imagine how many partials you'd have to create and keep track of?