Has many through with checkboxes

Hello, I have a basic has many through relationship between a Task and a Label (through TaskLabel).

Labels can be created independently of a task, and these labels can be assigned to a task via checkboxes on the task create/update form.

I’ve been having difficulty coming up with both the form and changeset code. I have a (mostly) working solution, but I’m still not happy with it and I’m wondering if there’s a more canonical way to achieve what I want.

What I ended up going with is creating a virtual label_ids field which acts as a proxy for the IDs in the labels table. This avoids having to interact with the join table directly. Here’s the code for the schemas:

defmodule App.Tasks.Task do
  schema "tasks" do
    field :name, :string
    field :label_ids, {:array, :id}, virtual: true
    has_many :task_labels, App.Tasks.TaskLabel, on_replace: :delete
    has_many :labels, through: [:task_labels, :label]
    timestamps()
  end

  def changeset(task, attrs) do
    task
    |> cast(attrs, [:name, :label_ids])
    |> put_task_labels()
    |> cast_assoc(:task_labels)
  end

  defp put_task_labels(changeset) do
    label_ids = get_change(changeset, :label_ids, [])
    task_labels = Enum.map(label_ids, fn(id) -> %{label_id: id} end)
    put_change(changeset, :task_labels, task_labels)
  end
end

defmodule App.Labels.Label do
  use Ecto.Schema
  import Ecto.Changeset

  schema "labels" do
    field :name, :string
    has_many :task_labels, App.Tasks.TaskLabel
    has_many :tasks, through: [:task_labels, :task]
    timestamps()
  end

  def changeset(label, attrs) do
    label
    |> cast(attrs, [:name])
    |> put_task_labels()
    |> cast_assoc(:task_labels)
  end
end

defmodule App.Tasks.TaskLabel do
  use Ecto.Schema
  import Ecto.Changeset

  schema "task_labels" do
    belongs_to :task, App.Tasks.Task
    belongs_to :label, App.Labels.Label
    timestamps()
  end

  def changeset(task_label, attrs) do
    task_label
    |> cast(attrs, [:task_id, :label_id])
  end
end

And here’s the (live) form component code:

<%= f = form_for @changeset, "#",
  id: "task-form",
  phx_target: @myself,
  phx_submit: "save" %>

  <%= text_input f, :name, placeholder: "Task name" %>

  # @labels just returns all labels in the database
  <%= for label <- @labels do %>
    <label>
      <%= checkbox f, :label_ids, name: input_name(:task, :label_ids) <> "[]", checked_value: label.id, hidden_input: false, checked: label in @task.labels %>
      <span><%= label.name %></span>
    </label>
  <% end %>

  <%= submit "Save", phx_disable_with: "Saving..." %>
</form>

As I said, this does work but it also deletes existing join records and re-creates them (obviously because I’m using on_update: :delete).

My question is: is there a more canonical way to do this? The put_task_labels function (which effectively just coerces label ids into task_labels) feels wrong to me. I thought maybe I should be using inputs_for but that also doesn’t seem quite right since I’m not working with “nested” labels, I just want to assign/unassign labels to/from tasks.

Any help is greatly appreciated.

2 Likes