Introduction
The Music Store 3.0 sample application is an excellent tutorial for ASP.NET MVC 3.0.
The shopping cart, however, doesn't follow the practical
workflow and lacks some important features. For example, we need to go back to select the same album item and add it to cart to increase the quantity by one.
Clicking the "Remove from cart" link each time deducts the quantity by one, not removing the selected cart album item. We need to repeat the same action
multiple times to add or remove a particular album item having a quantity more than one. To correct the problems, we need a feature for editing and refreshing
the quantity field on the page, which is a basic requirement for a shopping cart of any real online store.
Since I couldn't find any articles or discussions regarding the shopping cart quantity updating issue for the Music Store sample application, I tried to do the work based
on the functional requirements listed below.
- Clicking the "Remove from cart" link will remove the selected album item in the cart no matter what the quantity is.
- Make the Quantity field editable.
- Add a "Refresh quantity" link to perform album item count updates including database actions. The link should also perform the same item removal action when the Quantity field
for a particular album item is empty or has a zero value.
- Implement custom client-side jQuery unobtrusive validations and server-side error handling for the input field.
When running the application, the shopping cart screen looks like this.
Changes for Remove from Cart Link
We only need to remove some lines of code in the RemoveFromCart()
function in the ShoppingCart
class and $(".RemoveLink").click(function ())
in the
JavaScript section on the shopping cart view. The lines to be removed are shown below with comment symbols plus double dashes. As a result, the unwanted behavior, deducting the count
one at a time, is removed from the code.
In Models\ShoppingCart.cs:
public int RemoveFromCart(int id)
{
var cartItem = storeDB.Carts.Single(
cart => cart.CartId == ShoppingCartId
&& cart.RecordId == id);
int itemCount = 0;
if (cartItem != null)
{
storeDB.Carts.Remove(cartItem);
storeDB.SaveChanges();
}
return itemCount;
}
In Views\Index.cshtml:
$(".RemoveLink").click(function () {
var recordToDelete = $(this).attr("data-id");
if (recordToDelete != '') {
clearUpdateMessage();
$.post("/ShoppingCart/RemoveFromCart", { "id": recordToDelete },
function (data) {
$('#row-' + data. DeleteId).fadeOut('slow');
$('#cart-total').text(data.CartTotal);
$('#update-message').text(data.Message);
$('#cart-status').text('Cart (' + data.CartCount + ')');
});
}
The "Remove from cart" link may not be important when we provide the "Refresh quantity" link with input value zero or null for the quantity,
which performs the same action as the "Remove from cart" link.
Adding an Input TextBox for the Quantity Field
The shopping cart view is associated with the ShoppingCartViewModel
that contains the collection of Cart
objects in the data model. Since we need the input field
for each cart item in the CartItems
list, the structure is similar to a data grid with an editable column. We also need an index number to define the
individual cart item. On the shopping cart view, we can change the @foreach()
loop into the for()
loop but I just want to keep the original code as much as possible.
- Add a local variable just above the
@foreach()
line:
@{int ix = 0;}
- In the
@foreach
loop, replace the <td>
tag holding Count data with the HTML Helper TextBoxFor()
. We also need to adjust the
text box width (default value 300px for the input text element set in site.css) and align the number characters to the right.
Old code:
<td id="item-count-@item.RecordId">
@item.Count
</td>
New code:
<td>
@Html.TextBoxFor(model => model.CartItems[ix].Count,
new { style = "width:30px; text-align:right;"
})
</td>
- Add code for matching the current index number as the last line in the
foreach()
loop, just before the closing curly bracket.
i++;
Adding the Refresh Quantity Link
We need to obtain the input text box ID value and pass it to the AJAX call in addition to the cart item ID value. By default, the
Razor engine automatically renders the list of element IDs by the format of "generic-list-object-name_index-number__object-date-property".
Note that there are double underscore characters after the index number. In our case, the ID value of the first input element should be "CartItem_0__Count and the second "CartItem_1__Count", etc.
There is also a particular format for the element "name" attribute but we do not need it here. If you want to customize the element
ID values, you need to use @Html.TextBox()
instead of @Html.TextBoxFor()
. But in this case, you need to specify custom attributes in the third parameter
of @Html.TextBox()
for manually rendering the
unobtrusive validation HTML.
We also need to define a custom attribute "txt-id
" for passing the input element ID in the “Refresh quantity” link. Add the <a>
tag just before the
line of "Remove from cart" <a>
tag:
<a href="#" class="RefreshQuantity" data-id="@item.RecordId"
txt-id="CartItems_@(ix)__Count">Refresh quantity</a> |
Adding a jQuery AJAX Function for Refreshing Quantity
We already know the data passed from the custom attributes: date-id and txt-id. Creating the client–side AJAX function for refreshing the quantity
updates is easy. Just copy the click function for RemoveLink and do some modifications.
$(".RefreshQuantity").click(function () {
var recordToUpdate = $(this).attr("data-id");
var countToUpdate = $("#" + $(this).attr("txt-id")).val();
if (recordToUpdate != '') {
$.post("/ShoppingCart/UpdateCartCount", { "id": recordToUpdate, "cartCount": countToUpdate },
function (data) {
if (data.ItemCount == 0) {
$('#row-' + data. DeleteId).fadeOut('slow');
}
$('#update-message').text(data.Message);
$('#cart-total').text(data.CartTotal);
$('#cart-status').text('Cart (' + data.CartCount + ')');
});
}
We declare a vaviable countToUpdate
which holds the data from the user input and then passes it as the second JSON parameter "cartCount
" for the AJAX post.
Handling the callback data is the same as for the "Removing from cart" link.
Adding Server-side Functions for Refreshing Quantity
We do not change the data properties in any model this time. We just add functions to save the album item in the cart to the
database and then return the data in JSON format for messaging purpose.
In Controllers\ShoppingCartController.cs:
[HttpPost]
public ActionResult UpdateCartCount(int id, int cartCount)
{
var cart = ShoppingCart.GetCart(this.HttpContext);
string albumName = storeDB.Carts
.Single(item => item.RecordId == id).Album.Title;
int itemCount = cart.UpdateCartCount(id, cartCount);
string msg = "The quantity of " + Server.HtmlEncode(albumName) +
" has been refreshed in your shopping cart.";
if (itemCount == 0) msg = Server.HtmlEncode(albumName) +
" has been removed from your shopping cart.";
var results = new ShoppingCartRemoveViewModel
{
Message = msg,
CartTotal = cart.GetTotal(),
CartCount = cart.GetCount(),
ItemCount = itemCount,
DeleteId = id
};
return Json(results);
}
In Models\ShoppingCart.cs:
public int UpdateCartCount(int id, int cartCount)
{
var cartItem = storeDB.Carts.Single(
cart => cart.CartId == ShoppingCartId
&& cart.RecordId == id);
int itemCount = 0;
if (cartItem != null)
{
if (cartCount > 0)
{
cartItem.Count = cartCount;
itemCount = cartItem.Count;
}
else
{
storeDB.Carts.Remove(cartItem);
}
storeDB.SaveChanges();
}
return itemCount;
}
Our quantity input field accepts the value zero. If the value zero is passed from the cartCount
parameter, the updating process will remove the cart
item, the same action as the "Remove from cart" link. If you don't like this behavior and want to disallow zero value entry, you may change the range validation
setting as mentioned in the next section.
So far so good. When running the application and adding multiple album items from Home page or the Genre menu to the shopping cart, we will
get the quantity editable list on the Shopping Cart page. We can then change the quantity, and refresh or delete any album item in the cart,
as the desired functionality (see the screenshot shown previously).
Adding Unobtrusive Validations and Server Error Handling
Before the editable quantity field is put into use, we need to add data validation approaches based on these rules.
- The input value must be numeric.
- The value range must be between 0 and 100.
- An empty value is allowed but treated as value 0. "Field display-name is required" message will not be rendered.
- The error message should be displayed on the top of the list or grid, not inside of it.
When running the application, the screens are shown as below.
MVC 3.0 comes with the nice feature of jQuery unobtrusive validations for data input but there are a few sample applications that show the implementation for a list of the same
model objects (entities). Let’s do the unobtrusive validations for the list of same input fields here.
Firstly, add the ComponentModel
and DataAnnotations
namespace references and validation attributes to Cart.cs. The code in the file should be like this:
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Web.Mvc;
namespace MvcMusicStore.Models
{
public class Cart
{
[Key]
public int RecordId { get; set; }
public string CartId { get; set; }
public int AlbumId { get; set; }
[Required(AllowEmptyStrings = true, ErrorMessage = " ")]
[Range(0, 100, ErrorMessage = "Quantity must be between 0 and 100")]
[DisplayName("Quantity")]
public int Count { get; set; }
public System.DateTime DateCreated { get; set; }
public virtual Album Album { get; set; }
}
}
We set AllowEmptyStrings
to true for the Required
attribute. We then set ErrorMessage
to contain one space character for the
attribute. If setting it to an empty string (""), we will get the error "ErrorMessageString or ErrorMessageResourceName must be set, but not both". This occurs due mostly
to a bug for setting the default ErrorMessage
in the System.ComponentModel.DataAnnotations.RequiredAttribute
class. When setting a custom message
for the attribute, it does not allow an empty string. But placing a space character in the message will do the trick and disable the message display.
We then override the default display name "Count" with "Quantity" so that any default error message using the "Count" name will show "Quantity" instead.
When setting the Range
attribute, MVC also sets the numeric checker and renders "The field display-name must be numeric" message automatically.
Next, let's change something in Views\ShoppingCart\index.cshtml and make the validations in effect.
- The Music Store 3.0 sample application already enables unobtrusive JavaScript using the setting in the site root web.config file but the jQuery library references
are specified locally on each involved view, not in the global template Shared\_Layout.cshtml. Add the
jQuery library references to Views\ShoppingCart\index.cshtml so that the beginning of the view should have these lines:
@model MvcMusicStore.ViewModels.ShoppingCartViewModel
@{
ViewBag.Title = "Shopping Cart";
}
<script src="@Url.Content("~/Scripts/jquery.validate.min.js")" type="text/javascript"></script>
<script src="@Url.Content("~/Scripts/jquery.validate.unobtrusive.min.js")" type="text/javascript"></script>
- Add code to include the page content in an HTML form. The validation will not work without having the HTML form.
@using (Html.BeginForm())
{
@*All lines from <h3> tag to end are inside here*@
}
- Place a
<div>
tag containing the code to display the validation error messages above the code line <div id="update-message"></div>
.
This will render a list of tags for error messages based on a list of album items currently in the cart. We want to display error messages on the top of
the grid structure, not in each row in the column. Fortunately, the Razor engine and HTML Helper allow us to place the validation error messages in other places,
not always around the input field areas.
<div>
@for (int i = 0; i < Model.CartItems.Count; i++)
{
<p>
@Html.ValidationMessageFor(model => model.CartItems[i].Count)
</p>
}
</div>
- Add code into the jQuery
$(".RefreshQuantity").click(function ())
to set the quantity to zero when input value is empty.
$(".RefreshQuantity").click(function () {
var recordToUpdate = $(this).attr("data-id");
var countToUpdate = 0;
if ($("#" + $(this).attr("txt-id")).val() !== '') {
countToUpdate = $("#" + $(this).attr("txt-id")).val();
}
if (recordToUpdate != '') {
- - - -
Finally, we need to manually handle the server-side validation errors. When the input value is above the maximum of the range and the client-side
error message is displayed, clicking the "Refresh quantity" link a second time will send invalid data to the server and cause a server
error "Validation failed for one or more entities". We need to catch the error and return a custom error message to the client-side.
This is done by adding and modifying code in the UpdateCartCount()
function of ShoppingCartController.cs. The revised function is shown below.
[HttpPost]
public ActionResult UpdateCartCount(int id, int cartCount)
{
ShoppingCartRemoveViewModel results = null;
try
{
var cart = ShoppingCart.GetCart(this.HttpContext);
string albumName = storeDB.Carts
.Single(item => item.RecordId == id).Album.Title;
int itemCount = cart.UpdateCartCount(id, cartCount);
string msg = "The quantity of " + Server.HtmlEncode(albumName) +
" has been refreshed in your shopping cart.";
if (itemCount == 0) msg = Server.HtmlEncode(albumName) +
" has been removed from your shopping cart.";
results = new ShoppingCartRemoveViewModel
{
Message = msg,
CartTotal = cart.GetTotal(),
CartCount = cart.GetCount(),
ItemCount = itemCount,
DeleteId = id
};
}
catch
{
results = new ShoppingCartRemoveViewModel
{
Message = "Error occurred or invalid input...",
CartTotal = -1,
CartCount = -1,
ItemCount = -1,
DeleteId = id
};
}
return Json(results);
}
When a server validation error occurs, we don't need to pass data in the AJAX callback except the record ID (DeleteId, in this case) so we set the value of all other data
fields to -1. In the AJAX callback function from Views\ShoppingCart\index.cshtml, we then check that only if data.ItemCount
is not -1, should the cart-total
and cart-status (for Cart Summary partial view) be processed.
function (data) {
if (data.ItemCount == 0) {
$('#row-' + data.DeleteId).fadeOut('slow');
}
$('#update-message').text(data.Message);
if (data.ItemCount != -1) {
$('#cart-total').text(data.CartTotal);
$('#cart-status').text('Cart (' + data.CartCount + ')');
}
}
If we continue to click the "Refresh quantity" link without correcting the out-of-range data, the custom server error message is shown on the Shopping Cart page.
Fine Tune for Shopping Cart
At this point, there are still some minor issues that need to be fixed.
- When entering only one or more space characters, there is neither a validation error message display nor a process similar to null quantity occurring. We need to add
a trim function to make it an empty string. Thus refreshing the shopping cart will remove the album item. The jQuery built-in trim function doesn't work
for my IE browser so that I added a custom function to Views\ShoppingCart\index.cshtml.
if (typeof String.prototype.trim !== 'function') {
String.prototype.trim = function () {
return this.replace(/^\s+|\s+$/g, '');
}
}
Then we need to call the trim()
function for checking the empty string in $(".RefreshQuantity").click(function ())
. The updated code is like this:
var countToUpdate = 0;
if ($("#" + $(this).attr("txt-id")).val().trim() !== '') {
countToUpdate = $("#" + $(this).attr("txt-id")).val();
}
- The client-side validation message can automatically be cleared when re-entering the correct number into the Quantity field. But the server-side
error message display doesn't have such a behavior. We can add a JavaScript function to Views\ShoppingCart\index.cshtml.
function clearUpdateMessage() {
$('#update-message').text('');
}
The function is called from the onkeyup
and onchange
event triggers of the input element.
We just add attribute items into the second parameter of @Html.TextBoxFor()
.
@Html.TextBoxFor(model => model.CartItems[ix].Count,
new { style = "width:30px; text-align:right;",
onkeyup = "clearUpdateMessage();",
onchange = "clearUpdateMessage();"
})
- The HTML encoded message string returned from the AJAX call cannot automatically be decoded when rendered from jQuery code. We need to manually handle the issue
for some special characters, for example, the "Górecki" shown on the first screenshot (otherwise "Górecki" would be displayed). We can use a
simple JavaScript function to decode the HTML entities.
function htmlDecode(value) {
if (value) {
return $('<div />').html(value).text();
}
else {
return '';
}
}
We can then call the htmlDecode
function for decoding the data.message
string in $(".RefreshQuantity").click(function ())
.
$('#update-message').text(htmlDecode(data.Message));
Conclusion
By adding the quantity update feature into the shopping cart for the MVC Music Store sample application, we not only make the business logic for the shopping car more
realistic but also dig more into the jQuery/AJAX data updates and unobtrusive input validations, especially for the ViewModel that contains the input fields
in a list of model objects.
Happy coding with the ASP.NET MVC!