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.