Task D: A Dash Of Ajax #4

Nicolas says:

I'd start by adding a remove_from_cart method to store_controller.rb:

def remove_from_cart
  begin
    product = Product.find(params[:id])
  rescue ActiveRecord::RecordNotFound
    logger.error("Attempt to access invalid product #{params[:id]}")
    redirect_to_index("Invalid product" )
  else
    @cart = find_cart
    @current_item = @cart.remove_product(product)
    redirect_to_index unless request.xhr?
  end
end

I'd then add the corresponding remove_product(product) method to cart.rb

def remove_product(product)
  current_item = @items.find {|item| item.product == product}
  current_item.decrement_quantity
  if current_item.quantity == 0
    @items.delete(current_item)
  end
  current_item
end

And add the decrement_quantity method to cart_item.rb (this and the increment_quantity could be merged into one method)

def decrement_quantity
  @quantity -= 1 if @quantity > 0
end

Then it's time to edit the templates. Starting with _cart_item.rhtml: (lines preceded with ">" have been added to the file's previous state in the tutorial)

  <% if cart_item == @current_item %>
    <tr id="current_item">
  <% else %>
    <tr>
  <% end %>
    <td><%= cart_item.quantity %>×</td>
    <td><%= h(cart_item.title) %></td>
    <td class="item-price" ><%= number_to_currency(cart_item.price) %></td>
 >  <td>
 >    <%= link_to_remote "remove", :url => { :action => :remove_from_cart , :id => cart_item.product} %>
 >  </td>
  </tr>

The link_to_remote method is an alternative to using the form_remote_tag method, and outputs a link instead of a button, which is more suitable to a "remove" action.

Edward says:

Playing with the _cart_item.html.erb I end up with:

<% if cart_item == @current_item %>
    <tr id="current_item">
<% else %>
    <tr>
<% end %>
    <td><%= cart_item.quantity %>&times;</td>
    <td><%=h cart_item.title %></td>
    <td class="item-price"><%= number_to_currency(cart_item.price)%></td>    
   <td>
            <% if cart_item.quantity > 1 %>
                <% form_remote_tag :url => {:action => 'subtract_from_cart', :id => cart_item.product } do %>
                    <%= submit_tag " Subtract" %>
                <% end %>
            <% else %>
                <% form_remote_tag :url => {:action => 'subtract_from_cart', :id => cart_item.product } do %>
                    <%= submit_tag " Remove" %>
                    <% end %>            
            <% end %>                
   </td>
</tr>

Matt says:

This didn't quite work for me. The above code didn't cause the cart to be updated when I removed an item. To make it work I first used a slightly different remove_from_cart method in store_controller.rb

  def remove_from_cart
    begin
      product = Product.find(params[:id])
    rescue ActiveRecord::RecordNotFound
      logger.error("Attempt to access invalid product #{params[:id]}")
      redirect_to_index("Invalid product" )
    else
      @cart = find_cart
      @current_item = @cart.remove_product(product)
      respond_to do |format|
        format.js if request.xhr?
        format.html {redirect_to_index}
      end
    end
  end

I then had to go and create a remove_from_cart.js.rjs template in store/views:

page.replace_html("cart", :partial => "cart", :object => @cart)
 
page[:cart].visual_effect :blind_up if @cart.total_items == 0

I found this seemed to work quite well except that items were being decremented twice. Changing the link_to_remote call in the _cart_item.html.erb template did the trick to clear that up:

    <td><%= link_to "remove", :action => :remove_from_cart, :id => cart_item.product %></td>

Axel Christ asks:

Why is a link more suitable for a »remove« action? Any specific reason?

k9d answers:

It's an aesthetic/preference thing, the page looks goofy with so many large buttons screaming REMOVE!!! Can you see the sales vanishing into thin air? No? Then you need more AJAX!

Bradford Chang says:

This seems to work. There is a problem, though, if javascript is disabled. It works fine if you use a form_remote_tag rather than the link_to_remote. Is there a way to get this working sans javascript using a link_to_remote call (without using a form button)?

meowsqueak answers:

To make the link_to_remote work when javascript is disabled, you might need to provide a fallback URL (with :href) :

 <%= link_to_remote "remove", {:url => { :action => :remove_from_cart, :id => cart_item.product}}, { :href => url_for(:action => :remove_from_cart, :id => cart_item.product) }  %>

k9d answers:

Another javascript-less solution using a plain old link_to, similar to exercise PT-B-2. The javascript-less remove link in _cart_item.hrtml looks like this (if you use it, you'll have to ajaxify differently than how Nicolas proceeds):

<%= link_to "remove", :action => :remove_from_cart , :id => cart_item.product %>

Testing at this stage should work fine, nothing will visibly change when you click the "remove" link for any cart_item. However, manually refreshing the page shows a decremented product count.

Now time to add AJAX. Create a file remove_from_cart.rjs:

page.replace_html("cart", :partial => "cart", :object => @cart)  
if @cart.total_items == 0
  page[:cart].visual_effect :blind_up
elsif @current_item.quantity > 0
  page[:current_item].visual_effect :highlight,
                                    :startcolor => "#FF8888",
                                    :endcolor => "#441111"
end

As you can see, we don't want to call the highlight effect for either of the following two conditions:

The item removal effectively empties the cart completely (no more items to highlight)
The item removal reduces the item count to 0, deleting it from the cart (cart_item no longer available to highlight)
I've also changed the highlight color to red, which is more consistent with a deletion. When the cart is empty, the blind_up effect hides the cart div. One improvement would be to stop the total_price from updating when the cart is empty, since it is not useful and looks a bit like a glitch.

RL asks:

How is AJAX call possible with just plain link_to? Can you elaborate?

ilari says:

You can stop the total price from updating when the cart is empty with the following:

page.replace_html("cart", :partial => "cart", :object => @cart) unless @cart.total_items == 0

James_H says:

Did any one else have trouble getting blind_up to work when removing items from the cart?

Derek I answers:

'_cart.rhtml' hides the cart div when empty so there is nothing to shrink when removing that last item. Remove the '<% unless cart.items.empty? %>' condition from _cart.rhtml to catch a glimpse. )

Derek I asks:

Also, I get the occasional HTTP 500 response when adding to my cart. The page will send a request as follows: commit=Add%20to%20Cart&_=. It's almost as if the Ajax request didn't get a chance to form before the regular HTTP request was formed. Weird! Thoughts anyone?

SydneyStephen? says on 11 Juanuary 2007:

I also get the HTTP 500 response if I press the add-to-cart button too quickly after emptying the cart or adding another item. I get a very inelegant error message. However if I repeat the operation more slowly the error does not occur:

Application error

Change this error message for exceptions thrown outside of an action (like in Dispatcher setups or broken Ruby code) in public/500.html

Marcello asks:

Anyone found a way to be more DRY with te add_product and remove_product methods?

DonH answers:

I tried to a little magic with blocks and yielding.

I started by making a private method manage_cart in the store_controller.rb:

def manage_cart
  begin
    product = Product.find(params[:id])
    p product.title
  rescue ActiveRecord::RecordNotFound
    logger.error("Attempted to access invalid product #{params[:id]}")
    redirect_to_index("Could not find Product")
  else
    @cart = find_cart
    @current_item = yield
    redirect_to_index unless request.xhr?
  end
end

Bill asks:

What is this line (p product.title) for?

Then I refactored both the add_to_cart and remove_from_cart to look like this:

def add_to_cart
  manage_cart {@cart.add_product(product)}
end
 
def remove_from_cart
  manage_cart {@cart.remove_product(product)}
end

The problem that I seem to be running into is all goes well till we hit the @current_item = yield, then everything just stops. Any suggestions? Am I using the blocks correctly, I was hoping to do it this way as opposed to added a marker token in the request parameters and doing a bunch of control logic with decision code.

Any input would be appreciated.

DonH:

Okay I got it figured, the calls in the add_to_cart and remove_from_cart need to be modified to:

def add_to_cart
  manage_cart {|product| @cart.add_product(product)}
end
 
def remove_from_cart
  manage_cart {|product| @cart.remove_product(product)}
end

And the call to yield should be: @current_item = yield product

Anyone come up with a better way?

El_gringo says on 27 March 2007:

I think we shouldn't have to make an SQL request for removing a product from the cart : we have all needed informations in the CartItems? objects, so an SQL access is not needed. Here are differences between my solution and the one of Nicolas :

In store_controller.rb :

def remove_from_cart
  @cart = find_cart
  product_id = params[:id].to_i
  @current_item = @cart.remove_product(product_id)
  redirect_to_index
end

And in cart.rb :

def remove_product(product_id)
  current_item = @items.find {|item| item.product.id == product_id}
  current_item.decrement_quantity
  if current_item.empty?
    @items.delete(current_item)
  end
  current_item
end

jpl says on 08 April 2007:

El_gringo, your solution does not handle "Invalid product": what if someone makes a request to /store/remove_from_cart/-1 ? OK, nobody will ever do it. But you shouldn't care about this optimization: actually, this optimization could exist in the implementation of Product.find ( imagine a cache mechanism that prevents some DB access). Anyway the session is stored in the DB so there will be at least one request to the DB.

Anthony says:

I like the last solution, most elegant in my opinion. I still don't like the idea of using a GET request to perform a database modification. A button can be used and styled appropriately.

I had one modification to El_gringo's delete function:

if current_item.empty?

changed to:

if current_item.quantity < 1

Otherwise, I was getting negative quantities in the cart.

Steven said:

