Add, edit, delete list elements

Contents:

  1. Introduction
  2. Add CRUD operations to the generic repository
  3. Define the user model
  4. Add a view for adding and modifying an user
  5. Implement the delete feature
  6. Summary

1. Introduction Go top

We will continue the solution built in the previous post Paging, sorting and filtering a list in ASP.NET MVC 4 and implement new features for adding, modifying and removing a list element. You can download the source code for the previous solution from Codeplex and use it as the starting point for implementing the features discussed here. The final result will look like this:
List-edit result
The main steps are:

  • Extend the generic repository to support CRUD (Create Read Update Delete) operations.
  • Create a view model starting from the User class that was generated by EF that will define the validation rules and will serve as the model for our user edit view.
  • Create controller action methods to support adding and modifying a user and the associated view that knows how to render a User view model.
  • Implement the delete action inside the users list.

2. Add CRUD operations to the generic repository Go top

First we will enhance the generic repository we created in the previous post to support adding, modifying and deleting an entity. We will also add a method for retrieving an entity based on its primary key supposing that the primary key is not composite (is based only on one table column).

Note: None of these methods will call save changes on the context because it will be the invoked from the unit of work.

/// <summary>
/// Retrieve an entity from the repository based on the unique identifier.
/// </summary>
/// <param name="id">The unique identifier of the entity.</param>
/// <returns></returns>
public T GetById(object id)
{
	var entityKey = new EntityKey(EntitySetName, PrimaryKeyName, id);
	object entity;
	if (Context.TryGetObjectByKey(entityKey, out entity))
		return (T)entity;
	
	return null;
}

/// <summary>
/// Add a new item to the repository.
/// </summary>
/// <param name="entity">The element to add to the repository.</param>
/// <returns></returns>
public void Insert(T entity)
{      
	ObjectSet.AddObject(entity);
}

/// <summary>
/// Updates an item in the repository.
/// </summary>
/// <param name="entity">The element to update.</param>
/// <returns></returns>
public void Update(T entity)
{
	if (!IsAttached(entity))
		Context.AttachTo(EntitySetName, entity);

	Context.ObjectStateManager.ChangeObjectState(entity, EntityState.Modified);
}

/// <summary>
/// Deletes an item from the repository.
/// </summary>
/// <param name="entity">The item to be deleted.</param>
/// <returns></returns>
public void Delete(T entity)
{
	if (!IsAttached(entity))
		Context.AttachTo(EntitySetName, entity);

	Context.ObjectStateManager.ChangeObjectState(entity, EntityState.Deleted);
}

/// <summary>
/// Deletes an item from the repository.
/// </summary>
/// <param name="id">The unique identifier of the entity to be deleted.</param>
/// <returns></returns>
public void Delete(object id)
{
	var entity = GetById(id);
	if(entity == null)
		throw new Exception("Entity not found.");
	Delete(entity);
}

/// <summary>
/// Returns true if the entity is attached to the current context.
/// </summary>
/// <param name="entity"></param>
protected bool IsAttached( T entity)
{
	ObjectStateEntry entry;
	Context.ObjectStateManager.TryGetObjectStateEntry(Context.CreateEntityKey(EntitySetName, entity), out entry);

	return !(entry == null || entry.State == EntityState.Detached);
}		

3. Define the user model Go top

Next, we will create the model by adding a partial class for the EF User class. Inside this partial class we define the model validation rules:

  • using attributes for basic field validation rules and
  • implementing the IValidatableObject interface to check if the username is unique among all users.

You can find more details in a previous post that was exclusively dedicated to data validation.

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using HelloWorld.Code.Util;

namespace HelloWorld.Code.DataAccess
{
    [MetadataType(typeof(UserMetaData))]
    public partial class User : IValidatableObject
    {
        /// <summary>
        /// Helper property used to store the URL referrer of the current page. Needed for the 'Go back to list' functionality.
        /// </summary>
        public string UrlReferrer { get; set; }

        /// <summary>
        /// Get a list that contains the available user roles.
        /// </summary>
        public IEnumerable<SelectListItem> UserRolesList
        {
            get
            {
                return (new[]
                            {
                                new SelectListItem
                                    {
                                        Selected = (UserRoleId == 0),
                                        Text = "Select...",
                                        Value = string.Empty
                                    }
                            }).Union(CacheManager.GetUserRoles(HttpContext.Current.Request.HttpMethod == "POST").
                                         Select(c => new SelectListItem
                                         {
                                             Selected = (c.UserRoleId == UserRoleId),
                                             Text = c.UserRoleName,
                                             Value = c.UserRoleId.ToString(CultureInfo.InvariantCulture)
                                         }));
            }
        }

