Last week a reader and I shared a few emails about an issue he was having with this site. His site, Bee Bright, is a candle shop. If you go into a product category, like for example pillar candles, you can see that he makes use of Ajax for his shopping cart. Simply click add to cart on one of the items and you can see this in action. This works for him but he was a bit concerned about his implementation. We talked back and forth a bit about it and I think it brings up a topic I've mentioned here before (and at Scotch on the Rocks last year). Moving to Ajax is not always a silver bullet solution. Sometimes it takes two or three implementations before you get things right. The more I work with Ajax the more I think about topics like this so I thought it would be good to discuss his solution, and mine, and let folks comment in as well. So enough preamble, let's get into it.
To begin, let's discuss how he implemented the Ajax and why he was concerned. The developer, Chris, made use of cfdiv within an output tied to a simple query. Here is his code. I've removed some extra HTML to make things a bit simpler.
<cfoutput query="products">
<a href="images/products/#product_img#" class="cloud-zoom" id="zoom#currentrow#" rel="position: 'inside', zoomWidth:'100', zoomHeight:'100'"><img src="images/products/#product_img_th#" width="220" /></a>
<div style="margin-left:240px;">
<div class="product_name">#product_name#</div>
#description#
</div>
<div class="orderbox">
<cfdiv bind="url:cybercart/product_order_form.cfm?cat_id=#url.cat_id#&prod_id=#prod_id#">
</div>
</cfoutput>
The cfdiv outputs a form that makes use of cfform. This allows the form to automatically use Ajax to post the results. This is what gives you the ability to replace the form with the cart status update after hitting the button.
Pretty simple - and it works. But Chris was a bit worried about the number of requests. On a page with 20 products he ended up seeing 21 CFM requests. One for the product listing and then 20 for each product form. Now the amount of HTML being sent back wasn't really that much (Chrome reports it at around 3 and a half K), but the more network requests you have going on the slower your total experience is going to be.
This is to me a great example of where an Ajax solution could possibly actually be slower than a non-Ajax solution. I don't mean that as an attack at all on his code. I've made this exact same type of mistake myself. But as Ajax developers sometimes it's easy to miss how a solution can backfire.
What I proposed to Chris was a solution that reduced the number of initial HTTP requests. Instead of a request for each div, I suggested that we just return the forms with the products and use our front end code to handle them with jQuery. I built up some samples at lunch time that demonstrate this. I began with a simple product listing based on the cfartgallery.
<h2>Products</h2> <cfoutput query="getArt">
<img src="artgallery/#largeimage#" align="left">
<h3>#artname#</h3>
<p>
#description#
</p>
<p>
<form>
<input type="text" value="1"> <input type="button" value="Add to Cart">
</form>
<br clear="left">
<hr/>
</cfoutput>
<cfquery name="getArt" datasource="cfartgallery">
select artid, artname, price, description, largeimage
from art
</cfquery>
Nothing too fancy here. Just select all the products, display them, and create a form to add them to a cart. You can see this in action here: http://www.coldfusionjedi.com/demos/jan102011/ (Old demos no longer work.). Ok, so let's now enhance this a bit.
<cfquery name="getArt" datasource="cfartgallery">
select artid, artname, price, description, largeimage
from art
</cfquery> <html>
<head>
<script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1/jquery.min.js"></script>
<script>
$(document).ready(function() {
$(".addToCart").click(function() {
//get the previous field
var field = $(this).prev();
//get the value
var numberToAdd = $(field).val()
if(!isNaN(numberToAdd)) {
var prodid = $(field).data("productid");
console.log("going to add "+numberToAdd+" for product id "+prodid);
}
});
})
</script>
</head> <body> <h2>Products</h2> <cfoutput query="getArt">
<img src="artgallery/#largeimage#" align="left">
<h3>#artname#</h3>
<p>
#description#
</p>
<p>
<form>
<input type="text" value="1" data-productid="#artid#"> <input type="button" value="Add to Cart" class="addToCart">
</form>
<br clear="left">
<hr/>
</cfoutput> </body>
</html>
I've got a few changes here. I added a class to the button and some data to my fields. If you scroll up to my jQuery code you can see I'm now listening for clicks to the button. Once clicked I get the previous item in the dom, my form field, and then grab the value. If numeric I fetch out the productid and I now know both the quantity and product id of what you want to add to the cart. You can demo this here: http://www.coldfusionjedi.com/demos/jan102011/index2.cfm (Old demos no longer work.) And before anyone complains - yes - I used console.log. Yes - I know it doesn't work in IE or Firefox w/o Firebug. If you don't have those guys then just don't run the demo. Ok, now let's add some more to it.
$(document).ready(function() {
$(".addToCart").click(function() {
//get the previous field
var field = $(this).prev();
//get the value
var numberToAdd = $(field).val()
if(!isNaN(numberToAdd)) {
var prodid = $(field).data("productid");
console.dir("going to add "+numberToAdd+" for product id "+prodid);
$.post("service.cfc?returnformat=json&method=addproducttocart", {"quantity":numberToAdd,"productid":prodid}, function(res,code) {
//result is the cart. we will just say how many items
var s = "Product added to cart. You now have ";
var total = 0;
for(var p in res) {
total += parseInt(res[p]);
}
s += total + " item(s) in your cart.";
console.log(s);
},"json");
}
});
})
In this update I only changed the JavaScript. If you want the full CFM for this just download the attached code. Now I'm doing a post of the quantity/product to the server. I take the result (which is the complete cart, and I'll show that in a minute) and I create a result string from it. I don't bother with a total price for the cart but just report on the total number of items. Obviously you could add that too if you wanted. In order for this to work I added an Application.cfc:
public boolean function onApplicationStart() {
return true;
} public boolean function onRequestStart(string req) {
//allow for easy testing of the cart
if(structKeyExists(url,"init")) onSessionStart();
return true;
} public void function onSessionStart() {
session.cart = {};
} }
component {
this.name="jan102011demo";
this.sessionManagement="true";
The only thing useful here was the creation of the cart on session startup. Next, here is my simple service component:
remote function addProductToCart(numeric quantity, numeric productid) {
if(!structKeyExists(session.cart, arguments.productid)) session.cart[arguments.productid] = 0;
session.cart[arguments.productid]+=arguments.quantity;
return session.cart;
}
}
component {
Not much here either. I basically add to a structure that contains keys as product IDs and values as quantities. I could add validation on the product ID as well as quantity (what happens if a negative or float is passed?) to make this a bit more solid. So - there we go. I'm basically done. But I would like to actually display the result like the Chris did. Instead of overwriting the form though I decided to give the Freeow plugin a try. This is a jQuery plugin I saw last week that does Growl-style notifications. Using this meant adding a CSS tag, a JS script include, and a grand total of one line of code:
$("#freeow").freeow("Cart Updated!", s, {classes:["smokey"]});
You can see this yourself by hitting the big ole Demo button:
Sorry - old demos no longer work.
Thoughts? As always, I'm sure folks could code this a million other ways. My main point here - and one I want to talk more about this year - is that while Ajax is easy/sexy/cool/etc, it really makes sense to think about your solutions and what kind of impact they have once released. Make sense?
Archived Comments
Great post. It's always nice to hear about other ways to approach a problem.
One question, sort of unrelated to the topic. What is this code for in the <a> tag:
rel="position: 'inside', zoomWidth:'100', zoomHeight:'100'"
I've never seen that before so I was curious what it's for?
I've sent Chris this URL so he may reply with the real answer, but I think it is for the 'zoom' effect on the products. Did you notice that effect?
Ray, remove your console.log() from your js as it makes the demo fail if you do not have a console open.
As I made clear - I left that in on purpose. Please try with Chrome, or Firefox+Firebug. Or, you can download the zip and run it locally assuming you installed the CF demo apps.
Now I see the zoom effect on his site. Neat. He must use that tag (rel) as a placeholder for some custom JS script as I don't believe "rel" is a normal attribute for the <a> tag. I could be wrong though. Thanks for pointing me to his site!
Ray, use the following to make your code run regardless of console availability:
If(console) console.log("something");
I use it all the time in a simple log function.
I am perfectly aware of how to code around this - it's just that I don't _want_ to. ;) Maybe I'm being a jerk about this, but I kinda expect Ajax developers to use a browser that supports this out of the box. Regular users? Of course not. But "us" dev folks should not be complaining about it.
Haha...no sweat. I should have known you knew about it but figured I'd mention it anyway. :-)
Good stuff though.
Yeah Sorry Ray, I didn't really read the entire post just clicked on the demo link. I use Prototype and Scriptaculous but from time to time I like to see jQuery examples.
No need to apologize - I'm on somewhat of a religious crusade for the JS console. ;)
Had design questions of my own around this topic. When the possibility of reducing the page refreshes came around, I wanted to ajax everything.
What I learned was that you're not trying to replace HTML/CSS [looking at you flash websites], you're trying to make HTML/CSS faster/simple to use.
I primarily use ajax to reduce page refreshes and that's all.
I manage sessions and apps and files and databases with CF, and just connect the pieces with JS. If you're a halfway decent programmer you can keep the parts and pieces really small so there's no question of CF load to me.
Code files that do too much are always bad IMO. So, breaking it up in to tiny little pieces is a more manageable load anyway.
Network issues I would usually get around in some logical manner. If you have some big dataset the user needs to download, then cache it and use it as a "dictionary". If you have a too many little requests, then you probably need to look at creating some js object to track all the interface changes, then process them once when saving or checking out or whatever.
END RANT
OK, apparently there's something foundational about AJAX that I still don't understand...
I didn't think you could use the session scope with AJAX request because in my mind, an AJAX request was like another process coming in stateless.
It is stateless. But like any other HTTP request, it can use cookies. Don't even think about Ajax. It's all HTTP requests. Whether it's you typing in the URL in the browser or clicking a link or jQuery firing it, it's all the same.
@Phillip
Yeah, sessions, cookies, etc available over ajax. Just cfoutput some variable on your ajax url and see if you can't get a value from it with an ajax call.
I'm sure this is impressive indeed, but try as I might, load and reload, the buttons and demos do nothing when I click on them. Not in Google Chrome, or in the version of IE that's in FeedDemon. That leads me to remain cautious about using jquery for anything as critical as a shopping cart where users might not get the full experience.
If Ray's demo was a real shopping cart, I'd be a lost customer.
Your concerns are definitely valid, but I can assure you many large, commercial sites make use of jQuery. This demo represents approximately one hour of work. Any "real" app would have a bit more QA done to it. ;) That being said, let's try to diagnose why it isn't working for you in Chrome. It won't work in IE due to the console.log msg.
In Chrome, open your dev tools and see what error is being reported.
Now this is interesting. I thought the meta data was returned in upper case, and it is if you do this in the cfc:
<cfset var result = {}>
<cfset result.myName = session.myName>
<cfreturn result>
Then it IS returned in upper case.
But if you simply do a <cfreturn session>, then lowercase myname is a variable in the returned event instead of upper case MYNAME.
<cfset session.myName = "Phillip Senn">
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<title>SESSION</title>
<script src="http://www.google.com/jsapi"></script>
<script type="text/javascript">
google.load("jquery", "1");
</script>
<script>
$(function() {
$.ajax({
url: 'Remote.cfc'
,dataType: 'json'
,data: {
method:'mySession'
,returnFormat:'json'
}
,error: function(XMLHttpRequest, textStatus, errorThrown) {
$('#msg').text(textStatus).addClass('err')
}
,success: function(result){
console.log(result);
console.log('result.myname is:',result.myname);
console.log('result.MYNAME is:',result.MYNAME);
}
});
});
</script>
</head>
<body>
<div id="msg"></div>
</body>
</html>
<cfcomponent>
<cffunction name="mySession" access="remote">
<cfreturn session>
</cffunction>
</cfcomponent>
My understanding is that it is in _how_ you create the struct. So if you use dot notation than it is always upper cased. If you use brackets, then it is in the case you used. So this will always be uppercase:
<cfset s.fOO = 1>
<cfset s.ooo = 2>
And this will keep the same case.
<cfset s["foO"] = 1>
<cfset s["gOX"] = 2>
Oh! That's what Andy Matthews was talking about on that other AJAX post!
It's all coming around now.
I'm trying to get in the habit of always using brackets in struct creation...can't count how many times I've banged my head against some piece of broken AJAX, only to realize that keys were improperly cased :)
Should point out too that the same holds true for implicit structure creation, just different syntax:
<cfscript>
mystruct = {admin=true,"falSIfy"=false};
</cfscript>
will produce a structure with keys "ADMIN" and "falSIfy"
wow ! that's what i am looking for. thank you very much dude.
It would be really great if you were to include download files with your post. It will make following your tutorials much more effective especially for novice coldfusion developers like myself.
I did - and normally do. See the "Download attached file" link at the end of the blog entry?
Sometimes on jQuery-ish entries I won't if you just need to view source to get the code.
I have a question on the use of the session scope in storing the cart items. I am building a simple cart, and using a similar approach you describe here. It is part of an existing web application using CFCs in a shared directory. When I send my info to my service, which I've added to a CFC in this directory, via AJAX call, it ends up in a different session than the one started when the application is called. As Ajax calls are stateless, and thus leaving the door open for hitting sessions other than the one available to my CF code, how could I manage this so I can make anything sent via Ajax to my service available to my application?
Ajax calls will pass along the same cookies CF makes to identify you for sessions, so if your CFC updates session.foo, it will be for the same user... unless you put the CFC in a folder w/ another Application.cfc.
How would you get the quantities of each item to refresh in real time?
Why would you need that? Unless the user opens another tab to go shopping, it really isn't necessary. You could use a JS timeout to check the server every 30 seconds.
Whoops, I should clarify - I was thinking more along the lines of an incremental indicator alongside each item showing the current quantity in your cart. Alongside a "+/-" button to adjust quantity and update every time you add or subtract, i.e.: [ - ] 0 [ + ]
Your CFC would simply have a method that allows you to add one or subtract one of a line item from a cart. It's definitely doable. I'm not going to write it out in a blog comment of course. But you could do it rather quickly I'd think.