Dynamic Liveview Forms
Posted on
Hello hello,
I'm back with another post on Elixir. This one is about working with LiveView and building dynamic forms. When I talk about dynamic forms I'm referring to the ability to add or remove any number of form fields for a "embeds_many" data type. In the case of Galley, the recipe form has many parts to it, the pertinent ones here being the ingredients and the steps. Any recipe has a variable number of ingredients and instructional steps. I needed to enable users to add and/or delete form fields to fit their recipes.
This turned out to be a bit harder than I thought, as I waded into the world of LiveView and the mechanics of Ecto and Phoenix at the same time.
Note: A lot of what I've learned and detail here was informed by this post, which partially helped me solve what I was trying to do.
What's the data?
Before I get too much farther with that, let's look at the shape of the data representing a recipe:
schema "recipes" do
field :source, :string
field :title, :string
field :slug, :string
field :yields, :string
field :notes, :string
belongs_to :user, Galley.Accounts.User
embeds_one :time, R.RecipeTime, on_replace: :update
embeds_many :uploaded_images, Image, on_replace: :delete do
field :url, :string
field :is_hero, :boolean, default: false
end
embeds_many :ingredients, Ingredient, on_replace: :delete do
field :ingredient, :string
field :quantity, :string
field :measurement, :string
field :temp_id, :string, virtual: true
end
embeds_many :steps, Step, on_replace: :delete do
embeds_one :timer, R.RecipeTime, on_replace: :update
field :instruction
field :temp_id, :string, virtual: true
end
timestamps()
end
You can see above that I'm using embeds_many
, which enables having a single table, recipes
, to have many steps and ingredients. In this case, we aren't using the words "has_many" because that syntax is used to imply relationships via sql, whereas embeds_many
is about embedding a sequence of data inside the originating table. The latter is going to be holding json data in my table.
So, before we get into any more code, here's what I want to achieve:
- As a user, I can create a new recipe and;
- I can click a button to add a row of forms to add a new ingredient (or step) to my recipe.
- If I want to remove a step or ingredient, I should be able to click another button to remove that item.
Where LiveView comes into play
The above isn't too complicated, but it gets a bit more confusing when it comes time to implement this form with LiveView. LiveView enables me to dynamically add form fields rather than having to reload the entire page if the user wanted to add an additional field. Normally, this would all be handled by Javascript, but because I have LiveView I can avoid writing javascript (yay) and also continue using the great change validation systems that Ecto provides.
So, we need to break the use of LiveView down into two pieces:
- Adding and Remove fields that have not yet been persisted to the database.
- Removing fields that have been persisted to the database.
Let's start with 1.
Adding temporary fields
Here's what my markup looks like - I've included the entirety of the "sub-form" representing a "step" for clarity as it aligns with the embeds_many
schema posted above. However, you mainly need to pay attention to a few small lines (marked with comments).
<%= inputs_for f, :steps, fn fp -> %>
<tr class="mt-2 items-center">
<td> <%= textarea(fp, :instruction, id: fp.name, rows: 1,) %> </td>
<!-- Important! -->
<%= hidden_input(fp, :temp_id) %>
<!-- Ignore this -->
<td class="flex ml-4">
<%= inputs_for fp, :timer, fn fo -> %>
<%= select(fo, :hour, 0..12, class: "w-16 mr-2 py-1") %>
<span class="self-center dark:text-gray-100 -mt-2">:</span>
<%= select(fo, :minute, 0..59, class: "w-16 ml-2 py-1") %>
<% end %>
</td>
<td class="align-top">
<!-- Important! -->
<%= if has_temp_id(fp.data.temp_id) do %>
<button
class="btn-icon ml-2 mb-2"
phx-value-remove={fp.data.temp_id}
phx-click="remove-step"
type="button"
phx-target={@myself}
>
✕
</button>
<% else %>
<!-- This part will come later... -->
<% end %>
</td>
</tr>
<button
id="add-instruction-btn"
class="btn-clear mt-8"
type="button"
phx-click="add-instruction" <!-- instruction -->
phx-target={@myself}
>
Add Instruction
</button>
<% end %>
So, we've got three important things above - two button, one for adding a step, another for removing a step and a hidden input. Let's walk through them!
Adding a step
In my corresponding .ex
file, I have a function that handles the dispatch of "add-instruction". It looks like this (with additonal comments for clarity)
def handle_event("add-instruction", _val, socket) do
# store our socket assigns in a variable for less typing
sA = socket.assigns
# catpure all existing steps
existing_steps = Map.get(sA.changeset.changes, :steps, sA.recipe.steps)
# create a new steps variable where, the existing steps are concatted with
steps =
existing_steps
|> Enum.concat([
# a new step, with a *temporary id*
Recipes.change_step(%Recipe.Step{temp_id: GalleyUtils.get_temp_id()})
])
# make a *ew* changeset that puts the *new* steps *into* the existing changeset.
changeset = sA.changeset |> Ecto.Changeset.put_embed(:steps, steps)
# send it all back!
{:noreply, assign(socket, changeset: changeset, num_steps: length(steps))}
end
# in Galley.Utils module
# this generates a random temporary id.
def get_temp_id do
:crypto.strong_rand_bytes(5)
|> Base.url_encode64()
|> binary_part(0, 5)
end
Ok, we're getting somewhere. By clicking the "Add instruction" button we are effectively appending a new item to the changeset that wraps our Recipe struct. When this data comes back to the "front end" it adds a new field to our set.
What about temp_id
?
When the entire form is submitted, Ecto will persist the recipe form to the database if it's valid. But, more importantly, it will ignore the temp_id
and assign the embedded schema it's own unique id
. This is a good thing. The temp_id
is actually a virtual field. If you look up above at the schema that describes a Recipe, you might have seen these lines:
embeds_many :steps, Step, on_replace: :delete do
embeds_one :timer, R.RecipeTime, on_replace: :update
field :instruction
field :temp_id, :string, virtual: true # <<< important
end
The virtual boolean indicates that we aren't going to be persisting the temp_id
to the database; it strictly shows up when we are sending data to our front end. This means that non-persisted fields will have a temp_id while *persisted fields will have a nil temp_id
and a non_nil value for id
.
We'll see temp_id
come into play when it comes time to handle the removing of a step that has not yet been persisted to the database
Remove non-persisted fields
Let's look at our html again for the recipe step fields:
<%= if has_temp_id(fp.data.temp_id) do %>
<button
class="btn-icon ml-2 mb-2"
phx-value-remove={fp.data.temp_id}
phx-click="remove-step"
type="button"
phx-target={@myself}
>
✕
</button>
.....
When we click the small x
in our form (adjacent to each "step" field) it will fire off an event of remove-step
which will have access to a param of remove
with the temp_id
that was set when we ran add-instruction
:
def handle_event("remove-step", params, socket) do
# grab the temp_id, which is named "remove" by phx-value
id_to_remove = params["remove"]
# check if our changeset has changes under "steps" ie /anything/
# has happened in this part of the form will result in "changes" being added
# to the changeset
if Map.has_key?(socket.assigns.changeset.changes, :steps) do
# rebuild the steps, but this time, filtering out the ID we've
# requested be removed.
steps =
socket.assigns.changeset.changes.steps
|> Enum.reject(fn changeset ->
step = changeset.data
# only allow deleting of items that have been inserted.
step.temp_id == id_to_remove && changeset.action == :insert
end)
# like before, we'll re-put the steps structure into our changeset
# and send it back
changeset = socket.assigns.changeset |> Ecto.Changeset.put_embed(:steps, steps)
{:noreply, assign(socket, changeset: changeset, num_steps: length(steps))}
else
{:noreply, socket}
end
end
The above block has a bit of safety checking - It's not really possible that a user could run "remove-step" unless there's already been a temporary step added via Add instruction
. Nonetheless, I've added an if
check to make sure.
At this point, we've gotten to a place where we can add and remove fields that do not yet exist in the database. Let's move on to removing existing database entries.
Removing persisted fields
The article I linked at the beginning got me this far, but that is where our paths divulged. The article covers a way to delete persisted entries, but it required using a checkbox and actually hitting a submit button in order to carry out the action - I think that's the traditional course of action.
I was hoping I could find a way to use LiveView to delete things in place - but the solution wasn't immediately clear. I ended up posing my situation on the Elixir discord channel and got some quick feedback. The following is what I ended up implementing.
# A bit hacky but:
# Delete the recipe steps and refetch the recipe
# Loop over the old changeset's `changes` field, and keep the previous changes (save for the deleted one)
# Reassign the data from the db to the socket changeset, and put the changeset changes back in.
def handle_event("remove-persisted-step", %{"remove" => step_id_to_remove}, socket) do
Recipes.delete_recipe_step(socket.assigns.changeset.data, step_id_to_remove)
updatedRecipe = Recipes.get_recipe!(socket.assigns.changeset.data.id)
old_changes = socket.assigns.changeset.changes
# we need to bake the changeset and recreate it in order to avoid
# conflicts with unsaved changes with dynamically added form fields
changeset =
socket.assigns.changeset
|> Ecto.Changeset.put_embed(:steps, updatedRecipe.steps)
|> Ecto.Changeset.apply_changes()
|> Recipes.change_recipe()
# HACK: for now, we have to reference previous changeset changes via old_changes inside update
|> Map.update(:changes, %{}, fn changes ->
steps = Map.get(socket.assigns.changeset.changes, :steps, [])
if length(steps) > 0 do
existing_changed_steps = Enum.filter(steps, fn change ->
change.action == :insert || change.data.id != step_id_to_remove
end)
%{old_changes | steps: existing_changed_steps}
else
changes
end
end)
{:noreply, socket
|> assign(:changeset, changeset)
|> assign(:recipe, updatedRecipe)
}
end
I'm not sure if I ended up with the best solution - I'm manually messing around with the :changes
map inside the changeset, which feels a bit ... ominous (but maybe it's perfectly fine?). A quick walkthrough of what is happening in the above somewhat fishy code:
- Delete the recipe by using the step_id
- re-fetch the recipe, using the id that was in the socket.assigns map.
- store the old changes to gain access to them later
- rebuild the changeset with the updated steps, and then bake the changes in (I can't remember exactly why I had to do this, but without
.apply_changes()
the re-adding of changesets seemed to cause conflicts - add the old changeset changes in, but run a filter on the existing steps and remove the one that was deleted from the DB. Something seems off here because I'm using an update function and referencing variables outside of the closure.
But, with all the above - it works! Do you know a better way to achieve what I'm looking for? Let me know with an email.
As always, thanks for reading!
o/ WT