I am writing a helper DSL to make it easier to craft a nice menu ui within a view. The view's erb is producing an error undefined method 'safe_append=' for nil:NilClass when I break the block across multiple erb tags but it works fine if I stick it in one tag. I want to understand why -- it should work across multiple tags and is a lot more natural.

This doesn't work:

          <%= @menu.start do -%>
              <%= menu_item some_path_in_routesrb, 
                  title: "Dashboard", 
                  details: "12 New Updates", 
                  icon: "feather:home",
                  highlight: true 
              %>
              <%= menu_item next_path, 
                  title: "Magical stuff", 
                  details: "unicorn registry", 
                  icon: "fontawesome:rainbow",
                  highlight: true 
              %>
          <% end -%>

But this works:

          <%= @menu.start do 

                  menu_item "#", 
                  title: "Dashboard", 
                  details: "12 New Updates", 
                  icon: "fe:home",
                  first: true,
                  highlight: true 

                  menu_item organizations_path, 
                  title: "Organization", 
                  details: "33k Updates", 
                  icon: "fa:university"

          end -%>

The aforementioned start method for a menu looks like this

    def start(&block)
        if block_given?
            self.instance_eval(&block)
        else
            raise "menu expected a block!"
        end
    rescue => e
        @logger.ap e.message,   :error
        @logger.ap e.backtrace, :error
    ensure                
        if @menu_items.size > 0
            return content_tag(:div, content_tag(:ul, self.display, class: "menu-items"), class:"sidebar-menu")
        else 
            return "There is nothing to render here. Place an item in the menu"
        end
    end 

What am I missing?

3 Answers

1
Danilo Cabello On

The block given to the start function is different when you receive a "erb template" vs "the list of method calls", on the case that works (method calls) this is what is being executed by the Ruby interpreter:

@menu.menu_item("#", 
               title: "Dashboard", 
               details: "12 New Updates", 
               icon: "fe:home",
               first: true,
               highlight: true)
@menu.menu_item(organizations_path, 
               title: "Organization", 
               details: "33k Updates", 
               icon: "fa:university")

Which is valid Ruby.

On the other case you must parse that template string before you attempt to call instance_eval. I do not have the correct implementation answer for you but I would suggest looking at how others do, for example, I know ERB allows for:

<% if @cost < 10 %>
  <b>Only <%= @cost %>!!!</b>
<% else %>
  Call for a price, today!
<% end %>

So I would look at the source code.

The other library that I know that allows this form of construction is liquid by shopify:

<ul id="products">
  {% for product in products %}
    <li>
      <h2>{{ product.name }}</h2>
      Only {{ product.price | price }}

      {{ product.description | prettyprint | paragraph }}
    </li>
  {% endfor %}
</ul>

I would also take a look at how the for loop is implemented in this case by looking at the source code.

Hope that helps you get to the final implementation of your DSL.

1
ErvalhouS On

When you build blocks with <%= %> this means it will print something, what has a similar output as doing <% puts 'something' %>. Since your start method is expecting a block and the return value of a <%= %> block is nil, the exception undefined method 'safe_append=' for nil:NilClass is giving you a hint of what to do.

Change your blocks to just execute the code, so that the returning values get passed into the start method block, like this:

<%= @menu.start do %>
  <% menu_item some_path_in_routesrb, 
    title: "Dashboard", 
    details: "12 New Updates", 
    icon: "feather:home",
    highlight: true 
  %>
  <% menu_item next_path, 
    title: "Magical stuff", 
    details: "unicorn registry", 
    icon: "fontawesome:rainbow",
    highlight: true 
  %>
<% end %>

Also, remove the minus sign in your tags, since it avoid line breaks after the expression.

6
khaled_gomaa On

I tried to find an example of what you are trying to do and found that the closest thing to it is the form_for.

Then I tried to find why would your way not work.

After tracing the execution of code, it seems that the block is trying to render itself assuming it is inside the ActionView::Context instance where it is going to find Context#output_buffer where it finds nil and can't call safe_append on it.

Now how to solve this problem.

You have to make sure that whatever you are trying to render in the view has all the context it needs to render itself which is what Rails does in the form_for

        <%= @menu.start do |m| -%>
          <% m.menu_item some_path_in_routesrb, 
              title: "Dashboard", 
              details: "12 New Updates", 
              icon: "feather:home",
              highlight: true 
          %>
          <% m.menu_item next_path, 
              title: "Magical stuff", 
              details: "unicorn registry", 
              icon: "fontawesome:rainbow",
              highlight: true 
          %>
      <% end -%>

And have this in the menu class

 def start(&block)
        if block_given?
            yield self
        else
            raise "menu expected a block!"
        end
    rescue => e
        @logger.ap e.message,   :error
        @logger.ap e.backtrace, :error
    ensure                
        if @menu_items.size > 0
            return content_tag(:div, content_tag(:ul, self.display, class: "menu-items"), class:"sidebar-menu")
        else 
            return "There is nothing to render here. Place an item in the menu"
        end
    end 

Now the idea of having eval_instance can be done but won't be actually that clean IMHO as it means that you will try to mimic the same behaviour of ERB parsing.