Phoenix 1.7 for Elixir: Edit a Form in a Modal

itizadz

Adz

Posted on September 20, 2023

Phoenix 1.7 for Elixir: Edit a Form in a Modal

In part one of this series, we introduced the CoreComponents that get generated when bootstrapping a new Phoenix project. In part two, we implemented a create modal.

Now, we will implement an edit modal.

You can continue following along with our companion repo.

Editing a Form in a Modal

You will first notice that each item will need a different changeset. We want to edit each item, so we need to be able to build a changeset from a different struct each time.

You could do this by iterating over all of the items in mount and rendering a different modal for every row, but this won't work at all. You would have to have one changeset per assign, which doesn't work when you have a list to add to. It would also mean a lot more HTML because you'd render the whole modal once per row. It's an all-round bad idea.

Instead, we need a way to build the correct changeset based on the item we click on. We can do that by using another JS function — push. This will push an event to the backend, along with any attributes that we want to send. If we add an edit button per row, the click action can push an event to the backend
with the pet_id as a param. Then, we can select the pet from the list of assigns and build a changeset out of it.

First, add the button to the markup. It might be nice to use an icon for this, so let's take a quick detour to icons.

Icons in Phoenix

Phoenix 1.7 ships with a vendored heroicons library and an <.icon> component in CoreComponents.
It works by supplying the name of an icon as a name attr, like so:

<.icon name="hero-cpu-chip" />
Enter fullscreen mode Exit fullscreen mode

The names for the available icons are the filenames contained in the following path: assets/vendor/heroicons/optimized/20/solid/. Looking at the files doesn't tell us much about what they look like because we just see svg markup.

What would be cool is if we could render a dev-only route that displays all icons on one page. Then when we consider using an icon, we can go to that page and peruse them all at our leisure.

First, let's add the route:

# in lib/petacular_web/router.ex
live("/storybook", PetacularWeb.Pages.StoryBookLive, :show)
Enter fullscreen mode Exit fullscreen mode

Then, we can make the necessary PetacularWeb.Pages.StoryBookLive module. We'll now write a function that generates all the icon names from the files in the assets folder, then iterate over them and create an icon from each one. This will give us a dynamic list of icons to render.

