Generating dynamic child rows in Rails partial
Asked Answered
I

3

7

I have implemented a nested attribute form using the nested attributes Railscast as a guide. As a result, the user can click an icon to dynamically add "child" rows to my view.

Unfortunately, I can only make this work for the last icon in my view (illustrated here). This icon is generated in my view, but the others are generated in the partial which is used to render each row.

Is it possible to do this? If so, what is the best approach?

Here is my latest attempt.

Sheet has_many Slots. In the sheet edit view, I use a sheet form builder (sheet) to render my slot partial and also pass it to a helper link_to_add_fields which renders a link which will generate a new row when clicked (this part works fine). You'll notice I am also attempting to pass sheet to the partial so that I can call link_to_add_fields from there but this is where it breaks down:

The view - edit.html.haml:

= sheet.fields_for :slots do |builder|
  = render 'slots/edit_fields', f: builder, sheet:sheet
= link_to_add_fields image_tag("plus.jpg", size:"18x18", alt:"Plus"), sheet, :slots, 'slots/edit'

The partial - _edit_fields.html.haml:

- random_id = SecureRandom.uuid
.row.signup{:id => "edit-slot-#{random_id}"}
  .col-md-1
    %span.plus-icon
      = link_to_add_fields image_tag("plus.jpg", size:"18x18", alt:"Plus"), sheet, :slots, 'slots/edit'
    %span.minus-icon
      = image_tag "minus.jpg", size:"18x18", alt:"Minus"
  .col-md-2= f.text_field :label
  ... other fields ...

The helper method:

def link_to_add_fields(name, f, association, partial)
  new_object = f.object.send(association).klass.new
  id = new_object.object_id
  fields = f.fields_for(association, new_object, child_index: id) do |builder|
    render(partial.to_s.singularize + "_fields", f: builder, name: name)
  end
  link_to(name, '#', class: "add_fields", data: {id: id, fields: fields.gsub("\n", "")})
end

I get undefined local variable or method 'sheet' on the call to the helper from the partial. Basically, I need the sheet (parent) form builder to be available on each link for the helper to work. Or I need to give up on this approach and use AJAX (also tried that).

UPDATE

After debugging a bit, it is clear that sheet is getting passed down to the partial. The root issue is that I seem to be setting up an endless recursion:

  1. Partial invokes link_to_add_fields so that my + icon can serve as the "add child" link.
  2. link_to_add_fields renders the partial so that the fields can be generated when the + icon is pressed.

The other issue I am running into is that when the original children are rendered, they get sequential indexes in the attribute collection (0, 1, 2,...). So, even if I figure out a way to render new child rows among the originals, I'm not sure how I will be able to maintain the order of children when the form is submitted without a lot of jQuery gymnastics or something.

Icaria answered 8/5, 2015 at 14:17 Comment(12)
Can you share your partial code and js you have written for click of add button. I am feeling some problem is there only.Garfish
OK I've added code but I am not at all certain this is the right approach.Icaria
I think i get what you are trying to do. Not posting this as an answer, because its not. Maybe just keep the last + button, but add a way of positioning the elements? Like: jqueryui.com/sortable I believe this will solve your problem and add more functionality. I've done it manually in the past. But there is a Rails cast: railscasts.com/episodes/147-sortable-listsOubliette
Thanks @DickieBoy. I took a look at the Railscast and the jQuery link. This is definitely an interesting option but I would like to make my UI work as shown if possible. This is a prod app (written in PHP) and I don't want to confuse my users when I port to RoR.Icaria
Do your partials have a prepended underscore in their file names? Is edit_fields.haml.html actually named _edit_fields.haml.html? Because it needs to be in in order for rails to handle it correctlyPositive
Thats annoying. Just so i'm clear. The + buttons add a row but it adds it at the bottom? And you want it in a row above the + clicked? If so, hook into the nested:fieldAdded js event that is fired from the gem. You should be able to get the target of the event. It's index-1 is where you want the new row which will be the last row in the list.Oubliette
No, only the + button at the end of the list works (the one rendered from the edit view). I can't make the ones from inside the partial work at all. Also, when a row is added using that + button, the + in that new row doesn't work.Icaria
@Positive yes they do. Typo fixed above.Icaria
@steveklein ah, ok I see the problem. The gem is designed so that there is only one button that adds a new row and is outside of the fields_for block. You could hack around it so that those buttons trigger the single button the gem is expecting. You can then use the index of the button clicked to insert your new row.Oubliette
What gem are you referring to? I basically need a way to build a link which, when clicked, will render a new row above it and the new row needs to start with the same type of link.Icaria
Sorry, I was under the assumption you were using: github.com/ryanb/nested_form my bad. Ryan(the guy who does the railscast) built it after those railscasts became popular. I also thought he mentions it in those railscasts. I would use it, it makes it a lot easier to do the things you are doing.Oubliette
I've looked through this but not sure it addresses my use case. To clarify, I have Ryan's Nested Model Form working perfectly but want to be able to generate new child rows from child row links (the + buttons).Icaria
I
4

