Dynamic LiveView Forms

Tagged: elixir

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:

  1. As a user, I can create a new recipe and;

  2. I can click a button to add a row of forms to add a new ingredient (or step) to my recipe.

  3. 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:

  1. Adding and Remove fields that have not yet been persisted to the database.

  2. 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