Go slowly   About  Contact  Archives

Maintain Ecto many-to-many associations in Phoenix template

While building Today collection feature for Memoet, I encounter a many-to-many relation problem in Ecto: how to edit the relationship using only Phoenix template form.

The models

We got a Deck model, which contains many notes of the same topic, as follow:

schema "decks" do
  field(:name, :string)
  has_many(:notes, Note)

  timestamps()
end

Now we create a new Collection model, which will contains many decks for us to learn all notes in those decks at once, aka cross-deck learning:

schema "collections" do
  field(:name, :string)

  timestamps()
end

Now a collection will contains many decks, and a deck may belong to many collections, so that is a typical many-to-many relation. We define a mediate model for that:

schema "decks_collections" do
  belongs_to(:collection, Collection)
  belongs_to(:deck, Deck)

  timestamps()
end

We will work mostly from the collection onward, i.e. adding decks from one collection, not adding collections to one deck, so we only need to add the association to Collection model:

schema "collections" do
  field(:name, :string)

  has_many(:decks_collections, DeckCollection, on_replace: :delete)
  has_many(:decks, through: [:decks_collections, :deck], on_replace: :delete)

  timestamps()
end

def changeset(col_or_changeset, attrs) do
  col_or_changeset
  |> cast(attrs, [:name])
  |> cast_assoc(:decks_collections)
end

cast_assoc in the changeset function is responsible for creating new or updating the old relations when in need. And we use has_many with on_replace: :delete to replace all relations at once for that matter.

If you want to add collections to a deck, define similar associations in the Deck model.

The models are all looking good now.

The template

To display current decks in one collection and allow users to edit the relations, we need two lists of decks:

all_decks = Deck
            |> Repo.all()
col_decks = collection.decks

And in the collection’s edit template, we display the relation using checkbox inputs:

<%= for deck <- @all_decks do %>
  <label
    <input
      <%= if Deck.member?(@col_decks, deck), do: "checked" %>
      name="collection[deck_ids][]"
      type="checkbox"
      value="<%= deck.id %>"
    >
    <%= deck.name %>
  </label>
<% end %>

Data for collection[deck_ids][] name will become %{"collection" => %{"deck_ids" => [1, 2, 3]}} when passing to controller. And we got the deck IDs we need to keep only these decks in the collection. So in our controller, we got:

def update(conn, %{"collection" => collection_data, "id" => collection_id} = _params) do
  decks_collections =
    case collection_data do
      %{"deck_ids" => deck_ids} ->
        deck_ids
        |> Enum.map(fn deck_id ->
          %{
            "deck_id" => deck_id,
            "collection_id" => collection_id,
          }
        end)

      _ ->
        []
    end

  params = %{
    "decks_collections" => decks_collections
  }

  Collection
  |> Repo.get!(collection_id)
  |> Collection.changeset(params)
  |> Repo.update()
end

When we pass %{"decks_collections" => [%{"deck_id" => 1, "collection_id" => 1}]} to changeset function, Ecto will handle all the hard works for us, following the rules we have defined before in our models.

The end

That’s it. All is done without a single line of JavaScript. Visit memoet.manhtai.com and see the Today collection for yourself!

Written on May 30, 2021.