Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why is a block from a view rendered twice?

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.

like image 715
Max Schmeling Avatar asked Jan 02 '15 17:01

Max Schmeling


2 Answers

<%= 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')
like image 73
JTG Avatar answered Nov 14 '22 06:11

JTG


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.

like image 28
Prakash Murthy Avatar answered Nov 14 '22 07:11

Prakash Murthy