One of the previous "Playtime" tasks was to change the book icons to clickable elements that could also be used to add the book to the cart. Is there a way to continue this feature with Ajax?

Onno says:

To get that working, combine the original link for the image with link_to_remote mentioned earlier here. You'll get the next line for the image-link in store/index.rhtml:

<%= link_to_remote image_tag(product.image_url), {:url => { :action => :add_to_cart, :id => product }}, {:href => url_for(:action => :add_to_cart, :id => product) } %>

Patrick says:

I was having a problem with the format of the cart table, what with things sometimes taking up 2 lines sometimes 1. It looked messy. also I thought it'd be nice if we could add to the cart from the cart itself since we can remove items from it there. So I used Nicolas and meowsqeak and tweaked it a bit for my purposes. The only thing different is my _cart_item.rhtml. I added two td columns. In one, I separated the product.quantity from the times symbol, and in the other I added an add to cart link. I also made "add" and "remove" into "+" and "-" respectively to save space and not overly encourage our customers to remove products. Here is my _cart_item.rhtml:

<% if cart_item == @current_item %>
  <tr id="current_item">
<% else %>
  <tr>
<% end %> 
    <td><%= cart_item.quantity %> </td> 
    <td> × </td> 
    <td><%= h(cart_item.title) %></td> 
    <td class="item-price"><%= number_to_currency(cart_item.price) %></td> 
    <td>
      <%= link_to_remote "+", {:url => { :action => :add_to_cart, :id => cart_item.product}}, { :href => url_for(:action => :add_to_cart, :id => cart_item.product) }  %>
    </td>
    <td>
      <%= link_to_remote "-", {:url => { :action => :remove_from_cart, :id => cart_item.product}}, { :href => url_for(:action => :remove_from_cart, :id => cart_item.product) }  %>
    </td>
  </tr>

This works with or without java. My one question is, for the fall back url, does it call a POST request even though its an href or is it sending a GET request?

dkusleika says:

If you attempt to remove an item (a legitimate product) that does not exist in the cart, you will get an error. At least I did. My store_controller remove_from_cart looks like

  def remove_from_cart
    begin
      product = Product.find(params[:id])
    rescue ActiveRecord::RecordNotFound
      logger.error("Attempt to access invalid product #{params[:id]}")
      redirect_to_index("Invalid product" )
    else
      @cart = find_cart
      if @cart.has_product(product)
        @current_item = @cart.remove_product(product)
        redirect_to_index unless request.xhr?
      else
        redirect_to_index
      end
    end
  end

I'm sure there's a clean way to implement has_product, but

  def has_product(product)
    current_item = @items.find {|item| item.product == product}
    if current_item
      true
    else
      false
    end
  end

If the product id is bogus, the store_controller will catch it. If it's a good product id, but just doesn't happen to be in the cart, has_product will return false and redirect_to_index will be called - and basically nothing else done. Also, I implemented my remove_product slightly different

  def remove_product(product)
    current_item = @items.find {|item| item.product == product}
    if current_item.quantity < 2
      @items.delete(current_item)
    else
      current_item.decrement_quantity
      current_item
    end
  end

I only call decrement_quantity if I need to. I doubt this makes much difference.

If someone can show me how to write has_product correctly, or if there's a better way to check, I'd appreciate it.

Scott says:

The following code worked for me (Rails 2.0.2). Thanks to all who contributed to this thread, which helped me get this working.

store_controller.rb

# ...
def remove_from_cart
    begin
        product = Product.find(params[:id])
    rescue ActiveRecord::RecordNotFound
        logger.error("Attempt to access invalid product #{params[:id]}")
        redirect_to_index "Invalid product"
    else            
        @cart = find_cart
        @current_item = @cart.remove_product(product)
        if request.xhr?
            respond_to {|format| format.js}
        else
            redirect_to_index
        end
    end
end

cart.rb
# ...
def remove_product(product)
    current_item = @items.find {|item| item.product == product}
    current_item.decrement_quantity
    if current_item.quantity == 0
        @items.delete(current_item)
    end
    current_item
end

cart_item.rb
# ...
def decrement_quantity
    @quantity -= 1 if @quantity > 0
end

_cart_item.html.erb
# ...
<%= link_to_remote "&times;",
    {:url => {:action => :remove_from_cart, :id => cart_item.product}},
    {:href => url_for(:action => :remove_from_cart, :id => cart_item.product)} %>

remove_from_cart.js.rjs
if @cart.total_items == 0
  page[:cart].visual_effect :blind_up
else
    page.replace_html("cart", :partial => "cart", :object => @cart)
    if @current_item.quantity > 0
      page[:current_item].visual_effect :highlight, :startcolor => "#FF8888", :endcolor => "#114411"
    end
end

SSJordan says:

I got this exercise working.