        /// <summary>
        /// Saves the model using the repository and unit of work.
        /// </summary>
        public void Save()
        {
            using (var unitOfWork = new UnitOfWork())
            {
                var repository = unitOfWork.GetUserRepository;

                if (UserId == 0)
                    repository.Insert(this);
                else
                    repository.Update(this);

                unitOfWork.Save();                
            }
        }

        /// <summary>
        /// Validates that the username is unique among all users.
        /// </summary>
        /// <param name="validationContext"></param>
        /// <returns></returns>
        public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
        {
            using (var unitOfWork = new UnitOfWork())
            {
                var repository = unitOfWork.GetUserRepository;
                var existingUser = repository.Search(u => u.UserId != UserId &&
                        string.Compare(u.Username, Username, StringComparison.CurrentCultureIgnoreCase) == 0, null, null);
                
                if(existingUser.Count > 0)
                    yield return new ValidationResult("Username already taken. Please choose another username.", new[] { "Username" });
            }
        }
    }

    public class UserMetaData
    {
        [Required(ErrorMessage = "Required")]
        [StringLength(100, MinimumLength = 3, ErrorMessage = "The first name must be a string of at least 3 characters and at most 100 characters")]
        [Display(Name = "First Name")]
        public string FirstName { get; set; }

        [Required(ErrorMessage = "Required")]
        [StringLength(100, MinimumLength = 3, ErrorMessage = "The last name must be a string of at least 3 characters and at most 100 characters")]
        [Display(Name = "Last Name")]
        public string LastName { get; set; }

        [Required(ErrorMessage = "Required")]
        [StringLength(100, ErrorMessage = "The e-mail can't have more than 100 characters")]
        [RegularExpression(@"^\w+([-+.]*[\w-]+)*@(\w+([-.]?\w+)){1,}\.\w{2,4}$", ErrorMessage = "Invalid e-mail")]
        [Display(Name = "E-mail")]
        public string Email { get; set; }

        [Required(ErrorMessage = "Required")]
        [StringLength(50, MinimumLength = 3, ErrorMessage = "The username must be a string of at least 3 characters and at most 50 characters")]
        public string Username { get; set; }

        [Required(ErrorMessage = "Required")]
        [StringLength(50, MinimumLength = 3, ErrorMessage = "The password must be a string of at least 3 characters and at most 50 characters")]
        public string Password { get; set; }

        [Required(ErrorMessage = "Required")]
        [Range(1, 2, ErrorMessage = "Required")]
        [Display(Name = "User role")]
        public int UserRoleId { get; set; }
    }
}

4. Add a view for adding and modifying an user Go top

For adding and editing an user we will add action methods inside the UsersController and a view for defining the user interface.

[HttpGet]
public ActionResult Add()
{
	ViewBag.IsNewUser = true;
	return View("User", new User {UrlReferrer = Request.UrlReferrer == null ? string.Empty : Request.UrlReferrer.AbsoluteUri});
}

[HttpPost]
public ActionResult Add(User model)
{
	ViewBag.IsNewUser = true;
	return Save(model);
}

[HttpGet]
public ActionResult Edit(int id = 0)
{
	ViewBag.IsNewUser = false;
	var user = new UnitOfWork().GetUserRepository.GetById(id);
	if (user == null)
	{
		ViewBag.Error = "User not found";
		user = new User();
	}
	user.UrlReferrer = Request.UrlReferrer == null ? string.Empty : Request.UrlReferrer.AbsoluteUri;
	
	return View("User", user);
}

[HttpPost]
public ActionResult Edit(User model)
{
	ViewBag.IsNewUser = false;
	return Save(model);
}

protected ActionResult Save(User model)
{
	if(ModelState.IsValid)
	{
		try
		{
			model.Save();
			ViewBag.Message = "The user was saved successfully";
		}
		catch (Exception exp)
		{                    
			ViewBag.Error = "There was an unexpected error while saving the user.";
		}
	}
	return View("User", model);
}

The last thing needed is a view that supports both cases: add new and edit user.

@model HelloWorld.Code.DataAccess.User
@{
    ViewBag.Title = ViewBag.IsNewUser ? "Add a new user" : "Edit user";
}