I wound up solving this with jQuery. Not super elegant but very effective. Basically, I just added a click handler for the + icons which built the new row and inserted it where needed (basically just hacked out the jQuery necessary to replicate the HTML produced by Rails). In case my use case helps someone else in the future, here is the jQuery (see imgur link in original post for reference):

//click handler to add edit row when user clicks on the plus icon
$(document).on('click', '.plus-icon', function (event) {

  //build a new row
  var one_day = 24 * 3600 * 1000;
  var d = new Date();
  var unique_id = d.getTime() % one_day + 10000;            // unique within a day and offset past original IDs
  var new_div =
    $('<div/>', {'class': 'row signup'}).append(
      $('<div/>', {'class': 'col-md-1'}).append(
        $('<span/>', {'class': 'plus-icon'}).append(
          '<img alt="Plus" src="/assets/plus.jpg" width="18" height="18" />'
        )
      ).append(
        $('<span/>', {'class': 'minus-icon'}).append(
          '<img alt="Minus" src="/assets/minus.jpg" width="18" height="18" />'
        )
      )
    ).append(
      $('<div/>', {'class': 'col-md-2'}).append(
        '<input type="text" value="" name="sheet[slots_attributes]['+unique_id+'][label]" id="sheet_slots_attributes_'+unique_id+'_label">'
      )
    ).append(
      $('<div/>', {'class': 'col-md-2'}).append(
        '<input type="text" value="" name="sheet[slots_attributes]['+unique_id+'][name]" id="sheet_slots_attributes_'+unique_id+'_name">'
      )
    ).append(
      $('<div/>', {'class': 'col-md-2'}).append(
        '<input type="text" value="" name="sheet[slots_attributes]['+unique_id+'][email]" id="sheet_slots_attributes_'+unique_id+'_email">'
      )
    ).append(
      $('<div/>', {'class': 'col-md-2'}).append(
        '<input type="text" value="" name="sheet[slots_attributes]['+unique_id+'][phone]" id="sheet_slots_attributes_'+unique_id+'_phone">'
      )
    ).append(
      $('<div/>', {'class': 'col-md-2'}).append(
        '<input type="text" value="" name="sheet[slots_attributes]['+unique_id+'][comments]" id="sheet_slots_attributes_'+unique_id+'_comments">'
      )
    );
  var new_input = 
    '<input id="sheet_slots_attributes_'+unique_id+'_id" type="hidden" name="sheet[slots_attributes]['+unique_id+'][id]" value="'+unique_id+'">';

  //insert new row before clicked row
  $(new_div).insertBefore($(this).parent().parent());
  $(new_input).insertBefore($(this).parent().parent());
});

I was stuck here for a while... a reminder that there are usually multiple ways to solve a problem and it is important to not get stuck on one idea. Thanks all for your suggestions and inputs.

Icaria answered 19/5, 2015 at 14:30 Comment(3)
Glad you got it sorted! Can I suggest you render the inputs hidden on the page(or in the js), then duplicate it with the js, then replace the ids. It will cut down on a lot of code in this function and make it easier to maintain the nested form.Oubliette
Thanks @Dickie. So are you saying that instead of building the new row in JS/JQ, I should just duplicate an existing row? I saw a SO post explaining how to do this with .clone - is that what you had in mind? Sounds promising... something to put on the list for "re-factoring day"Icaria
Pretty much. I wouldn't do it with an existing row. Render a new slot object with the partial and clone that.Oubliette
O
5

undefined local variable or method 'sheet'

Is being caused by the way you are rendering the partial.

= render 'slots/edit_fields', f: builder, sheet:sheet

Is not sufficient for passing variables to a partial. You need:

= render partial: 'slots/edit_fields', locals: {f: builder, sheet:sheet}

That will make f available in your partial.