However, in reading k9d's decision to change highlight color to red, it dawned on me that these highlight colors should probably be controlled by CCS.

How would you hook a style class in the blind_up and blind_down effects that can then be controlled through CSS?

amanfredi says:

I noticed that combining this with the grow effect in the previous playtime exercise will likely result in your cart item tr becoming hidden when you decrement the quantity to 1.
To fix this I needed to know which action caused the ajax update to the cart, so I set an instance variable "@cart_action" to the name of the method called in store_controller.rb. This way I could check which action actually caused the cart_item quantity to become 1.

If there's a better way to avoid this problem, I'd love to hear some suggestions.

SilviuB says:

I have used following code for remove_from_cart method in store_controller.rb. This is to cache all possible errors:

  def remove_from_cart
    begin
      product = Product.find(params[:id])
      @cart = find_cart
      @current_item = @cart.remove_product(product)
    rescue NoMethodError
      logger.error("Attempt to access a product which is not in your cart #{params[:id]}")
      redirect_to_index "Product not in your cart!"
    rescue ActiveRecord::RecordNotFound
      logger.error("Attempt to access invalid product #{params[:id]}")
      redirect_to_index "Invalid product!"
    else
      respond_to do |format|
        format.js if request.xhr?
        format.html { redirect_to_index }
      end
    end
  end

Isn't better/safer to use:
  <td>
    <% form_remote_tag :url => {:action => :remove_from_cart, :id => cart_item.product} do -%>
      <%= submit_tag "-" %>
    <% end -%>
  </td>

instead of link_to_remote?

JinYoung:

OK. I've done like below.

In store_controller.rb

  def remove_from_cart
    begin
      product = Product.find(params[:id])
    rescue ActiveRecord::RecordNotFound
      logger.error("Attempt to access invalid product #{params[:id]}" )
      redirect_to_index("Invalid product")
    else
      @cart = find_cart
      @current_item = @cart.remove_product(product)
      respond_to do |format|
        format.js if request.xhr?
        format.html { redirect_to_index("One #{product.title} was removed") }
      end
    end
  end

In _cart.html.erb, I've removed first and last line(<% unless cart.items.empty? %>, <% end %>) and add colspan attribute to "total-cell" td tag

<td colspan="2" class="total-cell"><%= number_to_currency(cart.total_price) %></td>

In cart.rb

def remove_product(product)
  current_item = @items.find {|item| item.product == product}
  if current_item
    current_item.decrement_quantity
    @items.delete(current_item) if current_item.quantity == 0
  end
  current_item
end

Add new file 'remove_from_cart.js.rjs'

page.replace_html("cart" , :partial => "cart" , :object => @cart)
page[:current_item].visual_effect :highlight, :startcolor => "#88ff88" , :endcolor => "#114411" if @current_item.quantity >= 1

Finally, I've added delete button column to the cart table. In _cart_item.html.erb

<td>
  <% form_remote_tag :url => { :action => ((@cart.items.length == 1 and cart_item.quantity == 1) ? :empty_cart : :remove_from_cart), :id => cart_item.product } do %>
    <%= submit_tag "D" %>
  <% end %>
</td>

I think the "(@cart.items.length == 1 and cart_item.quantity == 1)" part could raise to a controversy. At first, I had done it with another way.

In remove_from_cart.js.rjs

page.replace_html("cart" , :partial => "cart" , :object => @cart)
page[:current_item].visual_effect :highlight, :startcolor => "#88ff88" , :endcolor => "#114411" if @current_item.quantity >= 1
page[:cart].visual_effect :blind_up if @cart.items.length == 0

In _cart_item.html.erb

<td>
  <% form_remote_tag :url => { :action => :remove_from_cart, :id => cart_item.product } do %>
    <%= submit_tag "D" %>
  <% end %>
</td>

However, this way is not good for display effect. So I changed some code to call empty_cart when the cart item is last one of our cart.

Daniel says:

I added id to the cart_item model:
cart_item.rb

  def id
    @product.id
  end

I added has_item(id) to the cart model
cart.rb

  def has_item(id)
    this_item = @items.find {|item| item.id == id.to_i}
    if this_item
      true
    else
      false
    end
  end

This searches for a cart item with given id similar to dkusleika's version but as you will see in remove_from_cart I won't do any database querry.

Maybe there is a product with that id in my database, but I don't care since the only thing I want to know is, if there is a product with that id in my cart.

  def remove_from_cart
    @cart = find_cart
    if @cart.has_item(params[:id])
      @current_item = @cart.remove_item(params[:id])
      respond_to do |format| 
        format.js if request.xhr?
        format.html {redirect_to_index}
      end    
    else
      logger.error("EE :: Item not in cart #{params[:id]}")
      redirect_to_index
    end
  end
Unless otherwise stated, the content of this page is licensed under Creative Commons Attribution-ShareAlike 3.0 License