Stuck in passing data through button - "Add to shopping cart"

Hi I decided to make another attemp to learn basics of LiveView. For learning purpose i’ve chosen absolutely simple fake ecommerce site.

I want to implement “add to cart” button that triggers adding one product to cart (from main site) or chosen quantity products to cart(from exact product cart). For now I’m struggling with handling the simple one.

I have a category view (show.html.heex) that creates div with products and on the bottom there is a button:

<%= for product <- @products do %>
          <%= live_patch to: Routes.product_show_path(@socket, :show, product.id) do %>
                  <p class="text-center text-blue-500 md:text-2xl sm:text-xl mt-5"><strong><%= product.name %></strong></p>
                  <button class="bg-blue-400 text-white font-[Poppins] duration-500 px-6 py-2 mx-4 my-4 md:my-0 hover:bg-blue-600 rounded"><%= link "Cart!", to: "#", phx_click: "add_to_cart", phx_value_id: product.id %>
                  </button>
            </div>
          <% end %>
        <% end %>

My whole show.ex looks like this:

@impl true
  def mount(_params, _session, socket) do
    {:ok,
    socket
    |> assign(:cart_items, nil)}
  end

  @impl true

  def handle_event("add_to_cart", %{"product_id" => product_id, "quantity" => quantity}, socket) do
    product = Catalog.get_product!(product_id)

    case ShoppingCart.get_cart_by_user_id(socket.assigns.current_user.id) do
      {:ok, cart} ->
        add_item_to_shopping_cart(socket, cart, product, quantity)

      {:error, _} ->
        cart = ShoppingCart.create_cart(socket.assigns.current_user)
        add_item_to_shopping_cart(socket, cart, product, quantity)
    end

    {:noreply, socket}
  end

  @impl true
  def handle_params(%{"id" => id}, _, socket) do
    {:noreply,
     socket
     |> assign(:page_title, page_title(socket.assigns.live_action))
     |> assign(:category, Catalog.get_category!(id))
     |> assign(:products, Catalog.get_product_by_category_id(id))}
  end

  def handle_params(%{"id" => product_id, "quantity" => 1}, _, socket) do
    {:noreply,
    socket
    |> assign(:products, Catalog.get_product!(product_id))
    |> assign(:cart_items, ShoppingCart.create_cart_item())}
  end

  defp page_title(:show), do: "Show Category"
  defp page_title(:edit), do: "Edit Category"

  defp add_item_to_shopping_cart(socket, cart, product, quantity) do
    case ShoppingCart.add_item_to_cart(cart, product, quantity) do
          {:ok, _item} ->
            socket
            |> put_flash(:info, "Item added to shopping cart")
            |> redirect(to: Routes.cart_show_path(socket, :show, cart))

          {:error, _changeset} ->
            socket
            |> put_flash(:info, "Error with adding item")
            |> redirect(to: Routes.cart_show_path(socket, :show, cart))
    end
  end

I tried hard code quantity from this layer because there will be always 1, but it doesnt work either. It looks like every time function matches phx_value_id to category not to a product.

Is there an obvious way that I cant find to just pass product.id through button to handle_event (or from product view also quantity from select or input) or i need to create controller instead of using liveview and there make ‘create’ function to do whole work?

Sorry for messy post I have to go to work and I tried to solve problem before it. If You have more questions I’ll reply them after coming back.

Greetings

From your description, It’s not very clear what you’re trying to achieve and what is your expected behavior.

However, I can spot a couple red flags in your code:

 <%= live_patch to: Routes.product_show_path(@socket, :show, product.id) do %>
                  <p class="text-center text-blue-500 md:text-2xl sm:text-xl mt-5"><strong><%= product.name %></strong></p>
                  <button class="bg-blue-400 text-white font-[Poppins] duration-500 px-6 py-2 mx-4 my-4 md:my-0 hover:bg-blue-600 rounded"><%= link "Cart!", to: "#", phx_click: "add_to_cart", phx_value_id: product.id %>
                  </button>

