Add a Form to a Modal in Phoenix 1.7
Tags: elixir, phoenix
Published: 2023-08-08 13:35:19

In part one of this series, we introduced the core generated components when bootstrapping a new Phoenix project. We used a button and a modal from the core components to lay the groundwork for a "create modal".

In this post, we will put a form onto the modal and create pets.

Let's get started!

_Note: As in the last post, you can follow along with our [companion repo](https://github.com/Adzz/petacular)._

## Adding a Form to Our Phoenix Application

There is a [simple form](https://github.com/Adzz/petacular/blob/main/lib/petacular_web/components/core_components.ex#L186) included in `PetacularWeb.CoreComponents`. This expects two slots: an `:inner_block`, which will be our form input components, and `:actions`, which will be the submit buttons. The `:actions` slot is a named slot, meaning, we can render the components we want to use as buttons inside of an `<:actions>` tag, like this:

```elixir
# in /lib/petacular_web/pages/home_live.ex

<PetacularWeb.CoreComponents.simple_form for={@create_form} phx-submit="create_pet">
  <:actions>
    <PetacularWeb.CoreComponents.button>
      Save
    </PetacularWeb.CoreComponents.button>
  </:actions>
</PetacularWeb.CoreComponents.simple_form>
```

The [input components are also in `PetacularWeb.CoreComponents`](https://github.com/Adzz/petacular/blob/main/lib/petacular_web/components/core_components.ex#L266) and produce the correct kind of input based on the `attrs` used to define them. We just need a text field for now, so we can define the text input like so:

```elixir
<PetacularWeb.CoreComponents.input field={@create_form[:name]} label="Name" />
```

If you don't define a `type` attr, it defaults to a text field. The `@create_form` is created from a changeset (as we will see in a moment).

### Phoenix's Component `to_form`

Let's take a moment to talk about forms and changesets. We used to pass changesets directly to forms. But in Phoenix 1.7, a new [`to_form`](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#to_form/2) function generates a [`Phoenix.HTML.Form`](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html)
struct from the given changeset.

This seemingly small change is a massive improvement, so it's worth talking about. First, it provides us with some indirection. Calling `to_form` ensures that what the `<.form` component sees is a `Phoenix.HTML.Form` struct, rather than a changeset. That means if we wanted to swap away from a changeset and use something else in the future (like a bare map of params), we could — with no changes to the `<.form` component itself. We just change the places we call `to_form` in `mount` and `handle_event`.

Next, the `Phoenix.HTML.Form` handles change tracking better. Up until Phoenix 1.7, if a form field changed, the whole form was re-rendered. Usually that's fine, but if you have a large complex form or dropdowns with lots of options, it can be a big boon not to have to re-render all of that each time you change a field.

Finally, it also simplifies the syntax. When we passed changesets through to forms, we used the `:let` attribute to assign the changeset to a variable we could refer to. Now we can omit that entirely. We can also refer to the value of a form field more simply; previously, you would have done something like: `value={Ecto.Changeset.fetch_field!(@changeset, :my_field)}`. With the form struct, it becomes: `value={@form[:my_field].value}`

A full before and after might look like this.

Before:

```elixir
@impl true
def mount(_params, _session, socket) do
  default_assigns = %{
    changeset: Petacular.Pet.create_changeset(%{})
  }

  {:ok, assign(socket, default_assigns)}
end

~H"""
<.form :let={f} for={@changeset} phx-submit="...">
  <%= <Phoenix.HTML.Form.text_input(f, :my_field, value: Ecto.Changeset.fetch_field!(@changeset, :my_field)) %>
</.form>
"""
```

And after:

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

  {:ok, assign(socket, default_assigns)}
end

~H"""
<.form for={@create_form} phx-submit="...">
  <input type="text" name={@create_form[:my_field]} value={@create_form[:my_field].value} />
</.form>
"""
```

Note how we don't need `HTML.text_input` anymore, and we don't have to reach into the changeset.

[Check out the official Phoenix docs for more information](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#form/1-using-the-for-attribute).

### Using `to_form`

First, set up a form in `mount`:

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

  {:ok, assign(socket, default_assigns)}
end
```

Now we can build a form and use the pre-built inputs. These accept the field from the form (which is as easy as `field={@create_form[:name]}`).

```elixir
<PetacularWeb.CoreComponents.modal id="create_modal">
  <h2>Add a pet.</h2>

  <PetacularWeb.CoreComponents.simple_form
    for={@create_form}
    phx-submit="create_pet"
  >
    <PetacularWeb.CoreComponents.input field={@create_form[:name]} label="Name" />
    <:actions>
      <PetacularWeb.CoreComponents.button>
        Save
      </PetacularWeb.CoreComponents.button>
    </:actions>
  </PetacularWeb.CoreComponents.simple_form>
</PetacularWeb.CoreComponents.modal>
```

### Saving the Form

If we save and refresh the form, we should see that we can click a button and enter a name into it. The final thing to do to get this working is to write an event handler for the form submission.

Here is the first gotcha. We are using changesets to validate our forms. Because live views are stateful, we might be tempted to re-use the changeset in our assigns when we submit the form. This is not a good idea. What's worse, `Ecto.Changeset.cast` et al. will happily accept a changeset as input, meaning it's a very easy trap to fall into without realizing you are doing something wrong. The symptom might be errors in your changeset not disappearing as you expect, for example.

This trap is harder to fall into if you use `to_form` because the changeset is not in the assigns — the form struct is. This is another good reason to use `to_form`!

So every time we want to cast some params, we must create a fresh changeset. Let's do that:

```elixir
# in lib/petacular_web/pages/home_live.ex
@impl true
def handle_event("create_pet", %{"pet" => params}, socket) do
  case Repo.insert(Petaclular.Pet.create_changeset(params)) do
    {:error, message} ->
      {:noreply, socket |> put_flash(:error, inspect(message))}

    {:ok, _} ->
      new_assigns = %{}
      {:noreply, assign(socket, new_assigns)}
  end
end
```

In the case of an error, we add a flash that shows it to the user, and when we successfully complete, we need to do two things:

1. Show the created pet.
2. Close the modal.

To show the new pet, we first need to render all pets on the page. So we will fetch them all from the DB in mount and then add some code to render them:

```elixir
# in lib/petacular_web/pages/home_live.ex
@impl true
def mount(_params, _session, socket) do
  default_assigns = %{
    pets: Repo.all(Petacular.Pet),
    create_form: Phoenix.Component.to_form(Petacular.Pet.create_changeset(%{}))
  }

  {:ok, assign(socket, default_assigns)}
end

...

<h1 class="font-semibold text-3xl mb-4">Pets</h1>

<div class="w-50 mb-4">
  <%= for pet <- @pets do %>
    <p>Name: <span class="font-semibold"><%= pet.name %></span></p>
  <% end %>
</div>
```

Now that we can see them, we can refresh the list of pets when we add one successfully:

```elixir
# in lib/petacular_web/pages/home_live.ex
@impl true
def handle_event("create_pet", %{"pet" => params}, socket) do
  case Repo.insert(Petacular.Pet.create_changeset(params)) do
    {:error, message} ->
      {:noreply, socket |> put_flash(:error, inspect(message))}

    {:ok, _} ->
      new_assigns = %{
        pets: Repo.all(Petacular.Pet)
      #  ^^^ add new
      }

      {:noreply, assign(socket, new_assigns)}
  end
end
```

This is great. If we try adding a pet, we will see the pet gets created and appears on the page, but the modal remains open.

### Closing the Modal with `push_event`

Once we have successfully added a pet, we want to close the modal. There is already a "close the modal" button on the modal; the little `X` in the top right-hand corner. What if we could just target that?

Well, we can! There is a function called [`push_event`](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#push_event/3) that lets us emit a JS event from the backend. We can add some JavaScript that listens for that event and triggers a "click" event for the close button, effectively closing the modal.

First, we call `push_event` in the event handler:

```elixir
# in lib/petacular_web/pages/home_live.ex
@impl true
def handle_event("create_pet", %{"pet" => params}, socket) do
  case Repo.insert(Petacular.Pet.create_changeset(params)) do
    {:error, message} ->
      {:noreply, socket |> put_flash(:error, inspect(message))}

    {:ok, _} ->
      new_assigns = %{
        pets: Repo.all(Petacular.Pet),
        create_form: Phoenix.Component.to_form(Petacular.Pet.create_changeset(%{}))
        # reset the form back to being empty ^^^
      }

      socket =
        socket
        |> assign(new_assigns)
        |> push_event("close_modal", %{to: "#close_modal_btn_create_modal"})
        #  ^^^^ add this
      {:noreply, socket}
  end
end
```

Now to react to the event, we have two options:

1. Add a global event listener for the event.
2. Use a hook.

Let's try the second approach. First, we'll wire up the hook in `app.js`:

```js
const ModalCloser = {
  mounted() {
    this.handleEvent("close_modal", () => {
      this.el.dispatchEvent(new Event("click", { bubbles: true }));
    });
  },
};

let liveSocket = new LiveSocket("/live", Socket, {
  params: {
    _csrf_token: csrfToken,
  },
  hooks: {
    ModalCloser: ModalCloser,
  },
});
```

Now let's put the hook onto the close modal button in `PetacularWeb.CoreComponents` (remember, anything that has a hook also needs an ID). To help us later cater for multiple modals on one page, let's use the required `@id` attr as part of the id:

```elixir
<button
  id={"close_modal_btn_" <> @id}
  phx-hook="ModalCloser"
  <!-- ^^ Add these ^^ -->
  phx-click={JS.exec("data-cancel", to: "##{@id}")}
  type="button"
  class="-m-3 flex-none p-3 opacity-20 hover:opacity-40"
  aria-label={gettext("close")}
>
  <.icon name="hero-x-mark-solid" class="h-5 w-5" />
</button>
```

You can see all the changes in [this commit](https://github.com/Adzz/petacular/commit/91e8b86e1f518409de39c1d964f978b03723d52a). Fantastic, we now have a working create modal! 🎉

## Wrapping Up

In the second part of this series, we successfully added a form to the modal that creates new pets.

Stay tuned for the third and final part, where we will edit an existing pet.

Until then, 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](/elixir-alchemy)!**