Nicemaker

Nicemaker

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?

First Post!

felix-starman

felix-starman

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

Last Post!

Nicemaker

Nicemaker

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.

Where Next?

Popular in Questions Top

JeremM34
Hello, how can I check the Phoenix version ? Thanks !
New
PeterCarter
There are pre-rolled solutions for other frameworks that do work. However, Phoenix does not seem to have these. Have people had good expe...
New
nsuchy
Hi. I’ve noticed that Windows Powershell has it’s own IEX command and you cannot access Elixir’s IEX due to the conflict. This isn’t a cr...
New
joeerl
Hello again - after a longish gap I’ve decided I really must dig into Elixir and see what’s been happening here - so I have a few questio...
New
fireproofsocks
Forgive me if this is obvious, but how does one delete a database record WITHOUT selecting it first? Ecto.Repo — Ecto v3.14.0 has exampl...
New
fayddelight
I tried installing elixir 1.11.2 erlang 23.3.4 via asdf in my zsh shell. Enabled the versions locally and globally. When I list them ...
New
JorisKok
I have a server on AWS, and was running a load test using artillery. When looking at the Phoenix dashboard I see the Ports going to 100% ...
New

Other popular topics Top

grych
Hi folks, Few months ago I have announced the proof-of-concept of the library to manipulate the browsers DOM objects directly from Elixi...
639 54006 488
New
vonH
When I run the Plug and I recompile I wind up having to use Ctrl C to quit iex and start again. Witht the help of rlwrap I can use the cu...
New
ashish173
I am using Ecto timestamps with postgres, I can see the timestamps() use the :naive_dateime but for my use case I wanted to store the ti...
New
dokuzbir
I want to highlight html closing tags when i click a html tag. That works in .html files but doesnt work for html.eex templates. How can...
New
gausby
I asked this very same question on twitter and got some interesting feedback, but I thought it would be a good question to ask here as we...
1207 40042 209
New
shijith.k
I am trying to start a new phoenix project with elixir 1.9, but mix phx.new does not work. It says that ** (Mix) The task "phx.new" could...
New

We're in Beta

About us Mission Statement