live_patch (in LV >= 0.18 this is now replaced by the link component) renders a link that patches the live view. Why do you render a button with a click event inside the link? What is the expected behavior when I click the button? To patch the live view or to trigger the add_to_cart event?

Also: it probably should be phx_value_product_id: product.id because your handle_event calback expects product_id not an id as parameter.

case ShoppingCart.get_cart_by_user_id(socket.assigns.current_user.id) do
      {:ok, cart} ->
        add_item_to_shopping_cart(socket, cart, product, quantity)

      {:error, _} ->
        cart = ShoppingCart.create_cart(socket.assigns.current_user)
        add_item_to_shopping_cart(socket, cart, product, quantity)
end

{:noreply, socket}

The result of add_item_to_shopping_cart is discarded here. You probably want to turn this into socket = case ShoppingCart.get_cart_by_user_id ...

Finally, you should reverse the order of your handle_params callbacks, as the second one will never match. The compiler should have warned you about it, actually.

1 Like

Hi, thank you for your answer. I have more time now so I explain everything correctly with process thinking.

First of all - by clicking Add to cart button I want to add product instance to the shopping cart -

  1. check if there is current shopping cart - if not create it (user can have one cart in one time only)
  2. create cart_item and add it to the cart - cart_item is product.id, quantity and price for product

Reason why I put link into button is just trying few different things how to make it work. After deleting link and put phx_value… into button It just redirects me to product/:id without doing anything else

The reason about live_patch - I want to be redirected to the product/:id by clicking created div in category view - ProductLive.Show is live_view not live_component and I wasn’t warned about deprecation in terminal - maybe because I use 0.17.5 v of LiveView.

I will change to phx_value_product_id and try it. My question is if i can also pass more params that way in the same button - add for example phx_value_quantity and bind it with input or hardcode in some way?

I probably missed this spot with “socket = case…” I try it too, your explanation seems logical as my whole code vanishes and never becomes socket.

Last - yes i saw callback in terminal but in the night my head probably didnt work well…

Thank you once again, I will try to change everything and check the results :slight_smile:

Ok after few combinations I made small step forward. It creates cart if doesnt exist. Before i got lack of current_user but I passed it assigning current_user to socket in mount.

Now it looks like that:

defmodule EshopyWeb.CategoryLive.Show do
  use EshopyWeb, :live_view

  alias Eshopy.Catalog
  alias Eshopy.ShoppingCart
  alias Eshopy.ShoppingCart.Cart
  alias Eshopy.Accounts

  @impl true
  def mount(_params, %{"user_token" => user_token}, socket) do
    user = Accounts.get_user_by_session_token(user_token)
    {:ok,
    socket
    |> assign(:current_user, user)
    |> assign(:cart, ShoppingCart.get_cart_by_user_id(user.id))
    |> assign(:cart_items, nil)}
  end

  @impl true

  def handle_event("add_to_cart", %{"product" => product_id, "quantity" => quantity}, socket) do
    product = Catalog.get_product!(product_id)

    socket =
      case ShoppingCart.get_cart_by_user_id(socket.assigns.current_user.id) do
        {:ok, cart} ->
          add_item_to_shopping_cart(socket, cart, product, quantity)

        [] ->
          {:ok, %Cart{} = cart} = ShoppingCart.create_cart(socket.assigns.current_user)
          add_item_to_shopping_cart(socket, cart, product, quantity)
      end

    {:noreply, socket}
  end

  @impl true
  def handle_params(%{"id" => id}, _, socket) do
    {:noreply,
     socket
     |> assign(:page_title, page_title(socket.assigns.live_action))
     |> assign(:category, Catalog.get_category!(id))
     |> assign(:products, Catalog.get_product_by_category_id(id))}
  end

  def handle_params(%{"product" => product_id, "quantity" => 1}, _, socket) do
    {:noreply,
    socket
    |> assign(:products, Catalog.get_product!(product_id))
    |> assign(:cart_items, ShoppingCart.create_cart_item())}
  end

  defp page_title(:show), do: "Show Category"
  defp page_title(:edit), do: "Edit Category"

  defp add_item_to_shopping_cart(socket, cart, product, quantity) do
    case ShoppingCart.add_item_to_cart(cart, product, quantity) do
          {:ok, _item} ->
            socket
            |> put_flash(:info, "Item added to shopping cart")
            |> redirect(to: Routes.cart_show_path(socket, :show, cart))

          {:error, _changeset} ->
            socket
            |> put_flash(:info, "Error with adding item")
            |> redirect(to: Routes.cart_show_path(socket, :show, cart))
    end
  end
