How to model a form depending on 2 lists

I am helping my brother in his first forays into Elixir and Phoenix.
He is rewriting a Rails app. His customer wants the interfaces to stay the same.
The form he is struggling is this.

You have an Entry which has many Products, where these represent variants for the same entry. The second part is a Cart, this contains a list of Product with a reference to the current user.

The interface is built up by showing general information from Entry and a list of the Product variants. Each variant needs an input to enter the chosen quantity the customer wants to order. The information here coming from the Cart, not every Product has a record in Cart.

Now for the issue.

We need to loop over @entry.products to show the information about that specific variant, but we also need to get the current quantity out of the @cart.cart_items.

I tried to create a form that contains the CartItems and filled in the :product association. That works on first render, but as soon as something is entered in one of the fields, they all get turned into Ecto.Changeset and gone is the product information.

In the end I created a very ugly beast that loops over @entry.products, renders the inputs with a name like "product[#{product.id}][quantity]" and a get_data function that is called inside the loop (yikes) and checks to see if @form already contains data, if so get it from there, if not get it from the cart.

Then in the save handle_event, I get a map with the product_id as keys and the individual fields as values and convert this into a list of maps so Ecto can cast_assoc it.

It is ugly as hell, it works, but I cannot help but wonder if there isn’t a better/cleaner solution that I somehow missed.
Any input would be appreciated.

      <.form for={@form} phx-change="validate" phx-submit="save" id="cart_form">
        <table>
          <tr :for={product <- @entry.products}>
            <% data = get_data(@form, @entry, product, @cart) %>
            <td>
              <input type="number" value={data.quantity} name={"product[#{product.id}][quantity]"} />
              <input type="hidden" value={data.id} name={"product[#{product.id}][id]"} />
              <input
                type="hidden"
                value={data.product_id}
                name={"product[#{product.id}][product_id]"}
              />
              <input
                type="hidden"
                value={data.catalog_id}
                name={"product[#{product.id}][catalog_id]"}
              />
            </td>

            <td><%= product.article_number %></td>
            ...
def handle_event("validate", %{"product" => cart_params}, socket) do
    form =
      cart_params |> to_form()

    {:noreply, socket |> assign(form: form)}
  end

  def handle_event("save", %{"product" => cart_params}, socket) do
    %{cart: cart} = socket.assigns

    cart_items =
      Enum.reduce(cart.cart_items, [], fn item, agg ->
        if data = Map.get(cart_params, to_string(item.product_id)) do
          if data["quantity"] == "0" do
            agg
          else
            [data | agg]
          end
        else
          [Map.from_struct(item) | agg]
        end
      end) ++
        Enum.reduce(cart_params, [], fn {_key, item}, agg ->
          if item["quantity"] == "0" || item["id"] != "0" do
            agg
          else
            [Map.delete(item, "id") | agg]
          end
        end)

    Shop.Cart.update(cart, %{cart_items: cart_items})

    {:noreply, socket}
  end
def get_data(form, %{catalog_id: catalog_id}, %{id: product_id}, %{cart_items: items}) do
    if form.params == %{} do
      Enum.find(
        items,
        %Schema.CartItem{
          product_id: product_id,
          catalog_id: catalog_id,
          quantity: 0,
          id: 0
        },
        fn prod -> prod.product_id == product_id end
      )
    else
      Map.get(form.params, to_string(product_id))
      |> Enum.map(fn {k, v} -> {String.to_existing_atom(k), v} end)
      |> Enum.into(%{})
    end
  end

Again it is ugly and any input would be greatly appreciated.

What about creating an embedded schema for your specific UI need and then translating it to your schema that does DB operations? This is usually how I handle this, it worked reliably well in the past and it’s pretty readable.

The main issue is I need to render the info from the products while at the same time render the fields based on entries in the cart. It is possible there are no entries in the cart for these products, but there are for some others not shown on this page. I need them to remain in the cart, while at the same time update existing for these products, or add/delete them.

1 Like