<h2>@ViewBag.Title</h2>
<hr/>
@if (ViewBag.Error != null && !string.IsNullOrEmpty(ViewBag.Error))
{
    <p style="color: red">@ViewBag.Error</p>
}
else if (ViewBag.Message != null && !string.IsNullOrEmpty(ViewBag.Message))
{
    <p style="color: green">@ViewBag.Message</p>
}
else
{            
    using (Html.BeginForm(ViewBag.IsNewUser ? "Add" : "Edit", "Users", FormMethod.Post))
    {       
        @Html.HiddenFor(model => model.UserId)
        @Html.HiddenFor(model => model.UrlReferrer)        
    
        @Html.LabelFor(model => model.FirstName, new { @class = "control-label" })
        @Html.TextBoxFor(model => model.FirstName)
        @Html.ValidationMessageFor(model => model.FirstName)
        <br/>
        @Html.LabelFor(model => model.LastName, new { @class = "control-label" })
        @Html.TextBoxFor(model => model.LastName)
        @Html.ValidationMessageFor(model => model.LastName)
        <br/>
        @Html.LabelFor(model => model.Email, new { @class = "control-label" })
        @Html.TextBoxFor(model => model.Email)
        @Html.ValidationMessageFor(model => model.Email)
        <br/>
        @Html.LabelFor(model => model.Username, new { @class = "control-label" })
        @Html.TextBoxFor(model => model.Username)
        @Html.ValidationMessageFor(model => model.Username)
        <br/>
        @Html.LabelFor(model => model.Password, new { @class = "control-label" })
        @Html.TextBoxFor(model => model.Password)
        @Html.ValidationMessageFor(model => model.Password)
        <br/>
        @Html.LabelFor(model => model.UserRoleId, new { @class = "control-label" })
        @Html.DropDownListFor(model => model.UserRoleId, Model.UserRolesList)
        @Html.ValidationMessageFor(model => model.UserRoleId)
        <br/>
        <input type="submit" name="Save" value="Save" />
    }
}
<br/>
@if (!string.IsNullOrEmpty(Model.UrlReferrer))
{
    <a href="@Model.UrlReferrer">Go back</a>
}

5. Implement the delete feature Go top

For deleting the user we will modify the index action method associated to the post HTTP method so that it accepts an optional parameter “hfDeleteId ” that, when provided, will contain the id of the user to delete.

[HttpGet]
public ActionResult Index(UsersList model, int page = 1, string sort = "", string direction = "")
{
	model.SetParameters(page, sort, direction, Request.Params);
	return View(model);
}

[HttpPost]
public ActionResult Index(UsersList model, int page = 1, string sort = "", string direction = "", int hfDeleteId = 0)
{           
	if (hfDeleteId > 0)
	{
		#region Delete

		using (var unitOfWork = new UnitOfWork())
		{
			var repository = unitOfWork.GetUserRepository;
			var user = repository.GetById(hfDeleteId);
			if (user != null)
			{
				try
				{
					unitOfWork.GetUserRepository.Delete(user);
					unitOfWork.Save();
					ViewBag.DeleteMessage = "The user was deleted";
					ViewBag.Deleted = true;
				}
				catch
				{
					ViewBag.DeleteMessage = "An unexpected error occured";
					ViewBag.Deleted = false;
				}
			}
			else
			{
				ViewBag.DeleteMessage = "The user to delete wasn't found";
				ViewBag.Deleted = false;
			}
		}

		#endregion

		model.SetParameters(page, sort, direction, Request.Params);
		return View(model);
	}
	
	//We do this redirect in order to preserve the search parameters in the URL after the user presses the search button.
	model.SetParameters(page, sort, direction, Request.Params);
	return RedirectToAction("Index", (new UrlHelper(ControllerContext.RequestContext)).GetRouteValueDictionaryForList(model));
}

The view for the users list was also changed to render two links for each row allowing us to edit and/or delete that user.