end

Button for now looks like that:

<button class="bg-blue-400 text-white font-[Poppins] duration-500 px-6 py-2 mx-4 my-4 md:my-0 hover:bg-blue-600 rounded">
                    <%= link "Cart!", to: "#", phx_click: "add_to_cart", phx_value_product: product.id, phx_value_quantity: 1 %>
                  </button>

But now I got in terminal problem with creating/adding cart_item if cart exists:

[error] GenServer #PID<0.1970.0> terminating
** (CaseClauseError) no case clause matching: [%Eshopy.ShoppingCart.Cart{__meta__: #Ecto.Schema.Metadata<:loaded, "carts">, cart_items: #Ecto.Association.NotLoaded<association :cart_items is not loaded>, id: 1, inserted_at: ~N[2023-01-05 11:54:35], updated_at: ~N[2023-01-05 11:54:35], user: #Ecto.Association.NotLoaded<association :user is not loaded>, user_id: 1}]
    (eshopy 0.1.0) lib/eshopy_web/live/category_live/show.ex:25: EshopyWeb.CategoryLive.Show.handle_event/3...

And if doesnt:

[error] GenServer #PID<0.1991.0> terminating
** (ArithmeticError) bad argument in arithmetic expression
    :erlang.*(#Decimal<2>, "1")
    (eshopy 0.1.0) lib/eshopy/shopping_cart.ex:180: Eshopy.ShoppingCart.add_item_to_cart/3
    (eshopy 0.1.0) lib/eshopy_web/live/category_live/show.ex:57: EshopyWeb.CategoryLive.Show.add_item_to_shopping_cart/4
    (eshopy 0.1.0) lib/eshopy_web/live/category_live/show.ex:31: EshopyWeb.CategoryLive.Show.handle_event/3

I’m trying to figure out how to solve it properly.

Context:

def create_cart(%User{} = user, attrs \\ %{}) do
    %Cart{}
    |> Cart.changeset(attrs)
    |> Ecto.Changeset.put_assoc(:user, user)
    |> Repo.insert()
  end

  def add_item_to_cart(%Cart{} = cart, %Product{} = product, quantity \\ 1) do
    %CartItem{quantity: quantity, price: product.unit_price * quantity}
    |> CartItem.changeset(%{})
    |> Ecto.Changeset.put_assoc(:product, product)
    |> Ecto.Changeset.put_assoc(:cart, cart)
    |> Repo.insert(
      on_conflict: [inc: [quantity: 1]]
    )
  end

def get_cart_by_user_id(user_id) do
    query =
      from c in Cart,
      where: c.user_id == ^user_id,
      left_join: i in assoc(c, :cart_items)

    Repo.all(query)
  end

def create_cart_item(attrs \\ %{}) do
    %CartItem{}
    |> CartItem.changeset(attrs)
    |> Repo.insert()
  end

I think i need to workout more on create_cart_item to pass informations and put_assoc to product.

I’m glad you made progress.

Here’s a few considerations:

  1. You have a link that doesn’t redirect anywhere (href #) inside a button, you don’t need the link:
<button class="bg-blue-400 text-white font-[Poppins] duration-500 px-6 py-2 mx-4 my-4 md:my-0 hover:bg-blue-600 rounded" phx-click="add_to_cart" phx-value-product={product.id} phx-value-quantity={1}>
  Cart!
</button>

(This can be changed later to a form that has the quantity input and the button submits it, but it should work for now)

  1. This case won’t work, see the inline comment I added:
  def handle_event("add_to_cart", %{"product" => product_id, "quantity" => quantity}, socket) do
    product = Catalog.get_product!(product_id)

    socket =
      case ShoppingCart.get_cart_by_user_id(socket.assigns.current_user.id) do
        {:ok, cart} -> # <-- This the function get_cart_by_user_id uses Repo.all() and will always return [] and never {:ok, cart}
          add_item_to_shopping_cart(socket, cart, product, quantity)

        [] ->
          {:ok, %Cart{} = cart} = ShoppingCart.create_cart(socket.assigns.current_user)
          add_item_to_shopping_cart(socket, cart, product, quantity)
      end

    {:noreply, socket}
  end

You might want to change your function to use Repo.one/2 instead, and it will return nil or %Eshopy.ShoppingCart.Cart{}.

  1. The last error is an ArithmeticError, :erlang.*(#Decimal<2>, "1"). This means that you’re trying to multiply a decimal by a string here: %CartItem{quantity: quantity, price: product.unit_price * quantity}. You need to convert the variable quantity to a number first.
1 Like

Hi, thanks for big help. I’ll refactor a little big later but I’d like to answer for two things I can see.

  1. I wanted to add small form with quantity input and button in the product cart. Explanation: when you want to add product really fast from category/brand site You just click button on main screen, if u want add few units you need to go directly to product view and then choose how many of them you want :slight_smile: - and of course learning purposes to use different types of code.

  2. Thanks for helping me with context - I didnt catch mistakes in Case/Do :slight_smile:

  3. I know that something was shitty here. Is there any possibility to get directly integer from button as i need to parse it in few places - quantity, counting price, on_conflict increasing or i need to write helper to parse it :slight_smile:

Thanks!

edit: And why I wrote button like I did. When I left just button with attributes It always redirects me to product page instead of handling event, if I move button outside the div it destroys whole UI (which is simple to correct).

I tried few things you mentioned in post - I think another small step was made, but here comes another errors (but I think they’re getting me closer to the end). I didnt expect that its so complicated. In my mind it looks like really simple stuff to code.

def handle_event("add_to_cart", %{"product" => product_id, "quantity" => quantity}, socket) do
    product = Catalog.get_product!(product_id)
    quantity = String.to_integer(quantity)
    
    socket =
      case ShoppingCart.get_cart_by_user_id(socket.assigns.current_user.id) do
        {:ok, %Cart{} = cart} ->
          add_item_to_shopping_cart(socket, cart, product, quantity)

        nil ->
          {:ok, %Cart{} = cart} = ShoppingCart.create_cart(socket.assigns.current_user)
          add_item_to_shopping_cart(socket, cart, product, quantity)
      end

    {:noreply, socket}
  end
def add_item_to_cart(%Cart{} = cart, %Product{} = product, quantity \\ 1) do
    %CartItem{}
    |> CartItem.changeset(%{quantity: quantity, price: Decimal.mult(product.unit_price, quantity)})
    |> Ecto.Changeset.put_assoc(:product, product)
    |> Ecto.Changeset.put_assoc(:cart, cart)
    |> Repo.insert(
      conflict_target: product.id,
      on_conflict: [inc: [quantity: 1, price: product.unit_price]]
    )
  end

I had to use Decimal.mult cause even after parsing string to integer compiler doesnt understand x * y when one of them is decimal and another is integer.

I’ve got two error depending on having cart in DB or not.

When I dont have cart yet (but even error appears new instance of cart is created):

** (FunctionClauseError) no function clause matching in Ecto.Adapters.Postgres.Connection.quote_name/1
    (ecto_sql 3.9.1) Ecto.Adapters.Postgres.Connection.quote_name(13)
    (ecto_sql 3.9.1) lib/ecto/adapters/postgres/connection.ex:1379: Ecto.Adapters.Postgres.Connection.intersperse_map/4
    (ecto_sql 3.9.1) lib/ecto/adapters/postgres/connection.ex:217: Ecto.Adapters.Postgres.Connection.conflict_target/1
    (ecto_sql 3.9.1) lib/ecto/adapters/postgres/connection.ex:205: Ecto.Adapters.Postgres.Connection.on_conflict/2
    (ecto_sql 3.9.1) lib/ecto/adapters/postgres/connection.ex:187: Ecto.Adapters.Postgres.Connection.insert/7
    (ecto_sql 3.9.1) lib/ecto/adapters/postgres.ex:127: Ecto.Adapters.Postgres.insert/6

When I have cart in DB:

** (CaseClauseError) no case clause matching: %Eshopy.ShoppingCart.Cart{__meta__: #Ecto.Schema.Metadata<:loaded, "carts">, cart_items: #Ecto.Association.NotLoaded<association :cart_items is not loaded>, id: 25, inserted_at: ~N[2023-01-05 19:17:02], updated_at: ~N[2023-01-05 19:17:02], user: #Ecto.Association.NotLoaded<association :user is not loaded>, user_id: 1}
    (eshopy 0.1.0) lib/eshopy_web/live/category_live/show.ex:24: EshopyWeb.CategoryLive.Show.handle_event/3
    (phoenix_live_view 0.17.12) lib/phoenix_live_view/channel.ex:382: anonymous fn/3 in Phoenix.LiveView.Channel.view_handle_event/3
    (telemetry 1.1.0) /home/mateusz/Pulpit/eshopy/deps/telemetry/src/telemetry.erl:320: :telemetry.span/3
    (phoenix_live_view 0.17.12) lib/phoenix_live_view/channel.ex:216: Phoenix.LiveView.Channel.handle_info/2

What I think? I saw during trying writing that I missed conflcit_target in Repo.insert function, so I passed product.id as the most logical for me right now. So i there comes error number 1. I deleted again conflict_target and error doesnt change. Still error 1 appears.

About second error I dont know if everything is okey. It seems like it finds cart in db but dont do anything with that. I dont expect from function to create cart_item and add it to db (cause there are errors mentioned above) but it looks like having cart in db makes another steps impossible (as i understand there is no clause matching so i need another option besides {:ok, cart} and nil - or put nested case statement inside one of clause.

Take a look at Ecto’s docs for upsert, you’re using the value of “product.id” instead of the column name as described.

But you also need to pay attention to your function, if the product already exists you’ll always increment it by 1, even if the function is called with a higher quantity. Imagine I have one item in the cart and want to add another 3, I should have 4 total and your code will make it only 2.

1 Like

I realized my mistakes and changed it to:

def add_item_to_cart(%Cart{} = cart, %Product{} = product, quantity \\ 1) do
    %CartItem{}
    |> CartItem.changeset(%{quantity: quantity, price: Decimal.mult(product.unit_price, quantity)})
    |> Ecto.Changeset.put_assoc(:product, product)
    |> Ecto.Changeset.put_assoc(:cart, cart)
    |> Repo.insert(
      conflict_target: [:cart_id, :product_id],
      on_conflict: [inc: [quantity: quantity, price: Decimal.mult(product.unit_price, quantity)]]
    )
  end

And now it works when I dont have cart in db - it creates cart and then creates cart_item and assoc it to created before cart. So thanks for big big help and pointing my mistakes :slight_smile: now I need to work with case/do function while I have cart in db to add another instances! :slight_smile: pretty big work and not so easy as i predicted before :slight_smile:

That was simplier than I thought - my case/do statement had a mistaken return written…

socket =
      case ShoppingCart.get_cart_by_user_id(socket.assigns.current_user.id) do
        %Cart{} = cart -> # It never returns {:ok, %Cart{}} thats why I missed this case option everytime
          add_item_to_shopping_cart(socket, cart, product, quantity)

        nil ->
          {:ok, %Cart{} = cart} = ShoppingCart.create_cart(socket.assigns.current_user)
          add_item_to_shopping_cart(socket, cart, product, quantity)
      end