tmbb

tmbb

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.

Showing Posts 1 to 4

codeanpeace

codeanpeace

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?

tmbb

tmbb OP

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.

siddd

siddd

Hi,

I would like some advice too.
I had to do button based reordering for multi-level(depth of 2) lists.

I have used two ways of doing it.

  1. Using sort_param and passing order in “validate” event
  2. Updating the data myself with a custom “move-todo” event(Personally prefer this for reasons written at the end)

Have rewritten the code according to your use-case. Not tested :grimacing:

Method 1: Using sort_param
I have something like <input name="todos[move_up]” /> for each row.
In “validate”, I modify the params like

  params =
      if params["todos_list"]["sort_todos"] do
        # new row being added in my case, do nothing with :sort_param
        params
      else
        # re-order indices according to move input pressed
        order = 0..(length(todos) - 1) |> Range.to_list()

        todos_order =
          case params["todos"]["move_up"] do
            nil ->
              case params["todos"]["move_down"] do
                nil ->
                  order

                str_idx ->
                  idx = String.to_integer(str_idx)
                  swap_items(order, idx, idx + 1)
              end

            str_idx ->
              idx = String.to_integer(str_idx)
              swap_items(order, idx, idx - 1)
          end

        # update the field used for :sort_param
        put_in(params, ["todos_list", "sort_todos"], todos_order)
      end

Method 2: using custom event.
The button looks something like:

	<.button
          :if={todo.index > 0}
          phx-click="move-todo”
          phx-value-index={todo.index}
          phx-value-direction="up"
        >
		Up
        </.button>

And the event handler looks something like

  def handle_event("move-todo", %{"direction" => direction, "index" => index}, soc) do
    %{source: %{data: todos_list}} = soc.assigns.form
    todos = todos_list.todos

    index = String.to_integer(index)
    curr_item = todos |> Enum.at(index)

    todos =
      case direction do
        "up" when index > 0 ->
          	item_above = todos |> Enum.at(index - 1)

	          todos
	          |> List.replace_at(index - 1, curr_item)
	          |> List.replace_at(index, item_above)

        "down" when index < length(todos) - 1 ->
	          item_below = todos |> Enum.at(index + 1)

	          todos
	          |> List.replace_at(index, item_below)
	          |> List.replace_at(index + 1, curr_item)
        _ ->
          todos
      end

    todos_list = %{todos_list | todos: todos}
    # TODO - create form from todos_list and assign to socket, show flash
    {:noreply, soc}
  end

  • I prefer the second method because I can use similar logic to add/clone a row etc.
  • Easier to add pre-populated values(when adding a row). Didn’t want to move that logic to changeset.
  • Shorter and more self-explanatory.
  • Simpler changes to show flash message(we have action, direction and index as variables already)
  • Also using method 1 to create the sort-order feels a bit weird. Maybe there is a better way, but I haven’t figured it out yet.

I am also saving the history as a simple list of structs so users can undo/redo changes.
Would love to hear if there are better ways to do that.

tmbb

tmbb OP

Yes, these are good advantages. They come with the disadvantage of requiring a bit more code, though. A problem with your approach might happen if you extend your approach to two levels. Moving things up and down and creating nested children may be a problem if they are meant to be children of structs which don’t have an ID. If you don’t have an id, how can you identify the supposed parent fo the newly created or moved resource? This will mean you always have to create the parent before adding the child. This may or may not be a problem to you, depending on how you approach things.

— All posts loaded —

Where Next? Top

Trending in Questions Top

RSP87
I’m working on a project that simulates the bumbl example in the programming phoenix book. It acts almost like an email client. We have a...
New
nseaSeb
Hello, I know there is an approach for handling lists that allows for optimized traversal, but I can’t recall the specific method (somet...
New
RemyXRenard
I’m seeing that a list inside a Kino.DataTable will be interpreted as a charlist, even if the Kino.configure() is set to charlists: :as_l...
New
velrest
So my question is quite simple and i have found no conclusive answer on forum, google or AI. Should we use :erlang.float for Integer to ...
New
samoloth
Hi, I’ve just set up an application with ash_authentication. There is only magic link strategy for now, so there is no confirmation add o...
New
brecabral
Documentation While reading the Scoped Routes section, I noticed that the documentation currently refers to a problem without explainin...
New
FlyingNoodle
If a change or preparation module uses Ash.Changeset.get_argument/2 or Ash.Query.get_argument/2 (or any of the other get_argument functio...
New

Other Trending Topics Top

mudasobwa
I am happy to introduce the very α version of the new programming language compiled to BEAM. Welcome Cure. It has literally three kille...
New
marciok
Hi there! We created Gust: A task orchestrator inspired by Airflow. For those who have never heard about Aiflow, it’s a Python-based wor...
New
jimsynz
Beam Bots (or just BB for short) is a framework for building fault-tolerant robotics applications in Elixir using familiar OTP patterns. ...
New
Dmk
Xamal is a deployment tool for Elixir apps that deploys native releases to bare metal servers over SSH. It’s a port of GitHub - basecamp/...
New
netoum
Corex is an accessible, unstyled UI component library for Phoenix that integrates Zag.js state machines using Vanilla JavaScript and Live...
New
webofbits
With AI doing more of the implementation work, I’ve been wondering how much coding I should deliberately keep doing myself. My main conc...
#ai
New

We're in Beta

About us Mission Statement

Options

Thread Display Mode




Thread Preview

Skip Thread Previews