@using System.ComponentModel
@using HelloWorld.Code.Util
@model HelloWorld.Models.UsersList
@{
    ViewBag.Title = "Users list";
}
@using (Html.BeginForm("Index", "Users", FormMethod.Post, new {id = "frmSearch"}))
{            
    @Html.LabelFor(model => model.FirstName) 
    @Html.TextBoxFor(model => model.FirstName)
    <br/> 
    @Html.LabelFor(model => model.LastName) 
    @Html.TextBoxFor(model => model.LastName)   
    <input type="submit" name="Search" value="Search"/>   
}
@using (Html.BeginForm("Index", "Users", FormMethod.Post, new {id = "frmDelete"}))
{            
    <input type="hidden" id="hfDeleteId" name="hfDeleteId"/>
    @Html.HiddenFor(model => model.FirstName)
    @Html.HiddenFor(model => model.LastName)                                             
}
@if (!string.IsNullOrEmpty(ViewBag.DeleteMessage))
{
    <p style="color: @(ViewBag.Deleted ? "green" : "red")">@ViewBag.DeleteMessage</p>
}
@if (!Model.PagedList.CurrentPage.Any())
{
    <p>No users were found</p>
}
else
{
    <table>
        <thead>
            <tr>
                <th>@Html.ActionLink("First name", "Index", "Users",
                                    Url.GetRouteValueDictionaryForList(
                                        Model,
                                        sortColumn: "FirstName",
                                        direction: string.Compare(Model.SortColumn, "FirstName",
                                        StringComparison.CurrentCultureIgnoreCase) != 0 ? "asc" : Model.SortDirection == ListSortDirection.Ascending ? "desc" : "asc"), null)</th>
                <th>@Html.ActionLink("Last name", "Index", "Users",
                                    Url.GetRouteValueDictionaryForList(
                                        Model,
                                        sortColumn: "LastName",
                                        direction: string.Compare(Model.SortColumn, "LastName", StringComparison.CurrentCultureIgnoreCase) != 0 ? "asc" : Model.SortDirection == ListSortDirection.Ascending ? "desc" : "asc"), null)</th>
                <th>E-mail</th>
                <th>Role</th>
                <th>Actions</th>
            </tr>
        </thead>
        @foreach (var user in Model.PagedList.CurrentPage)
        {
            <tr>
                <td>@user.FirstName</td>
                <td>@user.LastName</td>
                <td>@user.Email</td>
                <td>@user.UserRole.UserRoleName</td>
                <td>
                    @Html.ActionLink("Edit", "Edit", "Users", new RouteValueDictionary { { "id", @user.UserId } }, null) | 
                    <a href="javascript:;" class="delete" data-id="@user.UserId">Delete</a>                   
                </td>
            </tr>
        } 
    </table>
@Html.PagedListPager(Url, new PagerHtmlRenderer(
                                                currentPageNumber: Model.PagedList.CurrentPageNumber,
                                                pageSize: Model.PagedList.PageSize,
                                                totalNumberOfItems: Model.PagedList.TotalNumberOfItems,
                                                actionName: "Index",
                                                controllerName: "Users",
                                                routeValues: Url.GetRouteValueDictionaryForList(Model),
                                                pageRouteValueName: "page"))
    
}
<br />
<br/>
@Html.ActionLink("Add a new user", "Add", "Users")

@section scripts
{
    <script type="text/javascript">
        $().ready(function () {
            $('.delete').click(function (event) {
                event.preventDefault();
                $("#hfDeleteId").val($(this).attr("data-id"));
                document.forms["frmDelete"].submit();
            });
        });
    </script>    
}

Note that we restricted the deletion only for the HTML POST method, using the [HttpPost] attribute in the controller. Although it is possible to delete an item using a GET method this is not recommended since it might generate a security hole. For example let’s suppose our delete link would not execute any JavaScript to post the form but would only point to an URL that contains the id of the user to delete. Something like this:

<a href="http://localhost:12317/Users/Index?delete=10">Delete</a>   

And let’s also suppose that the controller action method that handles the HTTP GET checks if a delete parameter is passed in the query string and, if so, it deletes the user with that id. Now, when spiders/bots/crawlers will parse our page and try to follow the links they will trigger a delete. So when the site will be online and the Googlebot will try to index our pages it will follow the links it finds and automatically delete our data.

Even if you restrict that link only for authenticated users you will still have a security hole. For example, a user that is logged in could receive an malicious e-mail that contains an image that has the src attribute set to this URL:

<img src="http://localhost:12317/Users/Index?delete=10" />

If the user will allow images from his e-mail client then a delete will happen without the user being aware of that.

As a general rule, remember to use:

  • GET – for safe operations that only retrieve data without changing the state of the resource on the server,
  • POST – for operations that change the state of the resource,

Here is an funny post about Why learning HTTP does matter that summaries very nicely the unwanted behavior we might create using the wrong HTTP methods.

6. Summary Go top

This post is part of a series of demos about starting to work with ASP.NET MVC 4. Until now we have created a simple list that supports paging, sorting and filtering, and we implemented the add, modify and delete list item features as described in this post. You can download the complete source code that includes both the list and the edit from Codeplex.

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s