I have a Product schema with a many_to_many relation for Tags.
On the Edit Product Page I want to create a simple form that looks like this:
Name
[ Product A ]
Tags
[ Tag A ] [Tag B] [Tag C] [Tag D]
The bold Tags are the Tags that the Product has assigned, the other ones are not assigned yet. In my case its more user friendly to show all of the available tags because the number is quite limited.
The behaviour of the Tags should be pretty much like checkboxes, when you click them they should either be selected or unselected.
I want to have one “Save” button that becomes active ones the name or the tags have changed.
I tried to merge the approach of two very good tutorials to make this happen:
- Making a CheckboxGroup Input · The Phoenix Files
- Sorting and Deleting many-to-many assocs with Ecto and LiveView · The Phoenix Files
Both don’t really cover my UI case.
I also read this solution just now, which is what I want to achieve:
But as the author stated himself, also, I’m not too fond of view-related code in the Ecto shemas.
Here is my naive newbie approach, which nearly works:
Create Schemas
defmodule Shopex.Products do
schema "products" do
field :name, :string
many_to_many :tags, Shopex.Products.Tag,
join_through: "product_tag_relations",
on_replace: :delete
...
end
def changeset(product, attrs) do
product
|> cast(attrs, [:name,:archived_at])
|> validate_required([:name])
|> cast_assoc( :tags )
end
end
defmodule Shopex.Products.Tag do
schema "product_tags" do
field :name, :string
...
end
...
end
Create Live View Controller Edit Product
The get_product
function preloads the assigned Tags.
But I am also getting a full list of all available Tags and assigning it to the socket:
@impl true
def handle_params(%{"id" => id}, _, socket) do
product = Products.get_product!(id)
tags = Products.list_product_tags()
{:noreply,
socket
|> assign(:page_title, page_title(socket.assigns.live_action))
|> assign(:product,product)
|> assign(:tags, tags)
|> assign_form(Products.change_product(product))}
end
Create Simple Form View
<.simple_form
for={@form}
id="product-form"
phx-change="validate"
phx-submit="save"
>
<.input field={@form[:name]} type="text" label="Name" />
<.tags options={@tags} field={@form[:tags]}/>
<:actions>
<.button type="submit" phx-disable-with="Saving...">Save</.button>
</:actions>
</.simple_form>
Create Tags Component
I am creating in the first line a helper list to figure out if the checkbox further down should be selected or not.
That works on the first load. But as you will see further down it will fail as soon as I change one of the checkboxes.
def tags(assigns) do
selected_ids = Enum.map(assigns.field.value, &(&1.id) )
~H"""
<div>
<.label>Tags</.label>
<div class="flex gap-1">
<div class="flex item-center" :for={tag<- @options}>
<label
for={"#{@field.name}-#{tag.id}"} class="font-medium text-gray-700">
<input
type="checkbox"
id={"#{tag.id}"}
name={"#{@field.name}[]"}
value={tag.id}
checked={tag.id in selected_ids}
class="mr-2 h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 transition duration-150 ease-in-out"
/>
<%= tag.name %>
</label>
</div>
</div>
</div>
"""
end
Extend Validation Handler
def handle_event("validate", %{"product" => product_params}, socket) do
tags = socket.assigns.tags
|> Enum.filter(&(&1.id in product_params["tags"]))
|> Enum.map fn x ->Map.from_struct(x) end
params = Map.replace!(product_params, "tags", tags)
changeset =
socket.assigns.product
|> Products.change_product( params )
|> Map.put(:action, :validate)
{:noreply, assign_form(socket, changeset)}
end
And Break it
So far so good, the change_product
actually works.
But alas!
As soon as I change a checkbox it breaks
[error] GenServer #PID<0.9720.0> terminating
** (KeyError) key :id not found in: #Ecto.Changeset<action: :insert, changes: %{name: "Sensitive"}, errors: [], data: #Shopex.Products.Tag<>, valid?: true>
(shopex 0.1.0) lib/shopex_web/components/tags.ex:14:
Because on change the assigns.field.value
is not longer the list of preloaded Tags but a list of changesets.
%Phoenix.HTML.FormField{
...,
value: [
#Ecto.Changeset<
action: :insert,
changes: %{name: "Sensitive"},
errors: [],
data: #Shopex.Products.Tag<>,
valid?: true
>,
#Ecto.Changeset<action: :update, changes: %{}, errors: [],
data: #Shopex.Products.Tag<>, valid?: true>
]
Is this even a good approach at all? Any ideas how to fix that last little bit?