9

A web app I'm writing in Ruby on Rails has content panels that are used a lot. I'm attempting to write helpers to render these panels with a little cleaner syntax. I'm not very familiar with writing view helpers, so this may be simple. However, when trying to use the "html" (erb code) passed to a helper in a block, I get weird results where the block is rendered twice.

The relevant code is below. This example is using ERB because I simplified it to avoid any possible issues with HAML, but I was getting the same result with HAML.

The HTML that I want to be rendered is:

<section class="panel panel-default">
  <header class="panel-heading">Contacts</header>

  <div class="panel-body">
    <p>Test</p>
  </div>
</section>

This is my helper:

module ApplicationHelper
  def panel(type = :default, &block)
    tag = content_tag(:section, class: "panel panel-#{type.to_s}") do
      block.call PanelHelper.new
    end

    tag
  end

  class PanelHelper
    include ActionView::Helpers::TagHelper
    include ActionView::Context
    include ActionView::Helpers::CaptureHelper

    def header(text = nil, &block)
      if block_given?
        tag = content_tag(:header, block.call, class: 'panel-heading')
      elsif text
        tag = content_tag(:header, text, class: 'panel-heading')
      else
        raise ArgumentError, 'Either a block must be given, or a value must be provided for the text parameter.'
      end

      tag
    end

    def body(&block)
      content_tag(:div, block.call, class: 'panel-body')
    end
  end
end

This is my view:

<%= panel :default do |p| %>
  <%= p.header 'Contacts' %>
  <%= p.body do %>
    <p>Test</p>
  <% end %>
<% end %>

This is the HTML that is rendered:

<section class="panel panel-default">
  <header class="panel-heading">Contacts</header>

  <p>Test</p>
  <div class="panel-body">
    &lt;p&gt;Test&lt;/p&gt;
  </div>
</section>

Any ideas why this is happening and how to fix it? I'm probably just misunderstanding something about the view blocks.

Thanks

EDIT

I am able to get it functioning by using this body method:

def body(&block)
  @helper.concat tag(:div, class: 'panel-body').html_safe
  block.call
  @helper.concat '</div>'.html_safe
end

where @helper is the passed in the PanelHelper initializer as self from the main helper module ApplicationHelper. Also I remove the = when calling p.body because we're writing directly to the buffer.

Max Schmeling
  • 12,363
  • 14
  • 66
  • 109

3 Answers3

7
<%= p.body do %>
  <p>Test</p>
<% end %>

So, <p>Test</p> is appearing twice (kind of) because the first instance is the result of calling yield (or in your case block.call) inside of the body code of the ApplicationModule helper. According to this railscast, blocks in the view work differently than normal blocks, in that yield auto inserts the result of block call into the HTML (I'm still not sure why, but I'm trying to figure it out).

But this can be demonstrated by putting nil at the end of the body function:

def body(&block)
  content_tag(:div, block.call, class: 'panel-body')
  nil
end

will result in <p>Test</p> being placed in the code (the result of block.call, but not the content_tag call).

However, changing block.call to "<p>Test</p>"

def body(&block)
  content_tag(:div, "<p>Test</p>", class: 'panel-body')
  nil
end

will result in nothing being placed in your HTML. So it's the yield/block.call in a view helper which is having some unexpected consequences. So that is essentially why your seeing <p>Test</p> twice.

The solution, you can do what @PrakashMurthy suggested and pass the block to the content_tag like so

def body(&block)
  content_tag :div, class: 'panel-body' do
    block.call
  end
end

This works because your helper is not yielding to the block, instead it's passing it onto a method that doesn't have the same behavior of yield inserting the code into the template. You can also use the capture method, which takes the results of the block and returns it as a string.

content_tag(:div, capture(&block), class: 'panel-body')
JTG
  • 8,587
  • 6
  • 31
  • 38
  • Your answer did help me understand my issue a little better, but your two proposed approaches have the same result... either way block.call is being called and so I get the unexpected output. If i put def body(&block) content_tag :div, class: 'panel-body' do "

    Test

    ".html_safe end end in for the body method, I do get the expected output (though obviously it's not dynamic)... So I need to figure out how to get the contents of the block without it being inserted immediately into the page.
    – Max Schmeling Jan 03 '15 at 07:11
2

Using

<% p.body do %>
  <p>Test</p>
<% end %> 

i.e. <% p.body do %> instead of <%= p.body %> would suppress the first <p>Test</p> in the view.

EDIT:

def body(&block)
  content_tag :div, class: 'panel-body' do
    block.call
  end
end

<%= p.body do %>
  <p>Test</p>
<% end %> 

would give the output you want.

Prakash Murthy
  • 12,923
  • 3
  • 46
  • 74
0

I just hit the same problem and was able to figure it out after looking for a while in the Rails code.

To fix this you need to pass the proper view context. In this case I am passing self from the view helper.

# app/helpers/application_helper.rb

module ApplicationHelper
  def foo_helper(&block)
    FooPresenter.new(self, title: title).render(&block)
  end
end
# app/presenters/foo_presenter.rb

class FooPresenter
  def initialize(view_context)
    @view_context = view_context
  end

  def render(&block)
    @view_context.tag.div(&block)
  end
end
dobrinov
  • 594
  • 4
  • 11