Oubliette answered 15/5, 2015 at 12:25 Comment(6)
If you encounter more problems after this, update the question.Oubliette
Thanks @DickieBoy. I am working on other stuff at the moment but will get back to this soon and let you know either way.Icaria
@DickieBoy's answer is good. As a side note, if you find yourself struggling more on this, check out the cocoon gem for easy nested association forms: github.com/nathanvda/cocoonArachnid
Thanks for the tip Ben. I've looked at the gem and even played with the simple form demo but it is traditional nested forms (which I have working fine) with a single "add child" link at the end of the child list. I want to be able to generate a new child from anywhere within the child list.Icaria
@Oubliette - I appreciate your help but you are not addressing the root issue of the question with your answer. Also, I don't think what you are saying is true - I just ran a simple test rendering a partial with = render 'foo_thing', x: 'bar', y: 'baz' and verified that both x and y are passed to the partial.Icaria
@steveklein can you setup a demo app with the minimum needed to replicate the problem. I think i'm missing something.Oubliette
I
4

I wound up solving this with jQuery. Not super elegant but very effective. Basically, I just added a click handler for the + icons which built the new row and inserted it where needed (basically just hacked out the jQuery necessary to replicate the HTML produced by Rails). In case my use case helps someone else in the future, here is the jQuery (see imgur link in original post for reference):

//click handler to add edit row when user clicks on the plus icon
$(document).on('click', '.plus-icon', function (event) {

  //build a new row
  var one_day = 24 * 3600 * 1000;
  var d = new Date();
  var unique_id = d.getTime() % one_day + 10000;            // unique within a day and offset past original IDs
  var new_div =
    $('<div/>', {'class': 'row signup'}).append(
      $('<div/>', {'class': 'col-md-1'}).append(
        $('<span/>', {'class': 'plus-icon'}).append(
          '<img alt="Plus" src="/assets/plus.jpg" width="18" height="18" />'
        )
      ).append(
        $('<span/>', {'class': 'minus-icon'}).append(
          '<img alt="Minus" src="/assets/minus.jpg" width="18" height="18" />'
        )
      )
    ).append(
      $('<div/>', {'class': 'col-md-2'}).append(
        '<input type="text" value="" name="sheet[slots_attributes]['+unique_id+'][label]" id="sheet_slots_attributes_'+unique_id+'_label">'
      )
    ).append(
      $('<div/>', {'class': 'col-md-2'}).append(
        '<input type="text" value="" name="sheet[slots_attributes]['+unique_id+'][name]" id="sheet_slots_attributes_'+unique_id+'_name">'
      )
    ).append(
      $('<div/>', {'class': 'col-md-2'}).append(
        '<input type="text" value="" name="sheet[slots_attributes]['+unique_id+'][email]" id="sheet_slots_attributes_'+unique_id+'_email">'
      )
    ).append(
      $('<div/>', {'class': 'col-md-2'}).append(
        '<input type="text" value="" name="sheet[slots_attributes]['+unique_id+'][phone]" id="sheet_slots_attributes_'+unique_id+'_phone">'
      )
    ).append(
      $('<div/>', {'class': 'col-md-2'}).append(
        '<input type="text" value="" name="sheet[slots_attributes]['+unique_id+'][comments]" id="sheet_slots_attributes_'+unique_id+'_comments">'
      )
    );
  var new_input = 
    '<input id="sheet_slots_attributes_'+unique_id+'_id" type="hidden" name="sheet[slots_attributes]['+unique_id+'][id]" value="'+unique_id+'">';

  //insert new row before clicked row
  $(new_div).insertBefore($(this).parent().parent());
  $(new_input).insertBefore($(this).parent().parent());
});

I was stuck here for a while... a reminder that there are usually multiple ways to solve a problem and it is important to not get stuck on one idea. Thanks all for your suggestions and inputs.

Icaria answered 19/5, 2015 at 14:30 Comment(3)
Glad you got it sorted! Can I suggest you render the inputs hidden on the page(or in the js), then duplicate it with the js, then replace the ids. It will cut down on a lot of code in this function and make it easier to maintain the nested form.Oubliette
Thanks @Dickie. So are you saying that instead of building the new row in JS/JQ, I should just duplicate an existing row? I saw a SO post explaining how to do this with .clone - is that what you had in mind? Sounds promising... something to put on the list for "re-factoring day"Icaria
Pretty much. I wouldn't do it with an existing row. Render a new slot object with the partial and clone that.Oubliette
K
1

This sounds remarkably close to a problem I had to solve a few months back.

If I have this correct, your user has a list of 'things' and you wish your user to be able to add another 'thing' using the form being displayed.

