Another Ecto Many-To-Many LiveView Checkbox Approach

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:

  1. Making a CheckboxGroup Input · The Phoenix Files
  2. 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?

I’m on mobile so please excuse me if this is I missed something in the code examples.

It seems like you care more about the UX then capturing the change attempts.

It might be easier to use CSS to show or hide a submit button.

You could set it as:

name={"#{@field.name}[tag.id or something unique to the tag but not the relation]"}
value="true"
class={[if already_persisted?(...), do: "persisted", else: "not-persisted"}

and have a hidden checkbox for each tag that defaults to false so when you get a change event with everything not just the checked items.

Then a CSS rule for:

button[type=submit] ~ input.tag.not-persisted[checked]

You could also do this with data attributes, but then you’d need to use the :not function in CSS or something like that

I hope this gets it across but I think since this is mostly a display concern that you should be able to surface the tag info in a way that you can do it almost entirely in CSS and save yourself some headache

Thanks for the quick reply @felix-starman I just noticed that my sentence describing the “Save” button was quite bad. It should have been:

In the end I would love to have only one “Save” button for the whole Edit Product Page, that is only enabled if the tags or the name field have changed.
For this reason, I thought to keep the tag selection and name field in one simple_ form. But I haven’t got to this part because I struggle with the rerendering the tags ones the tag field has changed.

So I am till at the phase of validation, the save button stuff comes after.

I finally decided also to use the solution via “put_assoc” as described in this Post.

I found a way to work without the described virtual fields.
Please check the other Post for the solution.