In this post, we’ll use Ecto’s :sort_param and :delete_param options, along with LiveView, to sort and delete elements within a many-to-many association. Fly.io is a great place to run your Phoenix LiveView applications! Check out how to get started!
Ecto enables us to effortlessly work with different types of associations. In a many-to-many relationship, we can easily insert, modify, or delete elements. However, what if we want to sort the elements in a specific order? How can we remove specific records from an association?
Thankfully, Ecto has two new options to make that easier! When dealing with associations or embeds using cast_assoc/3 and cast_embed/3, respectively, we can use the :sort_param
and :drop_param
options. These options enable us to save associations in a specific order or delete associations based on their position.
But wait, there’s more! Passing these new parameters from LiveView is incredibly straightforward. In this post, we will leverage the power of LiveView and Ecto to sort and delete elements in a many-to-many relationship. Here’s what we’ll do:
- Define the necessary
Ecto.Schemas
. - Define the changesets that enable us to use the
:sort_param
and:drop_param
options. - Set up a form with the required inputs to populate a many-to-many association.
- Incorporate the magic of checkboxes into our form to sort and delete elements.
By the end, we will have achieved something like this:
Defining the Ecto Schemas
Let’s begin by defining our example. We’ll take inspiration from a bookstore scenario, where a book can have one or many authors, and an author can write one or many books. To represent this, we’ll define three schemas: books
, authors
, and author_books
.
First, we define the author_books
schema, which serves as the intermediate table in our many-to-many relationship:
defmodule ComponentsExamples.Library.AuthorBook do
use Ecto.Schema
import Ecto.Changeset
alias ComponentsExamples.Library.{Author, Book}
schema "author_books" do
field :position, :integer
belongs_to :author, Author, primary_key: true
belongs_to :book, Book, primary_key: true
timestamps()
end
end
This schema is straightforward. It has two relations, :author
and :book
, to preload information about the author and book, respectively. Additionally, note the :position
field. This field helps us store the author’s position, enabling us to preload the authors of a book in the order specified by their positions. We’ll explore this in more detail shortly.
Now we define the books schema:
defmodule ComponentsExamples.Library.Book do
use Ecto.Schema
import Ecto.Changeset
alias ComponentsExamples.Library.AuthorBook
schema "books" do
field :price, :decimal
field :publication_date, :utc_datetime
field :title, :string
has_many :book_authors, AuthorBook,
preload_order: [asc: :position],
on_replace: :delete
has_many :authors, through: [:book_authors, :author]
timestamps()
end
end
The books
schema consists of three fields: title
, price
, and publication_date
. We have also defined two associations that work neatly together.
The first association, has_many: book_authors
, enables us to save and query the relationship between books and authors using the AuthorBook
schema. Two essential options have been included:
- The
:preload_order
option ensures authors are sorted based on the earlier mentioned:position
field, in ascending order, when preloaded. - The
on_replace: :delete
setting ensures that any association element not included in the parameters sent will be deleted.
It’s important to note that the option on_replace: :delete
is required for the sort and delete operations, and here’s why it makes sense: Since the client is modifying and deleting children, it becomes necessary for them to provide the full listing. Any elements missing from the listing are considered discarded because there is no way to know if the “old” ones should be preserved or not.
The second association, has_many: authors
, enables us to conveniently preload the authors of a book. We achieve this by using the associations we mentioned earlier: [:book_authors, :author]
. The :book_authors
association guarantees that the authors are already listed according to their positions. It’s a good trick, isn’t it?
Next, we need to define the author schema, which, for this example, does not have any specific constraints:
defmodule ComponentsExamples.Library.Author do
use Ecto.Schema
import Ecto.Changeset
alias ComponentsExamples.Library.AuthorBook
schema "authors" do
field :bio, :string
field :birth_date, :string
field :gender, :string
field :name, :string
timestamps()
many_to_many :books, AuthorBook, join_through: "author_books"
end
end
With this, we have defined the necessary schemas to model the bookstore scenario.
Using :drop_param and :sort_param
Let’s start by defining the Book.changeset/3
function, to create or modify a book and its associated authors:
def changeset(book, attrs) do
book
|> cast(attrs, [:title, :publication_date, :price])
|> validate_required([:title, :publication_date, :price])
|> cast_assoc(:book_authors,
with: &AuthorBook.changeset/3,
sort_param: :authors_order,
drop_param: :authors_delete
)
end
Within this function, we specify the names of the parameters used for sorting and deleting elements within the :book_authors
association: :authors_order
and :authors_delete
. Additionally, we use the :with
option, which accepts a function with arity 3 to create the child records. The third argument passed to the function contains the position of each child element.
Warning: Make sure that you are using Ecto v3.10.2
or a newer version. If you attempt to send a function with arity 3 in the :with
option on older versions, you may encounter an error.
Now, let’s save the position in our :author_books
table. To accomplish this, we define the AuthorBook.changeset/3
function:
def changeset(author_book, attrs, position) do
author_book
|> cast(attrs, [:author_id, :book_id])
|> change(position: position)
|> unique_constraint([:author, :book], name: "author_books_author_id_book_id_index")
end
In this function, we ensure to handle the position passed as the third argument to our changeset function. We cast the attributes :author_id
and :book_id
, then modify the changeset to include the position. Finally, we enforce a unique constraint on the combination of :author_id
and :book_id
using the unique_constraint
function.
With this, our work with Ecto.Changeset
and Ecto.Schema
is complete. Now let’s explore how to send :authors_order
and :authors_delete
from a form.
Using inputs_for
to populate a has_many association
In this example, we assume that there are existing authors in our database, and we need to provide the user with options to choose the author(s) of a book. To achieve this, we first create a book changeset with at least one book_author
in the :book_authors
association. We then build a form using this changeset.
Let’s prepare our assigns:
def update(%{book: book} = assigns, socket) do
book_changeset = Library.change_book(book)
socket =
socket
|> assign(assigns)
|> assign_form(book_changeset)
|> assign_authors()
{:ok, socket}
end
The crucial part is the assign_form/2
function, where we build a form for the Book
. If the book does not have any author in the :book_authors
association, we create an empty %AuthorBook{}
and include it as a single author in the book’s :book_authors
association —this allows us to render at least one input to fill in the author information. Finally, we convert this modified changeset into a form:
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
if Ecto.Changeset.get_field(changeset, :book_authors) == [] do
book_author = %AuthorBook{}
changeset = Ecto.Changeset.put_change(changeset, :book_authors, [book_author])
assign(socket, :form, to_form(changeset))
else
assign(socket, :form, to_form(changeset))
end
end
To provide the user with a selection of authors from the database, we’ll use a select input. The select input expects a list of options in the format {label, value}
. We assign these options using the assign_authors/1
function:
defp assign_authors(socket) do
authors =
Library.list_authors()
|> Enum.map(&{&1.name, &1.id})
assign(socket, :authors, authors)
end
In this function, we fetch the authors from the database using Library.list_authors()
. We then transform each author into a {label, value}
tuple, where the label represents the author’s name and the value corresponds to the author’s ID. Finally, we assign the authors
list to the socket for use in the template.
With our assigns prepared, we can now define the inputs to send the attributes of the :book_authors
association.
def render(assigns) do
~H"""
<div>
<.simple_form
for={@form}
phx-change="validate"
phx-submit="save"
phx-target={@myself}
>
...
<div id="authors" phx-hook="SortableInputsFor" class="space-y-2">
<.inputs_for :let={b_author} field={@form[:book_authors]}>
<div class="flex space-x-2 drag-item">
<.icon name="hero-bars-3" data-handle />
<.input
type="select"
field={b_author[:author_id]}
placeholder="Author"
options={@authors}
/>
</div>
</.inputs_for>
</div>
<:actions>
<.button phx-disable-with="Saving...">
Save
</.button>
</:actions>
</.simple_form>
</div>
"""
end
We use the function component <.inputs_for>
to populate the @form[:book_authors]
association. Within this component, we access the attributes of each individual :book_authors
using the b_author
variable.
We create a select input to populate the b_author[:author_id]
field, passing in the list of authors as available options.
With this, we are now able to send information about books and authors. However, we still need to make one final modification to send our :sort_param
and :delete_param
parameters.
Checkboxes magic
:drop_param
and :delete_param
require a list of numerical indexes to identify the elements that need to be reordered or deleted. To send this list of indexes, we need to include the position of each b_author
element.
To do this, we add a hidden input inside the <.inputs_for>
component for each b_author
. This hidden input will send the value of the element’s position using value={b_author.index}
:
def render(assigns) do
~H"""
<div>
<.simple_form
for={@form}
phx-change="validate"
phx-submit="save"
phx-target={@myself}
>
...
<div id="authors" phx-hook="SortableInputsFor" class="space-y-2">
<.inputs_for :let={b_author} field={@form[:book_authors]}>
<div class="flex space-x-2 drag-item">
<.icon name="hero-bars-3" data-handle />
+ <input
+ type="hidden"
+ name="book[authors_order][]"
+ value={b_author.index}
+ />
<.input
type="select"
field={b_author[:author_id]}
placeholder="Author"
options={@authors}
/>
</div>
</.inputs_for>
</div>
<:actions>
<.button phx-disable-with="Saving...">
Save
</.button>
</:actions>
</.simple_form>
</div>
"""
end
The naming convention of this input is crucial. As the form is built from a Book
changeset, the input names reflect this structure name. For our example, each input name starts with “book” followed by square brackets and the attribute name being filled, such as name="book[publication_date]"
.
It’s important to note that this convention holds true unless you have specifically set up the :as
option of the to_form/2
function and used a different prefix for the form inputs. For example: to_form(changeset, as: "my_form")
In the case of :authors_order
, which involves multiple inputs, each element is uniquely identified by appending an index. For instance, the name attribute would be represented as name="book[authors_order][]"
.
With this additional input, we can now manage the position of each element. Let’s see how we can add and remove elements from the assoc:
def render(assigns) do
~H"""
<div>
<.simple_form
for={@form}
phx-change="validate"
phx-submit="save"
phx-target={@myself}
>
...
<div id="authors" phx-hook="SortableInputsFor" class="space-y-2">
<.inputs_for :let={b_author} field={@form[:book_authors]}>
<div class="flex space-x-2 drag-item">
<.icon name="hero-bars-3" data-handle />
<input type="hidden" name="book[authors_order][]" value={b_author.index} />
<input
type="hidden"
name="book[authors_order][]"
value={b_author.index}
/>
+ <label>
+ <input
+ type="checkbox"
+ name="book[authors_delete][]"
+ value={b_author.index}
+ class="hidden"
+ />
+ <.icon name="hero-x-mark" />
+ </label>
</div>
</.inputs_for>
</div>
<:actions>
<.button phx-disable-with="Saving...">
Save
</.button>
+ <label class="block cursor-pointer">
+ <input
+ type="checkbox"
+ name="book[authors_order][]"
+ class="hidden"
+ />
+ <.icon name="hero-plus-circle" /> add more
+ </label>
</:actions>
</.simple_form>
</div>
"""
end
To add elements to the book[authors_order][]
association, we use another checkbox input hidden inside a label with a plus icon. This input is essential to append new elements to the end of the list. It is placed outside the <.inputs_for>
function and after it. These checkboxes send the index of each b_author
element as the value.
For deleting elements, we need to send the index of the element to be removed. Inside the <.inputs_for>
function, we add another checkbox input hidden inside a label with a hero-x-mark
icon. These checkboxes also send the index of each b_author
element as the value.
Let’s see how the information of these checkboxes is sent when the validate
or submit
events are triggered:
[debug] HANDLE EVENT "validate" in ComponentsExamplesWeb.LibraryLive
Component: ComponentsExamplesWeb.MulfiFormComponent
Parameters: %{"_target" => ["book", "authors_order"], "book" => %{"authors_order" => ["0", "on"], "book_authors" => %{"0" => %{"_persistent_id" => "0", "author_id" => "2", "book_id" => "2", "id" => "88"}}, "price" => "2323", "publication_date" => "2023-07-13T19:37", "title" => "Libro prueba"}}
[debug] Replied in 982µs
If we focus on the authors_order
attribute, we can see that a list ["0", "on"]
is sent. This list indicates that there are two authors, with indexes "0"
and "on"
… wait, what? Well, this weird “on” index is used to identify when we’ve just added a new item using our “add more” checkbox.
What if we have another checkbox, similar to the one we used to add new authors, but placed before the <.inputs_for>
component? In that case, when adding an element, the "on"
parameter would be at the beginning of the list: ["on", "0"]
.
Let’s now see what happens when we delete an element:
[debug] HANDLE EVENT "validate" in ComponentsExamplesWeb.LibraryLive
Component: ComponentsExamplesWeb.MulfiFormComponent
Parameters: %{"_target" => ["book", "authors_delete"], "book" => %{"authors_delete" => ["1"], "authors_order" => ["0", "1"], "book_authors" => %{"0" => %{"_persistent_id" => "1", "author_id" => "1"}, "1" => %{"_persistent_id" => "0", "author_id" => "2", "book_id" => "2", "id" => "88"}}, "price" => "2323", "publication_date" => "2023-07-13T19:37", "title" => "Libro prueba"}}
[debug] Replied in 944µs
In this example, the authors_delete
attribute is sent along with a list of indexes representing the elements to be deleted.
You may have noticed that both example logs include a _persistent_id
. This identifier serves as internal Phoenix book keeping to track our inputs effectively.
Awesome! We’ve got it all covered now! Adding and deleting elements from the association is a breeze. Just send the required info from our form, and our changesets and schemas will take care of the rest. Way to go!
(For a complete view of the code, please refer to this PR.)
Some Things to Keep in Mind
When using this approach to sort and delete items in a association, there are a few considerations worth noting:
- Make sure to preload the association elements before performing any updates. This ensures that the data is correctly ordered.
- It’s important to be aware that this approach relies on preloaded elements. If you want to order associations in a relationship but haven’t preloaded all the elements, manual handling of reordering will be necessary.
- While this example demonstrates the manipulation of a many-to-many relationship, you can easily adapt these concepts to work with simpler relationships like has-many.