Numbering nested inputs in Phoenix LiveView

Last week I wrote about nested forms in Phoenix LiveView, and showed some techniques and use cases that go slightly beyond what you’ll find in the docs.

This week I’ll show another trick: how do you number the nested forms? By this I mean displaying an incrementing number on each row, like an <ol>. Here’s a preview of the end result:

It’s a simple requirement, but it’s not immediately obvious how to implement it.

We’ll stick with the same RecipeLive form from Mastering Phoenix Forms. Recall that this is a Phoenix LiveView form for creating or editing a recipe. Recipes have associated ingredients and instructions, and you can add and edit the nested associations using <.inputs_for />:

(For brevity’s sake I’ll only talk about numbering the “Ingredients” part of the form, but everything below would apply for the “Instructions” nested forms too.)

Normally, numbering a collection is trivially easy. You can just use Enum.with_index/2 with a for loop, e.g.:

 <div :for={{widget, i} <- Enum.with_index(@widgets, 1)}>
   {i} - {widget.name}
 </div>

(I’m passing 1 as the second argument to with_index/2 so the numbering starts from 1 and not the default 0)

But we can’t do this with inputs_for, as we don’t have direct access to the for. Where exactly would Enum.with_index/2 go here?

 <.inputs_for :let={ingredient_f} field={@form[:ingredients]}>
  <div class="flex items-center mt-4 mb-2 space-x-2">
    <.icon name="hero-bars-3" class="cursor-pointer relative w-5 h-5 mr-2 -top-1" />
    <div class="grow">
      <input type="hidden" name="recipe[ingredients_sort][]" value={ingredient_f.index} />
      <.input field={ingredient_f[:name]} type="text" phx-debounce />
    </div>

    <button
      type="button"
      name="recipe[ingredients_drop][]"
      value={ingredient_f.index}
      phx-click={JS.dispatch("change")}
      class="relative -top-1"
    >
      <.icon name="hero-x-mark" class="w-5 h-5" />
    </button>
  </div>
 </.inputs_for>

Learn LiveView in 15 lessons

Sign up to my newsletter to get instant access to the LiveView and OTP crash course, the FREE tutorial that’ll teach you Phoenix LiveView and OTP in 15 easy lessons.

Your privacy is my top priority. Your email address will never be shared with third parties.

Fortunately, Phoenix already provides an index if you know where to look. Within each iteration, the ingredient_f variable (which is an instance of %Phoenix.HTML.Form{}) has a index attribute that we can use to number the fields:

 
 <div id="ingredient-inputs" phx-hook="SortableInputsFor">
   <.inputs_for :let={ingredient_f} field={@form[:ingredients]}>
     <div class="flex items-center mt-4 mb-2 space-x-2">
+      {ingredient_f.index + 1}
       <.icon name="hero-bars-3" class="cursor-pointer relative w-5 h-5 mr-2 -top-1" />
       <div class="grow">
         <input type="hidden" name="recipe[ingredients_sort][]" value={ingredient_f.index} />
         <.input field={ingredient_f[:name]} type="text" phx-debounce />
       </div>
       

index starts from 0, so I’m writing index + 1 to render human-friendly numbers:

Let’s improve the layout and spacing:

 <div id="ingredient-inputs" phx-hook="SortableInputsFor">
   <.inputs_for :let={ingredient_f} field={@form[:ingredients]}>
     <div class="flex items-center mt-4 mb-2 space-x-2">
-      {ingredient_f.index + 1}
-      <.icon name="hero-bars-3" class="cursor-pointer relative w-5 h-5 mr-2 -top-1" />
+      <div class="mr-3 flex items-center mb-2">
+        <.icon name="hero-bars-3" class="cursor-pointer relative w-5 h-5 mr-2" />
+        <div class="h-5 leading-5">{ingredient_f.index + 1}</div>
+      </div>
       

Now the ingredients are numbered, and the numbers stay updated when you add or remove rows:

But there’s a bug: the numbers don’t stay in the correct order when I rearrange the ingredients:

See how “1, 2, 3” becomes “3, 1, 2”. I want the numbers to stay in their natural order no matter how I change the form.

Strangely, it fixes itself the next time I change something on the form:

We’re sorting the ingredients with Sortable.js, configured via a hook:

 // assets/js/hooks/SortableInputsFor.js
 import Sortable from "../../vendor/sortable"

 const SortableInputsFor = {
   mounted(){
     new Sortable(this.el, {
       animation: 150,
       ghostClass: "opacity-50",
       handle: ".hero-bars-3"
     })
   }
 }

 export default SortableInputsFor;

The bug occurs because Sortable.js is Javascript that runs only in the browser, and doesn’t communicate anything to the server. We’ve moved the <input>s around in the DOM, but LiveView won’t know about it until it receives the next phx-change or phx-submit event where the form parameters are in the new order. Therefore LiveView won’t update its state, and so won’t update the DOM with the correct, re-ordered indexs.

To fix the numbers, we need to force LiveView to update. We can do this by manually triggering a phx-change event from the hook:

 // assets/js/hooks/SortableInputsFor.js
 import Sortable from "../../vendor/sortable"

 const SortableInputsFor = {
   mounted(){
     new Sortable(this.el, {
       animation: 150,
       ghostClass: "opacity-50",
-      handle: ".hero-bars-3"
+      handle: ".hero-bars-3",
+      onEnd: (event) => {
+        event.item.querySelector("input").dispatchEvent(
+          new Event("input", {bubbles: true})
+        )
+      }
     })
   }
 }

 export default SortableInputsFor;

This uses Sortable’s onEnd hook to run some custom Javascript after the drag-and-drop. To trigger phx-change, we just follow the docs: we pick an <input> on the form (it doesn’t matter which one) and manually dispatch a Javascript Event.

Now it works:

This wasn’t a lot of code, although it still feels somewhat complicated for what should be an extremely basic feature. I couldn’t figure out a simpler approach — let me know if you’ve got something better.

If you’ve got any other questions about LiveView, let me know and I might make it the subject of my next post.

And for the ultimate guide to LiveView’s <inputs_for>, sort/drop params, nested forms and more, be sure to check out Mastering Phoenix Forms 😉.

Learn LiveView in 15 lessons

Sign up to my newsletter to get instant access to the LiveView and OTP crash course, the FREE tutorial that’ll teach you Phoenix LiveView and OTP in 15 easy lessons.

Your privacy is my top priority. Your email address will never be shared with third parties.