Reordering nested form inputs with buttons (not drag and drop!)

There are some online examples on how to reorder nested form inputs built with <.inputs_for/> using the Sortable.js library and streams. It’s all based on the new :sort_param provided by ecto.

I have a form with nested inputs in which I’d prefer not to use streams because there are several levels of nesting and I didn’t want to have to traverse the changeset to convert everything into streams and I don’t want to use drag and drop because the items to be reordered are quite “tall” and fill up too much of the screen to be dragged around easily.

From a UI point of view, I’d like to add buttons to move an element up the list (thus swapping it with the element above) and to move an element down (thus swapping it with the element below).

I’m having a real hard time with this, though. My naive solutions of updating the values of the :sort_param fields and emitting a change event are not working like I wanted, and I wonder if anyone else has already implemented this in a way that works.

Could you share what you tried and describe what isn’t working? Are you successfully updating/persisting the :sort_param aka position field and just having trouble with that change event?

I’ll paste what I think are the relevant parts:

<h1>New todo list</h1>

<.simple_form for={@form} action={@action} phx-change="validate" phx-submit="save">
  <.input field={@form[:name]} type="text" label="Name" />
  
  <h3>Todo items</h3>

  <.inputs_for :let={todo} field={@form[:todos]} skip_hidden="true">
    <div class="card mb-3">
      <div class="card-body">
        <input type="hidden"
                name={todo[:_persistent_id].name}
                value={todo.index} />

        <input type="hidden"
                name={todo[:id].name}
                value={todo[:id].value} />

        <input
          type="hidden"
          id={"#{@form[:todos_sort].id}_#{todo.index}"}
          name={@form[:todos_sort].name <> "[]"}
          value={todo.index}
        />

        <.input field={todo[:name]} type="text" label="Name"/>

        <label class="btn btn-sm btn-outline-danger" style="cursor:pointer">
          <input
             type="checkbox"
             name={@form[:todos_drop].name <> "[]"}
             value={todo.index}
             hidden />

          <div>Delete <.icon name="trash"/></div>
        </label>

        <.move_item
            item={todo}
            sort_param={@form[:todos_sort]}
            nr_of_items={length(@form[:todos].value)}>
        </.move_item>
      </div>
    </div>
  </.inputs_for>

  <.add_new_item sort_param={@form[:todos_sort]}>
    Add new item
  </.add_new_item>

  <hr/>

  <:actions>
    <.button>Save Todo list</.button>
  </:actions>
</.simple_form>

The <.add_new_item /> component works perfectly, and it’s based on what the docs suggest.

The <.move_item/>, however is a bit more complex. It generates two buttons which when clicked swap the values of the following input value and changes a “dummy field” in order to re-submit the form, as in the code below.

<input
      type="hidden"
      id={"#{@form[:todos_sort].id}_#{todo.index}"}
      name={@form[:todos_sort].name <> "[]"}
      value={todo.index}
    />

The code for that component is this:

  def move_item(assigns) do
    ~H"""
    <input
      type="hidden"
      id={"#{@sort_param.id}__dummy__#{@item.index}"}
      name={"__#{@sort_param.id}[__dummy__#{@item.index}]"}
      value="dummy"/>

    <div class="btn-group">
      <button
          type="button"
          class="btn btn-sm btn-outline-dark"
          phx-click={swap_with_element_above(@item, @sort_param, @nr_of_items)}
          disabled={@item.index < 1}>
        <.icon name="chevron-up" /> Move up
      </button>
      <button
          type="button"
          class="btn btn-sm btn-outline-dark"
          phx-click={swap_with_element_below(@item, @sort_param, @nr_of_items)}
          disabled={@item.index >= @nr_of_items - 1}>
        <.icon name="chevron-down" /> Move down
      </button>
    </div>
    """
  end
  
  
  defp swap_with_element_above(item, sort_param, _nr_of_items) do
    if item.index <= 0 do
      %JS{}
    else
      # These selectors represent the sort_param input fields
      # that control element ordering.
      # We can depend on these names because these are detgerministically
      # generated by the `<.nested_inputs_for/>` component.
      selector_for_element = "##{sort_param.id}_#{item.index}"
      selector_for_element_above = "##{sort_param.id}_#{item.index - 1}"
      selector_for_dummy_input = "##{sort_param.id}__dummy__#{item.index}"

      %JS{}
      |> JS.set_attribute({"value", item.index - 1}, to: selector_for_element)
      |> JS.set_attribute({"value", item.index}, to: selector_for_element_above)
      |> JS.dispatch("change", to: selector_for_dummy_input)
    end
  end

  defp swap_with_element_below(item, sort_param, nr_of_items) do
    if item.index >= nr_of_items - 1 do
      %JS{}
    else
      # These selectors represent the sort_param input fields
      # that control element ordering.
      # We can depend on these names because these are detgerministically
      # generated by the `<.nested_inputs_for/>` component.
      selector_for_element = "##{sort_param.id}_#{item.index}"
      selector_for_element_below = "##{sort_param.id}_#{item.index + 1}"
      selector_for_dummy_input = "##{sort_param.id}__dummy__#{item.index}"

      %JS{}
      |> JS.set_attribute({"value", item.index + 1}, to: selector_for_element)
      |> JS.set_attribute({"value", item.index}, to: selector_for_element_below)
      |> JS.dispatch("change", to: selector_for_dummy_input)
    end
  end

The :todos_sort is passed in the liveview parameters correctly, and the resulting changeset and form is generted correctly. It’s the DOM that doesn’t seem to be passed correctly.

The parameters are passed into the following changeset:

defmodule Registo.FormDemo.TodoList do
  use Ecto.Schema
  import Ecto.Changeset

  alias Registo.FormDemo.Todo

  @type t :: %__MODULE__{}

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id

  schema "todo_lists" do
    field :name, :string
    has_many :todos, Todo,
      preload_order: [asc: :position],
      on_delete: :delete_all

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(todo_list, attrs) do
    todo_list
    |> cast(attrs, [:name])
    |> cast_assoc(:todos,
        with: &todo_changeset/3,
        drop_param: :todos_drop,
        sort_param: :todos_sort
      )
    |> validate_required([:name])
  end

  def todo_changeset(todo, changes, position) do
    todo
    |> Todo.changeset(changes)
    |> put_change(:position, position)
  end
end

This code is very integrated in the rest of the codebase, so it’s hard to show a self-contained example. I could generate anew app and build this form from scratch but that would require using tailwind components, which I’m not at all familiar with that.