Phoenix forms with nested relations and Ecto changesets

Updated on: Tue Dec 19 2023

Hello there! Recently I was working on some project and I had to build edit functionality for entity which has child relations to other entity. Turned out this could be achieved in very elegant way as it is usually the case for phoenix elixir projects. But it was not obvious for me because surprisingly phoenix elixir examples are not thrown here and there without any order. We have to find them.

I also don't have too much of experience in phoenix elixir development so it took me a while to understand how to implement my idea. So I want to document my findings in a kinda tutorial mode just to not forget in the future and may be it will be useful for somebody else.

P.S. Lot's of insights I got from this flawless but a bit outdated gist here. Cheers mate!

Scaffolding

So let's start from the beginning and initialize new phoenix project:

copied bash

mix phx.new my_test_app

We will rely on Ecto functionality here so we need a database. I recommend using docker for local development and bootstrapping because this is what I usually do by myself. To make this happen we can use information from this post. Let's change directory to the one with our app source code, create the postgres docker configuration there and run the database server in container.

copied bash

cd my_test_app
touch docker-compose.dev.yml
# add yml configuration to the file
docker-compose -f docker-compose.dev.yml up -d

Now we have postgres instance running. To finalise initial setup we need to configure database connection in config/dev.exs, run create database command and actually launch development server

copied bash

mix ecto.create
mix phx.server

If we navigate to http://localhost:4000 we should be able to see now our phoenix app working.

Let's generate some schemas now which we'll rely some functionality shortly.

copied bash

mix phx.gen.context Categories Category categories text:string
mix ecto.migrate

mix phx.gen.context Posts Post posts text:string category_id:references:categories
mix ecto.migrate

Now we should have this two beautiful schemas with their contexts:

copied ruby


defmodule MyTestApp.Posts.Post do
  use Ecto.Schema
  import Ecto.Changeset

  schema "posts" do
    field :text, :string
    field :category_id, :id
    timestamps()
  end

  @doc false
  def changeset(post, attrs) do
    post
    |> cast(attrs, [:text])
    |> validate_required([:text])
  end
end


defmodule MyTestApp.Categories.Category do
  use Ecto.Schema
  import Ecto.Changeset

  schema "categories" do
    field :text, :string

    timestamps()
  end

  @doc false
  def changeset(category, attrs) do
    category
    |> cast(attrs, [:text])
    |> validate_required([:text])
  end
end

Our goal here is to be able to edit category together with it's posts at one moment. Let's start from something simple and then will add more functionality on top of it. First of all we'll create live view to create new categories without posts. Next step we'll make it possible to edit categories. And as the last steps we'll add functionality to edit posts inside the category and update it in one round trip.

Live view module to create category

Assuming we are in our my_test_app directory we need to create module lib/my_test_app_web/live/category_live.ex with the code from following listing:

copied ruby

defmodule MyTestAppWeb.CategoryLive do
  use MyTestAppWeb, :live_view

  alias MyTestApp.Categories
  alias MyTestApp.Categories.Category

  def mount(params, _session, socket) do
    socket = socket |> init_form(params)
    {:ok, socket}
  end

  def init_form(socket, %{}) do
    socket |> assign_new()
  end

  defp assign_new(socket) do
    changeset =
      %Category{
        text: ""
      }
      |> Categories.change_category()

    socket |> assign_form(changeset)
  end

  defp assign_form(socket, changeset) do
    form = to_form(changeset, as: "category")
    socket |> assign(form: form)
  end
end

This code should be pretty self explanatory. One note here is may be for now it seems a bit splitted to too many functions but we'll utilise them when start implementing edit functionality.

Next we need to create a template for this live view. I like to keep template out of main live view module. This remembers me about good old times. Here is the listing of category_live.html.heex

copied html

<.simple_form for={@form}>
  <.input field={@form[:text]} label="Text" />
  <:actions>
    <.button>Submit</.button>
  </:actions>
</.simple_form>

We use here components provided by phoenix utils componets module core_components. I find them pretty useful especially in experimenting and rnd.

As the last step here we need to configure route to be able to navigate to our live view. Go to router.ex and make changes from the listing below:

copied ruby

...
get "/", PageController, :home
live "/category/new", CategoryLive # <-- add this line
...

After this restart your devserver, navigate to http://localhost:4000/category/new and we'll see somewhere on the screen UI similar to the one on the picture

By default simple_form handles submit action via POST request to the route where it's rendered on. We want to utilise live view functionality properly so let's bind our form to CategoryLive. For this we need to send event from template on submit and handle this event in live view module.

To send event template we need to add phx-submit attribute to the simple-form:

copied html

<.simple_form for={@form} phx-submit="create-item">
  ...
</.simple_form>

To handle event in module we need to implement handle_event hook:

copied ruby

defmodule MyTestAppWeb.CategoryLive do
  ...

  def handle_event("create-item", %{"category" => payload}, socket) do
    case Categories.create_category(payload) do
      {:ok, _category} ->
        {:noreply, socket |> put_flash(:info, "Category created successfully")}

      {:error, changeset} ->
        {:noreply, socket |> assign_form(changeset)}
    end
  end
end

What is going on here in couple of words: on submit template sends event with name "create-item" and payload %{"category": all-params-which-are-part-of-the-form}. Remember we configured as: "category" param in to_form function call? That's why we can get all payload in one pattern match.
Inside the event handler we are able to pass all payload without any changes directly to create_category helper which was generated for us by mix phx.gen.context and then pattern match on response.
If operation successful we just show notification that category was created.
If error happened flow goes to {:error, changeset} case where we assign changeset populated by errors to the form which automatically renders errors notifications for us. For example if we'll try just click the button without typing any text we should see error right away:

Edit category

Now let's adapt current live view module for it to be able to serve also an edit category functionality. Let's now start from router. We'll add one more route which will receive category id as route path parameter and we'll rely on it in our live view module.

copied ruby

get "/", PageController, :home
live "/category/new", CategoryLive
live "/category/:id", CategoryLive # <-- add this line

Now let's hook into this in our live view module:

copied ruby

defmodule MyTestAppWeb.CategoryLive do
  ...
  def init_form(socket, %{"id" => id}) do
    socket |> assign_existing(id)
  end

  defp assign_existing(socket, id) do
    item = id |> Categories.get_category!()
    changeset = item |> Categories.change_category()

    socket
    |> assign(:item, item)
    |> assign_form(changeset)
  end
  
  def handle_event("update-item", %{"category" => payload}, socket) do
    item = socket.assigns.item

    case Categories.update_category(item, payload) do
      {:ok, _category} ->
        {:noreply, socket |> put_flash(:info, "Category updated successfully")}

      {:error, changeset} ->
        {:noreply, socket |> assign_form(changeset)}
    end
  end
end

Here we utilised smart decisions which we made on step one. We do pattern match on id parameter via def init_form(socket, %{"id" => id}) and grab category from database to initialise changeset with instead of creating it from scratch and keep it in socket - we'll have to provide it to update helper.
Update item handler is almost the same as create one except we call update_category helper with payload and category item which was stored in socket before.

We also need to modify a bit our category_live.html.heex - we need to adjust phx-submit. It will now have some if condition logic to call trigger different event names based on presence of id of in the form:

copied html

<.simple_form for={@form} phx-submit={if @form[:id].value, do: "update-item", else: "create-item"}>
...
</.simple_form>

If we'll save the template and refresh browser while being on the category/1 route we'll see follwing error: using inputs_for for association `posts` from `MyTestApp.Categories.Category` but it was not loaded. Please preload your associations before using them in inputs_for.

This happens because we try to render nested relations of existing item and we forgot to preload them. Let's do it! To make this we need to use Repo.preload in Categories context:

copied ruby

def get_category!(id), do: Repo.get!(Category, id) |> Repo.preload(:posts)

Now after server restart error should disappear.

But at the moment we do not have any categories with posts because we do not have functionality to create categories with posts. Let's extend our create category functionality. First let's extend our raw category struct via providing some list of empty mocked posts:

copied ruby

  ...
  defp assign_new(socket) do
    changeset =
      %Category{
        text: "",
        posts: mock_posts() # <-- this line
      }
      |> Categories.change_category()

    socket |> assign_form(changeset)
  end
  ...
  
  defp mock_posts() do
    Enum.map(1..3, fn _ ->
      %Post{id: nil, text: ""}
    end)
  end

Now if we navigate to the actual http://localhost:4000/category/new we'll see our form is extended by 3 inputs to add texts of posts into them.

Let's try to fill them and click submit button. Wow! It worked! Category created successfully! Emm.. not really. If we navigate to category we just created http://localhost:4000/category/3 we'll see that it does not have posts inside and we'll see in console that no inserts of posts actually happened in database. Why? That's because we actually forgot one last important thing - we need to teach our Category changeset to deal with nested relations. Let's do this!
It is as simple as following listing:

copied ruby

  def changeset(category, attrs) do
    category
    |> cast(attrs, [:text])
    |> cast_assoc(:posts) # <-- add this line
    |> validate_required([:text])
  end

That's it folks! Please refer to official docs for additional information.

Full listing is available on github