Adz
Posted on August 8, 2023
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>
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" />
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>
"""
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>
"""
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
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>
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
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:
- Show the created pet.
- 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>
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
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
Now to react to the event, we have two options:
- Add a global event listener for the event.
- 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,
},
});
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>
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!
Posted on August 8, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.