How to append inputs for `Phoenix.HTML.Form.inputs_for`?

So, I have a master detail structure saved as a json field to the parent table in my application. Something like:

defmodule Parent do
   use Ecto.Schema

   schema "parents" do
     field :name, :string
     embeds_many :children, Child
   end
end

defmodule Child do
  use Ecto.Schema

  embedded_schema do
    field :name, :string
  end
end

And then I’m using the inputs_for helper in the parent form for my children:

<%= inputs_for f, :stages, [append: [%Child{}]], fn fc -> %>
  <div class="form-group">
    <%= text_input fc, :name, class: "form-control" %>
    <%= error_tag fc, :name %>
  </div>
<% end %>

The append option on the inputs_for appends the input for a new child when the form is rendered. But, what I’m searching for is a way to append a new line to it, dynamically. Is there a easy way for it?

If you know an easy way to remove it too, I would be very glad. :grimacing:

4 Likes

What do you mean append a new line? And do you mean dynamically on the client side? You’d need javascript for that.

1 Like

Yep, that’s what I meant. I know I need JS for that, my hope was that someone had already been through that and created a simple helper to it. Or even explain what they think is the best way to solve this…

My current solution was:

I created a template html code for the field, like this:

<div id="child-form-template" class="hidden">
  <div class="child-form row">
    <div class="form-group col-xs-10">
      <input class="form-control" id="parent_children_x_name" name="parent[children][x][name]" required="required" type="text" value="">
    </div>
    <div class="form-group delete-child col-xs-2">
      <a class="btn btn-danger btn-block js-delete-child" href="#">
        Delete
      </a>
    </div>
  </div>
</div>

And a “Add child” button with the class “js-add-child” to the form. Then in the JS I coded this:

$("body").on("click", ".js-delete-child", (e) => {
  e.preventDefault()

  let $form = $(e.target).closest(".child-form")
  # this hidden input is created for the already existent children, if you want to remove a child, you need to remove it from the dom
  let $hidden_input = $form.prev()

  $form.remove()
  if ($hidden_input.attr("type") == "hidden") $hidden_input.remove()
})

$(".js-add-child").click((e) => {
  e.preventDefault()

  let $form = $("#child-form-template .child-form").clone()

  # I'm not proud of this
  let lastIndex = 0
  let $controls = $(".children .child-form .child-control")
  if ($controls.length > 0) {
    lastIndex = $controls.last().attr("id")
      .match(/parent_children_(\d+)_name/)[1]
  }

  let nextIndex = parseInt(lastIndex) + 1

  $form.find("#parent_children_x_name")
    .attr("id", `parent_children_${nextIndex}_name`)
    .attr("name", `parent[children][${nextIndex}][name]`)

  $(".children").append($form)
})

Before you judge me: I chose to solve this way because I wanted to keep it simple. I know it can bring some trouble to me in the future, because I render the fields for the children, sometimes using Phoenix.HTML, and sometimes just copying this template, and maybe someone who changes one of these, will not be aware of the other one.

My hope was to really render as much as I can in the server, I just suffered enough in my life with client side rendering. :grimacing:

In that case I’d say use Intercooler-js. It is a simple thing that retrieves snippets of html from the server to put in place of things. Just detect its headers (I made a plug for that, I can grab it for you if you want) and disable the template for given sections or render the parts you have it ask for. It is entirely server-side driven with intercooler just responding to clicks and such on the client-side to ask the server for stuff. All that javascript you have there would vanish. You’d just have to make a few more views and templates on the server to send back specifically requested parts. :slight_smile:

1 Like

:astonished: P-E-R-F-E-C-T! :heart: Javascript vanishing is just my dream! lol

Thank you so much! This will help a lot!

If it’s not asking too much, can you send me the plug you mentioned? :grimacing:
No hurry, you helped a lot already! Thanks again!

First of all I have an Intercooler.ex file with this:

defmodule Intercooler do

  def get_params(%Plug.Conn{params: params}) do
    get_params(params)
  end
  def get_params(%{"ic-request" => "true"} = params) do
    params
  end
  def get_params(_) do
    nil
  end


  def get_param(data, id, default \\ nil) when is_binary(id) do
    case get_params(data) do
      nil -> nil
      params -> Map.get(params, id, default)
    end
  end


  def active?(data), do: get_params(data) !== nil
  def get_target_id(data), do: get_param(data, "ic-target-id")

end

Just a simple set of helpers (that I keep intending to grow but have not yet, I should make a public repo…).

And I have an Intercooler/Plug.ex file with:

defmodule Intercooler.Plug do

  defmodule Format do
    import Plug.Conn
    import Phoenix.Controller

    def init(options), do: options

    def call(conn, options) do
      case conn.params["ic-request"] do
        "true" ->
          conn
          |> put_layout_formats(["ic.html" | layout_formats(conn)])
          |> put_format("ic.html")
        _ ->
          conn
      end
    end
  end

end

What that does is change the layout templates to be “.ic.html.eex” instead of “.html.eex”. This made it trivial to make special intercooler-only templates.

Next I have in my router’s browser pipeline I added this plug right after the :accepts plug:

    plug Intercooler.Plug.Format

Next in my web/templates/layout I made an “app.ic.html.eex”, with just this:

<%= render_sublayout @view_module, @view_template, @view_module, "sublayout.ic.html", assigns %>

And that was only because I was too lazy to clear the required layout name on the plug. ^.^
Do note, *you* will probably not be able to use the above file, the framework I’ve built up has a render_sublayout function that does arbitrary recursing through the page module system, your “app.ic.html.eex” will probably just be:

<%= render @view_module, @view_template, assigns %>

Say you have an index.html.eex template, if your intercooler calls back in to the same page (to the index.ic.html.eex template) to get information then you can query what it is asking for by using the above Intercooler module in your template, one of mine is like:

<%=
  case Intercooler.get_target_id(@conn) do
    "tab-check_in_out-checked_out" ->
      section_ids = @section.values
        |> Enum.map(fn out -> out.id end)
        |> Enum.sort
        |> Enum.uniq
      ids = Intercooler.get_param(@conn, "checked_out", %{"id"=>["-1"]})["id"]
        |> List.wrap
        |> Enum.map(&String.to_integer/1)
        |> Enum.sort
        |> Enum.uniq
      if section_ids !== ids do
        render "index_section_tab_out.html", conn: @conn, changeset: @changeset, section: @section
      end
    _ -> nil # TODO:  Need to add support for the polling tab better, check in button is not updating it there.  Not needed quite yet though...
  end
%>

In that I just check what it is asking for (“tab-check_in_out-checked_out” in this case), so I gather up the information it wants and call back to the real template to render that snippet back out to it (I have more case statements too for different parts).

Alternatively in intercooler you can have it request from different url’s instead of the same url with different ID names, then you just render the template straight out (though that style involves making more controller endpoints that might be duplicates of existing ones, I prefer the view dispatcher method, but either works and I use both in various cases).

Learn intercooler first, it works its magic via tags and such, then build your server around that. :slight_smile:

10 Likes

Great, you should push a package to Hex, this could surely help quite some people! :slight_smile:

2 Likes

Just another thing about this topic: does anyone worked with drab to make this kind of thing?

1 Like

Should be quite easy to do, but I’ve not done specifically this yet.

1 Like