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>
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 index
s.
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 😉.