ASP.NET MVC Task Manager (aka ‘Mr. Marky’)
In this blog I will introduce an online task manager created using the ASP.NET MVC framework. Here is a demo site and screen shot.
Demo Download
Background:
The purpose of the project is to explore more deeply the ASP.NET MVC framework while creating something that may be useful (at least to me). ‘Mr. Marky’ is an online task management system. At this point, the feature set really only covers the basics. You can create a new task, add items to the task, and check them off when you are done. In addition, the site provides the membership authentication and registration.
Models:
To contain the task and item data the following model classes are used:
[Serializable] public class TaskItem { public string Name { get; set; } public bool IsComplete { get; set; } } [Serializable] public class Task { public Guid? Id { get; set; } public string UserName { get; set; } public string Name { get; set; } public List<TaskItem> Items { get; set; } public Task() { Id = Guid.NewGuid(); UserName = ""; Name = ""; Items = new List<TaskItem>(); } public Task(string taskName) { Id = Guid.NewGuid(); UserName = ""; Name = taskName; Items = new List<TaskItem>(); } }
Both classes are decorated with the ‘Serializable’ attribute for the purpose of enabling the objects to be serialized (to XML) as a means of persistence. This is an architecture decision based upon the availability of databases provided by my hosting service (GoDaddy).
A ‘Task’ object holds the information for a task. This task can have many ‘TaskItem’ objects. The ‘Task’ class also has a couple of constructors that are used to instantiate ‘Task’ objects.
To use these objects in the Mr. Marky application the following interface must be implemented:
interface ITaskRepository { List<Task> Tasks(string userName); bool Create(Task task); Task Retrieve(Guid taskId); bool Update(Task task); bool Delete(Task task); }
This interface simply defines the CRUD (create, retrieve, update, delete) and list all methods. Using an interface provides an easy way to mock the data source for testing purposes (this blog entry will not cover testing). The following is an implementation of the interface that persists data in XML format.
public class XmlTaskRepository : ITaskRepository { public string XmlFileName { get; private set; } public XmlTaskRepository() { Initialize(); } private void Initialize() { // Create the task repository // string xmlFileUrl = "~/AppData/tasks.xml"; if (ConfigurationSettings.AppSettings["XmlDataFileUrl"] != null) { xmlFileUrl = ConfigurationSettings.AppSettings["XmlDataFileUrl"]; } if (!VirtualPathUtility.IsAppRelative(xmlFileUrl)) { throw new ArgumentException("XmlDataFileUrl must be app-relative"); } string fullyQualifiedPath = VirtualPathUtility.Combine( VirtualPathUtility.AppendTrailingSlash(HttpRuntime.AppDomainAppVirtualPath), xmlFileUrl); XmlFileName = HostingEnvironment.MapPath(fullyQualifiedPath); _tasks = LoadTaskData(); } #region IItemsRepository Members private List<Task> _tasks = null; public List<Task> Tasks(string userName) { var tasks = from p in _tasks where p.UserName == userName select p; return tasks.ToList(); } public bool Create(Task task) { _tasks.Add(task); return SaveTaskData(); } public Task Retrieve(Guid id) { Task task = null; try { task = (from p in _tasks where p.Id == id select p).First(); } catch { } return task; } public bool Update(Task task) { if (task.Id.HasValue) { Task taskToUpdate = Retrieve(task.Id.Value); if (taskToUpdate != null) { taskToUpdate.Name = task.Name; taskToUpdate.Items = task.Items; return SaveTaskData(); } else { if (Create(task)) { return SaveTaskData(); } else { return false; } } } else { return false; } } public bool Delete(Task task) { if (task != null) { if (_tasks.Remove(task)) { return SaveTaskData(); } else { return false; } } else { return false; } } #endregion #region Serialization private List<Task> LoadTaskData() { List<Task> tasks = new List<Task>(); if (File.Exists(XmlFileName)) { // Open a stream for reading. // Stream fileStream = null; try { fileStream = new FileStream(XmlFileName, FileMode.Open, FileAccess.Read); } catch (Exception ex) { // TODO: Log unable to open the file stream. // } if (fileStream != null) { try { XmlSerializer xmlSerializer = new XmlSerializer(typeof(List<Task>)); tasks = (List<Task>)xmlSerializer.Deserialize(fileStream); } catch (Exception ex) { // TODO: Log deserialization exception // tasks = new List<Task>(); } finally { // Close the stream. // fileStream.Close(); } } } return tasks; } private bool SaveTaskData() { bool result = false; // Open up a file stream for saving. // Stream fileStream = null; try { fileStream = new FileStream(XmlFileName, FileMode.Create, FileAccess.Write); } catch (Exception ex) { // TODO: Log unable to open the file stream. // } if (fileStream != null) { try { XmlSerializer xmlSerializer = new XmlSerializer(typeof(List<Task>)); xmlSerializer.Serialize(fileStream, _tasks); result = true; } catch (Exception ex) { // TODO: Log serialization exception. // } finally { fileStream.Close(); } } return result; } #endregion }
The constructor calls the ‘Initialize’ method that pulls the file location information from the Web.Config configuration file. Then the methods of the ‘IItemsRepository’ interface are implemented. The last bit of code handles the serialization / de-serialization of the data to the file. I have stubbed in exception handling and added comments where logging should be implemented.
There is also a transitional model that I use for editing. This is the model for the edit view. Here is the code:
public class TaskEditMV : IDataErrorInfo { public Guid? Id { get; set; } public string Name { get; set; } public string Items { get; set; } public TaskEditMV() { Id = Guid.NewGuid(); } public TaskEditMV(Task task) { Id = task.Id; Name = task.Name; Items = ""; foreach (var item in task.Items) { if (!string.IsNullOrEmpty(Items)) { Items += "n"; } if (item.IsComplete) { Items += "x "; } Items += item.Name; } } public Task ConvertToTask() { Task task = new Task(); task.Id = Id; task.Name = Name; string[] tasks = Items.Split('n'); foreach (string item in tasks) { string temp = item.Trim(); if (string.IsNullOrEmpty(temp)) { continue; } TaskItem ti = new TaskItem(); if (temp.Length > 2 && temp.Substring(0, 2) == "x ") { ti.IsComplete = true; ti.Name = temp.Substring(2); } else { ti.IsComplete = false; ti.Name = temp; } task.Items.Add(ti); } return task; } #region IDataErrorInfo Members public string Error { get { return null; } } public string this[string columnName] { get { if (columnName == "Name" && string.IsNullOrEmpty(Name)) { return "'Name' is required."; } return null; } } #endregion }
This model transforms the a ‘Task’ object into a format that is needed by the edit view (more later). The transformation is done by the constructor and the de-transformation by the ‘ConvertToTask’ method. Because this data will be edited by the user, this model implements the ‘IDataErrorInfo’ interface. This interface is used by the MVC model binding to provide server side validation on the data provided by the user.
That’s it for the model. Let’s take a look at the views.
Views:
This MVC application uses a master page to provide some common content. The following views provide content for the mater page. There is a ‘List’ view that presents a list of tasks for the user and an ‘Edit’ view that allows the user to edit the details of the task.
The ‘List’ view mark-up is shown below:
<asp:Content ID="Content2" ContentPlaceHolderID="MainContent" runat="server"> <script src="<%= Url.Content("~/Scripts/items.js") %>" type="text/javascript"></script> <div id="outer"> <div class="menu"> <div class="commands"> <% if (Model.Count() == 0) { %> <span class="get_started">Get started by adding tasks here ===></span> <% } %> <a class="hoverbox" href="<%= Url.Action("Edit") %>" > <img alt="add" title="add" src="<%= Url.Content("~/Content/Images/add.png") %>" /> </a> </div> </div> <% foreach (var task in Model) {%> <dl class="task"> <dt> <a class="title" href=""><%= Html.Encode(task.Name)%></a> <span class="commands"> <a class="confirm_delete hoverbox" href="<%= Url.Action("DeleteTask", new { task.Id } ) %>" > <img alt="delete" title="delete task" src="<%= Url.Content("~/Content/Images/delete_hover.png") %>" /> </a> </span> </dt> <dd> <div class="detail"> <ul> <% foreach (var item in task.Items) { %> <li> <% if (item.IsComplete) { %> <span class="item_status complete"></span> <% } else { %> <span class="item_status"></span> <%} %> <%= Html.ActionLink(Html.Encode(item.Name), "ToggleCheck", new { task.Id, item.Name }, new { @class = "toggleCheck", @id = task.Id + "|" + item.Name })%> <span class="commands"> <a class="confirm_delete hoverbox" href="<%= Url.Action("DeleteTaskItem", new { task.Id, item.Name } ) %>" > <img alt="delete" title="delete item" src="<%= Url.Content("~/Content/Images/delete_hover.png") %>" /> </a> </span> </li> <% } %> </ul> <div class="commands"> <a class="hoverbox" href="<%= Url.Action("Edit", new { task.Id } ) %>" > <img alt="add" title="add" src="<%= Url.Content("~/Content/Images/add.png") %>" />or <img alt="edit" title="edit" src="<%= Url.Content("~/Content/Images/edit.gif") %>" /> </a> </div> </div> </dd> </dl> <% } %> </div> </asp:Content>
This mark-up provides the ability to interact with the tasks and task items. It also provides the necessary CSS hooks to allow the site to be styled. In addition, the site uses jQuery to enhance the usability with client-side JavaScript.
The top portion of the mark-up defines a menu area. The remaining mark-up creates the definition list (‘dl’ element) containing the tasks. The definition title (‘dt’ element) is used to contain the title and a delete task method. The definition detail (‘dd’ element) is used to contain the items associated with each task.
The following bit of jQuery is used to initially hide the ‘detail’ div (inside the ‘dd’ element) and later display it when the user clicks the anchor tag (inside the ‘dt’ element).
$(".detail").hide(); $(".title").click(function() { $(this).parent().next("dd").find("div.detail").toggle("slow"); return false; });
The following jQuery allows all anchors that delete (either a task or an item) to provide a client-side confirmation before executing the action.
$("a.confirm_delete").click(function() { return confirm("Are you sure you want to delete?"); });
And finally, the following jQuery hooks up the item anchors (inside the ‘ul’ / ‘li’ list) to an AJAX call that toggles the completion status of that item.
$("a.toggleCheck").click(function() { var id = $(this).attr("id"); $(this).parent().find(".item_status").toggleClass("complete"); $.getJSON("/Task/ToggleIsComplete", { data: id }, function(data) { if (data != true) { alert("Failed to change the complete status."); } }); return false; });
There is a fair amount of CSS for this page. Please either download the project or use your favorite browser tool (Firebug in Firefox for instance) to finesse out these details.
The following mark-up defines the ‘Edit’ view:
<asp:Content ID="Content2" ContentPlaceHolderID="MainContent" runat="server"> <div id="outer"> <% if (TempData["IsInvalid"] != null) { %> <div class="message"> <%= Html.ValidationSummary("Save was unsuccessful. Please correct the errors and try again.")%> </div> <% } %> <div class="darkarea"> <% using (Html.BeginForm()) {%> <%= Html.Hidden("Id", Model.Id) %> <p class="field"> <label for="Name">Task Name:</label> <%= Html.TextBox("Name", Html.Encode(Model.Name) ) %> <%= Html.ValidationMessage("Task Name", "*") %> </p> <p class="field"> <label for="Items">Items (one per line, start with 'x ' for complete):</label> <%= Html.TextArea("Items", Html.Encode(Model.Items), new { rows="5" })%> </p> <div style="clear:both; "></div> <div class="commands"> <input type="submit" value="Save" /> <%=Html.ActionLink("Back to List", "List") %> </div> <% } %> </div> </div> </asp:Content>
Here we leverage the MVC model binding (using the IDataErrorInfo interface) to provide feedback to the user of errors in the entry form. Note that this is server-side validation and the application should probably have some measure of clients-side validation.
Controllers:
The following is the ‘TaskController’ for the above model and views.
[Authorize] public class TaskController : Controller { private ITaskRepository _taskRepository; public TaskController() { _taskRepository = new XmlTaskRepository(); } public ViewResult List() { return View(_taskRepository.Tasks(User.Identity.Name)); } public RedirectToRouteResult DeleteTask(Guid id) { Task task = _taskRepository.Retrieve(id); if (task != null) { if (task.UserName == User.Identity.Name) { if (_taskRepository.Delete(task)) { TempData["message"] = "'" + task.Name + "' has been deleted"; } else { TempData["message"] = "'" + task.Name + "' could not be deleted"; } } else { TempData["message"] = "Cannot delete a task that is not yours."; } } else { TempData["message"] = "Task was not found. Delete failed."; } return RedirectToAction("List"); } public RedirectToRouteResult DeleteTaskItem(Guid id, string name) { Task task = _taskRepository.Retrieve(id); if (task != null) { if (task.UserName == User.Identity.Name) { TaskItem taskItem = null; try { taskItem = (from p in task.Items where p.Name.Equals(name, StringComparison.CurrentCultureIgnoreCase) select p).First(); } catch { // TODO: Log a LINQ exception here. // } if (taskItem == null) { TempData["message"] = "Item not found. Delete failed."; } else { if (task.Items.Remove(taskItem) && _taskRepository.Update(task)) { TempData["message"] = "'" + taskItem.Name + "' has been deleted."; } else { TempData["message"] = "'" + taskItem.Name + "' was not deleted."; } } } else { TempData["message"] = "Cannot modify a task that is not yours."; } } else { TempData["message"] = "Task was not found. Delete failed."; } return RedirectToAction("List"); } public JsonResult ToggleIsComplete(string data) { bool result = false; int positionOfSeperator = data.IndexOf('|'); Guid taskId = new Guid(data.Substring(0, positionOfSeperator)); string itemName = data.Substring(positionOfSeperator + 1); Task task = _taskRepository.Retrieve(taskId); if (task != null) { if (task.UserName == User.Identity.Name) { for (int i = 0; i < task.Items.Count; i++) { if (task.Items[i].Name.Equals(itemName, StringComparison.CurrentCultureIgnoreCase)) { task.Items[i].IsComplete = !task.Items[i].IsComplete; result = _taskRepository.Update(task); } } } } return this.Json(result); } // This action method will get called only when the JavaScript is disabled. // Otherwise, the 'ToggleIsComplete' ajax method handles the request. // public ViewResult ToggleCheck(Guid id, string name) { Task task = _taskRepository.Retrieve(id); if (task != null) { for (int i = 0; i < task.Items.Count; i++) { if (task.UserName == User.Identity.Name) { if (task.Items[i].Name.Equals(name, StringComparison.CurrentCultureIgnoreCase)) { task.Items[i].IsComplete = !task.Items[i].IsComplete; if (_taskRepository.Update(task) && TempData["message"] == null) { TempData["message"] = "Item was updated."; } } } else { TempData["message"] = "Cannot modify a task you do not own."; } } } else { TempData["message"] = "Task was not found. Delete failed."; } return View("List", _taskRepository.Tasks(User.Identity.Name)); } [AcceptVerbs(HttpVerbs.Get)] public ViewResult Edit(Guid? id) { if (id.HasValue) { Task task = _taskRepository.Retrieve(id.Value); if (task.UserName == User.Identity.Name) { return View(new TaskEditMV(task)); } else { TempData["message"] = "Cannot modify a task that is not yours."; return View("List"); } } else { Task task = new Task(); task.UserName = User.Identity.Name; return View(new TaskEditMV(task)); } } [AcceptVerbs(HttpVerbs.Post)] [ValidateInput(false)] public ActionResult Edit(TaskEditMV taskEditModelView) { Task task = taskEditModelView.ConvertToTask(); task.UserName = User.Identity.Name; if (!task.Id.HasValue) { task.Id = Guid.NewGuid(); } if (ModelState.IsValid) { if (_taskRepository.Update(task)) { TempData["message"] = "'" + task.Name + "' has been saved."; } return RedirectToAction("List"); } else { TempData["IsInvalid"] = true; return View(taskEditModelView); } } }
The class is marked with the ‘Authorize’ attribute, thus requiring all action methods to have a known user logged into the system (more on authentication / authorization later). The constructor of this controller simply creates an instance of the ‘XmlTaskRepository’ class that we discussed in the models section. The following action methods are contained in the controller:
- List – This action method serves up the ‘List’ view that we covered earlier.
- DeleteTask – This action method provides the functionality to delete a task (and all its items). Although this method requires authentication, it is still vulnerable to a CSRF attack and could use a bit more security.
- DeleteTaskItem – This action method allows the deletion of an item from the given task.
- ToggleIsComplete – This is an AJAX action method that toggles the status (complete or not complete) of the item.
- ToggleCheck – This is an action method that toggles the status of the item in the case where JavaScript is disabled.
- Edit (Get and Post) – These methods handle the HTTP GET and POST for the ‘Edit’ view. Note that the POST method once again leverages the model binding of the MVC framework.
Authentication / Authorization:
This application uses the ‘ActionController’ and the corresponding views that are generated with the new MVC project. However, because my web hosting provider limits the number of databases that I can have, I converted the membership provider to use an XML file instead of the SQL Server database. As a starting point, I used the provider created by Mads Kristensen. This code provides a drop in replacement for the SQL Server membership provider. Be sure to read the comments as they provide hints to a number of errors that need to be fixed.
Summary:
I have a few more ideas to help Mr. Marky become more useful. Using the ASP.NET MVC framework to create a website that has forms authentication was very easy. The MVC design pattern promotes separation in the presentation layer.