DEV Community

Cover image for Add a Form to a Modal in Phoenix 1.7
Adz for AppSignal

Posted on • Originally published at blog.appsignal.com

Add a Form to a Modal in Phoenix 1.7

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.

Adding a Form to Our Phoenix Application

There is a simple form 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:

# 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>
Enter fullscreen mode Exit fullscreen mode

The input components are also in PetacularWeb.CoreComponents 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:

<PetacularWeb.CoreComponents.input field={@create_form[:name]} label="Name" />
Enter fullscreen mode Exit fullscreen mode

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 function generates a Phoenix.HTML.Form
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:

@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>
"""
Enter fullscreen mode Exit fullscreen mode

And after:

@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>
"""
Enter fullscreen mode Exit fullscreen mode

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.

Using to_form

First, set up a form in mount:

@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
Enter fullscreen mode Exit fullscreen mode

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]}).

<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>
Enter fullscreen mode Exit fullscreen mode

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:

# 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
Enter fullscreen mode Exit fullscreen mode

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:

# 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>
Enter fullscreen mode Exit fullscreen mode

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

# 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
Enter fullscreen mode Exit fullscreen mode

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

# 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
Enter fullscreen mode Exit fullscreen mode

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:

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,
  },
});
Enter fullscreen mode Exit fullscreen mode

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:

<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>
Enter fullscreen mode Exit fullscreen mode

You can see all the changes in this commit. 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!

Top comments (0)