I tackled it like this. My 'thing' is empires, users can change the name, delete the empire or add a new empire, within the context of managing their personal data such as changing their email address and passwords.

<%= form_for @user do |f| %>
<%= render layout: "users/partials/fields",
                 locals: {f: f} do %>

<b>Empires</b><br>
  <% n = 0 %>
  <%= f.fields_for :empires do |empire_form, index| %>
    <% n += 1 %>
    <%= empire_form.label :name, class: 'ms-indent' %>
    <%= empire_form.text_field :name, class: 'form_control' %>
    <%= empire_form.label :_destroy, 'Delete' %>
    <%= empire_form.check_box :_destroy %>
    <br>
  <% end %>

  <!-- Empty field for new Empire -->
  <b class='ms-indent'>Name</b>
    <input class="form_control" value="" name="user[empires_attributes][<%=n+1%>][name]" id="user_empires_attributes_0_name" type="text"> <b>new</b>
  <br>

<% end %>
  <%= f.submit "Save changes", class: "btn btn-primary" %>
<% end %>

The interesting bit is the counter to get the number of existing empires (I cannot remember why I didn't just use size or length) and using 'n+1' in an html input field to ensure the new empire was included in the form submitted, with its correct id. Note the use of the html input tag, Rails does not have an equivalent.

[Just an additional note in light of DickieBoys comment. This 'n+1' approach works but might be replaced by a random number and still work. I intend to experiment with this at a later date, but it works so I'm not in a hurry.]

The partial 'fields' looks like this, note the 'yield' in the middle.

<%= f.label :name %>
<%= f.text_field :name, class: 'form-control', autofocus: true %>

<%= f.label :email %>
<%= f.email_field :email, class: 'form-control' %>

<%= yield %>

<%= f.label :password %>
<%= f.password_field :password, class: 'form-control', value: "" %>

<%= f.label :password_confirmation, "Confirmation" %>
<%= f.password_field :password_confirmation, class: 'form-control' %>

The 'fields' partial includes a do-end block containing my empires code. The partial includes this block where it states 'yield'.

Finally, the update controller code.

def update
    @user = User.find(params[:id])
    updated = false
    begin
      updated = @user.update_attributes(user_params)
    rescue ActiveRecord::DeleteRestrictionError
      flash[:warning] = "Empire contains ships. Delete these first."
    end 
    if updated
      flash[:success] = "Profile updated"
      redirect_to @user
    else
      render 'edit'
    end
  end

I won't pretend this is the only solution, nor even necessarily the best. But it took a few days to nut out (Google and StackOverflow failed me!) and has worked reliably since then. The key was really to drop attempts to find a Rails solution for adding a new empire and just use html with a new id for the new empire. You may be able to improve on it. Hope it helps.

Knockknee answered 18/5, 2015 at 0:44 Comment(5)
I think you have a problem with your code. I imagine you have the structure User->*Empire (User has_many empires.) User 1 has 1 empire with id 1. User 2 has an empire with id 2. Your form for User 1 now contains an input with user[empires_attributes][2][name] Im not sure what this would do when submitted(it may be ok, and make a new or record be handled with validation). What you want to do in this case is use some sort of random id, from the question you can see that @steveklein is using new_object.object_id which is fine. Note .object_id is different to .id.Oubliette
lol, it has been in production for several months without problem. But you are correct. I just looked at the db data and it seems update_attributes is ignoring the :id provided and allocating a unique id (just as well). I'm not going to change my production code just yet, but I have added it to my maintenance tasks. Meantime it ain't broke, just in need of some massaging. Well spotted!Knockknee
I think it will break if you have something like: User has 3 empires, ids [1,3]. The new input would have id 3. Thus the params submitted look like(ish) user[empires_attributes][3][name] = "foo" and user[empires_attributes][3][name] = "bar". The browser/server(can't remember which) will override the first name submitted to: user[empires_attributes][3][name] = "bar" Meaning that you will just update id 3 and never be able to create a new one.Oubliette
If it performed as I originally intended, yes. But as it is allocating unique id's this isn't happening. In testing just now I added two empires to a dummy user created on Friday, the empires got unique ids; 2124, 2128, 2129. It seems to just treat the n+1 as a placeholder to be ignored.Knockknee
Not clear that this addresses my use case @Matt. As I think is clear from the image in my question, I need to be able to generate a new "child" (in your case empire) from any child in the list so that the user is intuitively adding new children at a particular position (position is important in my application).Icaria

© 2022 - 2024 — McMap. All rights reserved.