7 GUIs: Implementing a CRUD App in LiveView
This is the fifth post handling the 7 GUI tasks in LiveView. Here are some highlights of implementing a CRUD application.
The CRUD app
When I thought of CRUD, I thought this would be an easy exercise. After all, I’ve been building CRUD applications since before Elixir was a language. But building the required CRUD UI had some surprising challenges because all the functionality exists on a single page where several forms interact with the same data.
These are the task’s requirements:
- Build a page where we can create, update, and delete users.
- It should have two text fields: first name and last name. We’ll use them to create or update a user.
- We should see a list of all users (by last name, first name).
- We should be able to select a user from the list to edit or delete. We should only be able to select one user at a time.
- The page should allow searching for a specific user with the “prefix filter”. The search must filter the names by the last names that start with the prefix. And the search should happen immediately, without hitting enter.
- The update and delete buttons should only be enabled if we have selected an entry.
A first implementation
Because the form has three potential actions (create, update, and delete), I
initially thought I wouldn’t be able to use Phoenix’s form helpers with Ecto’s
changesets (since each form can only have one phx-submit
event). So, my first
inclination was to have something like React’s controlled components for all
fields. That meant every field had to have an assign:
socket
|> assign(:users, CRUD.list_users()) # list of existing users
|> assign(:current_user_id, nil) # when we select a user
|> assign(:first_name, "")
|> assign(:last_name, "")
I would then trigger events when any of those changed. For example, I had a text
input for the first name field where the value
was the @first_name
assign:
<input phx-blur="set-first-name" type="text" value="<%= @first_name %>">
Changes to that input would trigger a “set-first-name” event, and a
corresponding handle_event/3
function would update the name:
def handle_event("set-first-name", %{"value" => name}, socket) do
socket
|> assign(:first_name, name)
|> noreply()
end
Selecting a user was a little more complicated. When a user is selected, I wanted the UI to have the first and last name fields pre-filled with that user’s data. For the UI to do that, I had to change three assigns at the same time. That seemed like a code smell because we had to keep assigns synchronized:
def handle_event("select-user", %{"value" => user_id}, socket) do
user_id = String.to_integer(user_id)
user = find_user(socket.assigns.users, user_id)
socket
|> assign(:current_user_id, user_id)
|> assign(:first_name, user.first_name)
|> assign(:last_name, user.last_name)
|> noreply()
end
I also had three main events: one for creating a user, one for updating a user, and one for deleting the user. Because the first and last name fields were kept in different assigns, I had to build the user parameters when creating or updating a user. Here’s what the update handle event function looked like:
def handle_event("update", _, socket) do
user = find_user(socket.assigns.users, socket.assigns.current_user_id)
params = user_params(socket)
{:ok, updated_user} = CRUD.update_user(user, params)
socket
|> update(:users, fn users ->
Enum.map(users, fn
user when user.id == updated_user.id ->
updated_user
user ->
user
end)
end)
|> noreply()
end
defp user_params(socket) do
%{"first_name" => socket.assigns.first_name, "last_name" => socket.assigns.last_name}
end
Finally, I added a filter form at the top of the page to filter a list of users. The form triggered a “filter-list” event on change, and the handle event callback did something terrible:
def handle_event("filter-list", %{"filter" => text}, socket) do
socket
|> update(:users, fn users ->
Enum.filter(users, fn user -> String.starts_with?(user.last_name, text) end)
end)
|> noreply()
end
Did you spot the terrible part?
The handle event filtered the users
assign directly. It worked the first time
we searched for someone. But as soon as we made a typo or decided to filter for
a different name, we no longer had the full list of users! We had overridden the
list of users in memory, and we could only get it back by refreshing the page.
So, as a brute-force remedy, I introduced yet another assign to keep track of the filtered users:
users = CRUD.list_users()
socket
|> assign(:users, users) # canonical list of users
|> assign(:filtered_users, users) # filtered list of users
|> assign(:current_user_id, nil)
|> assign(:first_name, "")
|> assign(:last_name, "")
I rendered the list of @filtered_users
:
<select class="appearance-none" name="selected_user" size="<%= length(@users) %>">
<%= for user <- @filtered_users do %>
<option phx-click="select-user" id="user-<%= user.id %>" value="<%= user.id %>"><%= user.last_name %>, <%= user.first_name %></option>
<% end %>
</select>
And I kept having to repopulate the filtered_users
with the “canonical”
users
assigns every time we handled a filter event.
That implementation of the CRUD task worked… sometimes 😅, but it had many edge cases, and it felt very wrong. I had to keep assigns in sync, I had two copies of user data, and I couldn’t use the ergonomics of Phoenix forms and Ecto changesets, which made showing validation errors difficult. So, having learned about my domain, I went back to the drawing board.
Removing filtered_users
assigns
I first removed the filtered_users
assign. Like I mentioned above, filtering
the users
assign would make us lose data, so we had to keep resetting the
filtered_users
with the “canonical” users
assign. I didn’t like keeping two
copies of users data in memory, and it seemed like a code smell.
When I stopped to think about the problem, I realized that filtering users is
strictly a display concern and need not have an in-memory representation backing
it up. So, instead of filtering through our users when we handled the
“filter-list” event, I just kept track of the filter text in a filter
assign:
def handle_event("filter-list", %{"filter" => text}, socket) do
socket
|> assign(:filter, text)
|> noreply()
end
We can now filter our @users
when we render them:
<select class="appearance-none" name="selected_user" size="<%= length(@users) %>">
<%= for user <- filter_users(@users, @filter) do %>
<option phx-click="select-user" id="user-<%= user.id %>" value="<%= user.id %>"><%= user.last_name %>, <%= user.first_name %></option>
<% end %>
</select>
Where filter_users/2
looks like this:
defp filter_users(users, filter) do
Enum.filter(users, fn user -> String.starts_with?(user.last_name, filter) end)
end
That was much simpler, and it removed the need for a @filtered_users
assign.
✅
Separating new users from existing users
I next wanted to distinguish between working with a new user to create it and working with an existing user to update or delete it. If I could do that, I knew I’d be able to render two different forms (depending on whether we’re creating a new user or updating an existing one). And that would let us use all the powers and ergonomics that come from combining Phoenix forms with Ecto changesets.
So, I updated my mental domain model of user changes to look something like this:
@type t ::
{:new_user, Ecto.Changeset.t()}
| {:selected_user, User.t(), Ecto.Changeset.t()}
We either have a :new_user
and a changeset for a brand new user. Or we have a
:selected_user
, the user that was selected, and a changeset to update that
user.
To make that domain model a reality, I exposed a couple of helper functions in
my CRUD
module to help encapsulate those concepts:
def new_user_changes, do: {:new_user, User.changeset(%User{})}
def selected_user_changes(%User{} = user), do: {:selected_user, user, User.changeset(user)}
That meant the assigns in mount/3
could be simplified to only include the list
of users, the filter text, and a set of user changes:
def mount(_, _, socket) do
users = CRUD.list_users()
{:ok,
assign(socket,
users: users,
filter: "",
user_changes: CRUD.new_user_changes()
)}
end
Now, when we select a user in the UI, we stop dealing with a new user and start
dealing with an existing user. The transition between those two states falls
naturally in our “select-user” handle_event/3
callback:
def handle_event("select-user", %{"value" => user_id}, socket) do
user_id = String.to_integer(user_id)
user = find_user(socket.assigns.users, user_id)
socket
|> assign(:user_changes, CRUD.selected_user_changes(user))
|> noreply()
end
Our template becomes a little bit more complicated, but it’s very declarative, and I like that.
We add a case
statement that exhaustively handles all possible
@user_changes
. Since each of the cases is a tagged tuple (like {:new_user,
changeset}
), we use pattern matching to get the changeset
s that we’ll use in
our forms:
<%= case @user_changes do %>
<% {:new_user, changeset} -> %>
<%= f = form_for changeset, "#", [id: "new-user", phx_change: :update_params, phx_submit: "create-user"] %>
<%= text_input f, :first_name %>
<%= error_tag f, :first_name %>
<%= text_input f, :last_name %>
<%= error_tag f, :last_name %>
<div class="mt-10 space-x-2">
<%= submit "Create" %>
<button id="update" type="button" disabled>Update</button>
<button id="delete" type="button" disabled>Delete</button>
</div>
</form>
<% {:selected_user, _user, changeset} -> %>
<%= f = form_for changeset, "#", [id: "update-user", phx_change: :update_params, phx_submit: "update-user"] %>
<%= text_input f, :first_name %>
<%= error_tag f, :first_name %>
<%= text_input f, :last_name %>
<%= error_tag f, :last_name %>
<div class="mt-10 space-x-2">
<button id="create" type="button" disabled>Create</button>
<%= submit "Update" %>
<button id="delete" type="button" phx-click="delete-user">Delete</button>
</div>
</form>
<% end %>
But though the whole is more complicated, our forms gain a lot of power because we know the context in which we render them.
For the {:new_user, changeset}
case, we render a form to create a user. The
form has a phx_submit: "create-user"
event attached to it, which is
unambiguous since we know we’re creating a user. And we’re able to disable the
“Update” and “Delete” buttons confidently — one of the task’s
requirements.
For the {:selected_user, user, changeset}
case, we render a form that has a
phx_submit: "update-user"
event, which is (once again) unambiguous since any
updates to the first and last name must deal with updating a user.
Finally, we have a phx-click="delete-user"
event on the “Delete” button which
can delete the selected user that we keep in memory as the second element of
{:selected_user, user, changeset}
.
Both forms get validations errors thanks to Phoenix form helpers working with
Ecto changesets. And we can now show validation errors on change by adding a
phx-change="update_params"
event, which gets its own handle_event/3
callback:
def handle_event("update_params", %{"user" => params}, socket) do
socket
|> assign(:user_changes, CRUD.user_changes(socket.assigns.user_changes, params))
|> noreply()
end
CRUD.user_changes/2
keeps our changesets up to date, and it includes the
correct repo action
we’ll perform so that any validation errors show up on the
Phoenix form:
def user_changes({:new_user, _changeset}, params) do
{:new_user, %User{} |> User.changeset(params) |> Map.put(:action, :insert)}
end
def user_changes({:selected_user, user, _changeset}, params) do
{:selected_user, user, user |> User.changeset(params) |> Map.put(:action, :update)}
end
Finally, our handle_event/3
callbacks for creating, updating, or deleting a
user are now straightforward since it’s clear which form we’re dealing with and
since we have a changeset ready in our user_changes
assign:
# Create a user
def handle_event("create-user", _, socket) do
case CRUD.create_user(socket.assigns.user_changes) do
{:ok, user} ->
socket
|> update(:users, fn users -> [user | users] end)
|> assign(:user_changes, CRUD.new_user_changes())
|> noreply()
{:error, user_changes} ->
socket
|> assign(:user_changes, user_changes)
|> noreply()
end
end
# Update a user
def handle_event("update-user", _, socket) do
case CRUD.update_user(socket.assigns.user_changes) do
{:ok, updated_user} ->
socket
|> update(:users, &replace_updated_user(&1, updated_user))
|> assign(:user_changes, CRUD.new_user_changes())
|> noreply()
{:error, user_changes} ->
socket
|> assign(:user_changes, user_changes)
|> noreply()
end
end
# Delete a user
def handle_event("delete-user", _, socket) do
{:ok, deleted_user} = CRUD.delete_user(socket.assigns.user_changes)
socket
|> update(:users, &remove_deleted_user(&1, deleted_user))
|> assign(:user_changes, CRUD.new_user_changes())
|> noreply()
end
This second implementation satisfies all the requirements of the task, has a very declarative UI, has no duplicate data in assigns, and uses all the power of Phoenix forms and Ecto changesets. I like it a lot more than the first pass.
Resources
These are links to the repo with all my examples and the commit for the CRUD app:
You can also find my posts for the previous tasks:
And for a full description of all tasks, take a look at the 7 GUIs website.