Here are the icon_names (this assumes you will start your server from the project's route):

# in lib/petacular_web/pages/story_book_live.ex

defp icon_names() do
  (File.cwd!() <> "/assets/vendor/heroicons/optimized/20/solid")
  |> Path.expand()
  |> File.ls!()
  |> Enum.map(fn path ->
    "hero-" <> String.replace(path, ".svg", "")
  end)
end
Enter fullscreen mode Exit fullscreen mode

Then the markup:

# in lib/petacular_web/pages/story_book_live.ex
@impl true
def render(assigns) do
  ~H"""
  <div>
    <h1 class="text-xl mb-4 font-semibold">Storytime</h1>
    <div class="flex flex-col flex-wrap space-evenly">
      <%= for icon_name <- icon_names() do %>
        <section class="p-4 outline outline-1 mb-2 rounded">
          <h2 class="font-semibold"><%= icon_name %></h2>
          <div class="flex space-x-2 p-y-2 p-x-4 mr-2 my-2">
            <CoreComponents.icon name={icon_name} />
            <p>&ltCoreComponents.icon name="<%= icon_name %>"/&gt</p>
          </div>
          <div class="flex space-x-2 p-y-2 p-x-4 mr-2 my-2">
            <CoreComponents.icon name={icon_name<> "-mini"} />
            <p>&ltCoreComponents.icon name="<%= icon_name %>-mini"/&gt</p>
          </div>
          <div class="flex space-x-2 p-y-2 p-x-4 mr-2 my-2">
            <CoreComponents.icon name={icon_name<> "-solid"} />
            <p>&ltCoreComponents.icon name="<%= icon_name %>-solid"/&gt</p>
          </div>
        </section>
      <% end %>
    </div>
  </div>
  """
end
Enter fullscreen mode Exit fullscreen mode

There is one more thing to do, though. Tailwind will purge all classes it doesn't see being used when the app is built. Usually, this is great because it means the bundle size is smaller, with more lightweight pages. However, here, it is going to bite us. When you refer to classes dynamically, Tailwind doesn't see those classes being used, so it purges them. Tailwind's docs warn about this.

We need to tell Tailwind not to purge all the icon modules so we can render them. We do that by adding a line of config into tailwind.config.js, like so:

safelist: [{ pattern: /hero\-.*/ }],
Enter fullscreen mode Exit fullscreen mode

Now, we can head to http://localhost:4000/dev/storybook and see all the icons. See this commit for all of the changes.

Back to Editing Our Form

Okay, now we can select our edit icon and put it on the page.

<PetacularWeb.CoreComponents.icon name="hero-pencil-square-solid" class="mr-2" />
Enter fullscreen mode Exit fullscreen mode

We will put this in a button and add a phx-click that opens our edit modal for us. All this can live in the homepage we used in parts one and two.

# in lib/petacular_web/pages/home_live.ex

  @impl true
  def render(assigns) do
    ~H"""
    ...

    <div class="w-50 mb-4">
      <%= for pet <- @pets do %>
        <div class="flex">
          <button phx-click={open_edit_modal(pet.id)}>
            <PetacularWeb.CoreComponents.icon name="hero-pencil-square-solid" class="mr-2" />
          </button>
          <p>Name: <span class="font-semibold"><%= pet.name %></span></p>
        </div>
      <% end %>
    </div>

    ...
    """
  end
Enter fullscreen mode Exit fullscreen mode

We also need to create our Edit modal. This will be similar to our Create modal, but a bit different, so we'll just create a new modal.

We can put this in our ~H component just before the other modal:

# in lib/petacular_web/pages/home_live.ex

  @impl true
  def render(assigns) do
    ~H"""
  <PetacularWeb.CoreComponents.modal id="edit_modal">
    <h2>Edit a pet.</h2>

    <PetacularWeb.CoreComponents.simple_form for={@edit_changeset} phx-submit="edit_pet">
      <PetacularWeb.CoreComponents.input
        label="Name"
        id="edit_name"
        field={@edit_changeset[:name]}
        value={@edit_changeset[:name].value}
      />
      <:actions>
        <PetacularWeb.CoreComponents.button>
          Save
        </PetacularWeb.CoreComponents.button>
      </:actions>
    </PetacularWeb.CoreComponents.simple_form>
  </PetacularWeb.CoreComponents.modal>
    ...
    """
  end
Enter fullscreen mode Exit fullscreen mode

Now we need to add a changeset to the assigns. Initially, we can put any changeset in mount because when we open the modal, we are going to seed it:

# in lib/petacular_web/pages/home_live.ex

@impl true
def mount(_params, _session, socket) do
  default_assigns = %{
    pets: Repo.all(Petacular.Pet),
    edit_form: Phoenix.Component.to_form(Petacular.Pet.create_changeset(%{})),
    create_form: Phoenix.Component.to_form(Petacular.Pet.create_changeset(%{}))
  }

  {:ok, assign(socket, default_assigns)}
end
Enter fullscreen mode Exit fullscreen mode

Add the open_edit_modal Function

Now let's implement the open_edit_modal function. This has to do two things:

  1. Open the modal.
  2. Trigger a message to the backend so we can seed the changeset.
# in lib/petacular_web/pages/home_live.ex

defp open_edit_modal(pet_id) do
  %JS{}
  |> JS.push("open_edit_modal", value: %{pet_id: pet_id})
  |> PetacularWeb.CoreComponents.show_modal("edit_modal")
end
Enter fullscreen mode Exit fullscreen mode

The handler for this event needs to select the relevant pet from the list of pets and put that into the changeset:

# in lib/petacular_web/pages/home_live.ex

@impl true
def handle_event("open_edit_modal", %{"pet_id" => id}, socket) do
  pet = Enum.find(socket.assigns.pets, &(&1.id == id))

  new_assigns = %{
    edit_form: Phoenix.Component.to_form(Petacular.Pet.edit_changeset(%{}, pet))
  }

  {:noreply, assign(socket, new_assigns)}
end
Enter fullscreen mode Exit fullscreen mode

When we open the modal, the page will re-render the form because the changeset has changed, and the form will be seeded with the correct data. We can verify this by using
|> IO.inspect(limit: :infinity, label: "") on the form value:

value={@edit_changeset[:name].value |> IO.inspect(limit: :infinity, label: "edit for name:")}
Enter fullscreen mode Exit fullscreen mode

Debugging an Issue with the open_edit_modal

If you open the modal, you will see the correct value printed. But there is a problem — it's not showing on the page! What on earth could be the issue? This one is a doozy, so I will save you some hours of debugging.

The function that we use to open our modal has this line, which focuses the first "focussable" element in the modal:

|> JS.focus_first(to: "##{id}-content")
Enter fullscreen mode Exit fullscreen mode

This is done for accessibility, and so is generally a good idea.

The input happens to be the first focussable thing in our edit form. Phoenix also ensures that the client is the source of truth for an input's value:

For any given input with focus, LiveView will never overwrite the input's current value, even if it deviates from the server's rendered updates.

So what happens in our case? We push an asynchronous message to the backend, which changes an assign (the edit form), causing a re-render — |> JS.push("open_edit_modal", value: %{pet_id: pet_id}). Then we open the modal with JavaScript, but because the message to the server is async, the modal opens before we get a reply. The first focussable element is the input field, so that gets focus, then the server responds. This would normally re-render the input field, but now won't, because the input has focus!

Fixing the Issue

We've done everything right, yet are left adrift. What are our options?

  1. Remove the auto-focus capabilities of the modal.
  2. Have the edit modal focus on something that is not the input first.
  3. Somehow make the form opening synchronous to the server message.

I honestly don't know which is better, but let's reason them out. One is easy to do — just remove this line from the show_modal function — but may have accessibility implications, which makes it a non-starter.

The second option seems reasonable at first. We could maybe set the tabindex on the heading, but mdn
recommends the following:

If an element can be focused using the keyboard, then it should be interactive; that is, the user should be able to do something to it and produce a change of some kind (for example, activating a link or changing an option).

So that's out.

The third option is possible, but requires some ceremony. Instead of opening the modal with JS, you could have the backend trigger a JS event that opens the modal when it's finished seeding the changeset. That requires adding a handler in JS to listen for the event and also means that the modal open is slower, because it requires at least one round trip to the server. For me, that is out as well.

The solution? A secret fourth option — set the value of the field with JS. This means the field will open quickly, be set to the correct value, and auto-focus.

To support that, we alter our open_edit_modal function to accept the name, then use JS.set_attribute to set the field value.

...
<button phx-click={open_edit_modal(pet.id, pet.name)}>
  <PetacularWeb.CoreComponents.icon name="hero-pencil-square-solid" class="mr-2" />
</button>
...

defp open_edit_modal(pet_id, pet_name) do
  %JS{}
  |> JS.push("open_edit_modal", value: %{pet_id: pet_id})
  |> JS.set_attribute({"value", pet_name}, to: "#edit_name")
  |> PetacularWeb.CoreComponents.show_modal("edit_modal")
end
Enter fullscreen mode Exit fullscreen mode

Implementing the Update in Our Phoenix Application

Now the only thing left to do is implement the edit_pet handler. This is similar to the create version, where we flash an error and close the modal on success. We first want to select the pet we are editing, which means we need the pet's id. How can we get that?

The easiest way is to use a hidden input on the form. That way, when the form is submitted, the pet id will also be sent. To do that, we need to add the hidden input:

<%= Phoenix.HTML.Form.hidden_input(f, :id, id: "edit_pet_id_input") %>
Enter fullscreen mode Exit fullscreen mode

And set the value when we open the modal:

defp open_edit_modal(pet_id, pet_name) do
  %JS{}
  |> JS.push("open_edit_modal", value: %{pet_id: pet_id})
  |> JS.set_attribute({"value", pet_name}, to: "#edit_name")
  |> JS.set_attribute({"value", pet_id}, to: "#edit_pet_id_input")
  # ^^^ this line ^^^^
  |> PetacularWeb.CoreComponents.show_modal("edit_modal")
end
Enter fullscreen mode Exit fullscreen mode

We will see the id of the pet appear in the params, allowing us to select the pet we are editing from the assigns:

@impl true
def handle_event("edit_pet", %{"pet" => %{"id" => id} = params}, socket) do
  pet = Enum.find(socket.assigns.pets, &(&1.id == String.to_integer(id)))

  case Repo.insert(Petacular.Pet.edit_changeset(params, pet)) do
    {:error, message} ->
      {:noreply, socket |> put_flash(:error, inspect(message))}

    {:ok, _} ->
      new_assigns = %{
        pets: Repo.all(Petacular.Pet),
        edit_form: Phoenix.Component.to_form(Petacular.Pet.create_changeset(%{}))
      }

      socket =
        socket
        |> assign(new_assigns)
        |> push_event("close_modal", %{to: "#close_modal_btn_edit_modal"})

      {:noreply, socket}
  end
end
Enter fullscreen mode Exit fullscreen mode

See this commit for all the relevant changes.

And with that, we are done!

Wrapping Up

This concludes our three-part series in which we took a fresh Phoenix 1.7 application and built a create and edit modal for
it.

Hopefully, this gives you some new ideas you can extend and implement for your own apps.

Happy coding!

P.S. If you'd like to read Elixir Alchemy posts as soon as they get off the press, subscribe to our Elixir Alchemy newsletter and never miss a single post!

💖 💪 🙅 🚩
itizadz
Adz

Posted on September 20, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related