If you used wordpress, you will notice how it lets you assign categories to your posts. These categories or Tags are hierarchical and nested. One can assign one or more of these categories to a post (article). Here I’ll show you how to create such functionality and apply that to Questions inside a Quiz.
The Schema
In this example we will see how we can apply the Tags functionality to a Quiz taking website where you have many questions in the database. The Tags (Categories) are assigned to Questions and are of Many to Many (M:M) relationship with Question objects. In our database we will have a table for Questions, a table for Tags and a table called Question_Tags containing the many to many relationship between questions and Tags. Also the Tags table contains ParentTagId foreign key to enable parent-child relationship among the tags. This schema will accommodate multiple hierarchical levels of Tags. For the top level tags, the ParentTagId will be null.
We will use Microsoft ASP.NET MVC 5.2.2 along with Entity framework 6.1.1. We will be creating Model classes for Questions, Tags and Question_Tags. We’ll also create View Model classes: QuestionVM, TagVM to pass it onto our Razor Views.
Model Classes
The Question class which represents the Questions table is as follows;
1 2 3 4 5 6 7 8 9 10 11 |
public class Question { public int QuestionId { get; set; } public string Title { get; set; } public string Content { get; set; } .... public virtual List Question_Tags { get; set; } public void CopyFrom(QuestionVM qvm) { QuestionTypeId = qvm.QuestionTypeId; Title = qvm.Title; ..... Content = qvm.Content; OwnerId = qvm.OwnerId; } } |
Our Tag class is as follows
1 2 3 4 5 6 7 8 |
public class Tag { public int TagId { get; set; } public int? ParentTagId { get; set; } public string TagName { get; set; } public virtual List Question_Tags { get; set; } public virtual Tag ParentTag { get; set; } public virtual List ChildTags { get; set; } } |
Question_Tag class which represents a M:M relationship is as follows:
1 2 3 4 5 6 7 8 9 |
public class Question_Tag { public int Id { get; set; } public int QuestionId { get; set; } public int TagId { get; set; } public virtual Question Question { get; set; } public virtual Tag Tag { get; set; } } |
View Models
We will create two Viewmodel classes for this purpose. View Model objects contain only the data that is needed to display a View.
QuestionVM class
This class objects are created by the Question controller and the data is copied from Question objects using QuestionVM.CopyFrom(Question) method. Notice that QuestionVM contains TagVM objects whereas Question objects contain Question_Tag objects which inturn point to a Tag object.
Since we need to create a hierarchical Tag list in the View, we need all the Tags from the dB to be present in the view model as a List <TagVM>. For a given Question, the assigned Tags will have a selected=true and the rest will have selected=false. The QuestionVM constructor will populate the AllTagVMs list with all the available Tags from the database and initially assign selected=false. Later on the CopyFrom() method will assign selected=true for those Tags that are associated with a given Question.
Finally the GetOrderedList() recursive method takes the AllTagVMs list and orders in the correct order so that all child tags are next to their parent tags.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
public class QuestionVM { public int QuestionId { get; set; } public int OwnerId { get; set; } public string Title { get; set; } public string Content { get; set; } ... public List<TagVM> AllTagVMs { get; set; } public QuestionVM (EduToolsContext db) { GivenAnswerId = -1; AllTagVMs = new List(); if (db != null) foreach (var x in db.Tags.ToList()) AllTagVMs.Add(new TagVM() { Selected = false, TagId = x.TagId, TagName = x.TagName, ParentTagId = x.ParentTagId }); } public QuestionVM() { ... AllTagVMs = new List(); } public void CopyFrom(Question q) { QuestionId = q.QuestionId; QuestionTypeId = q.QuestionTypeId; Title = q.Title; .... foreach (var qt in q.Question_Tags) { AllTagVMs.Find(x => x.TagId == qt.TagId).Selected = true; } AllTagVMs = GetOrderedList().ToList(); } // this will order the Tags based on the parent child relationship public IEnumerable GetOrderedList( int? parentID = null) { foreach (var item in this.AllTagVMs.Where(x => x.ParentTagId == parentID).ToList()) { item.level++; yield return item; foreach (var child in GetOrderedList(item.TagId)) { child.level++; yield return child; } } } } |
TagVM Class
1 2 3 4 5 6 7 8 9 10 11 |
public class TagVM { public int TagId { get; set; } public int? ParentTagId { get; set; } public string TagName { get; set; } public bool Selected { get; set; } public int level { get; set; } public TagVM () { Selected = true; level = 0; } } |
Views
Edit.cshtml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
@model EduTools.Models.QuestionVM @{ ViewBag.Title = "Edit"; } <h2>Edit</h2> @using (Html.BeginForm()){ .... // code to display the Question data goes here .... // below code displays all the Tags in the dB in hierarchical format along with checkboxes to select/deselect inside a scrollable box <div class="form-group TagCloud"> <h3>Select Categories</h3> @Html.EditorFor(model => model.AllTagVMs) </div> } |
The .TagCloud CSS class makes the DIV look like a 300X200 scrollable container box.
1 2 3 4 5 |
.TagCloud { border:2px solid #ccc; width:300px; height: 200px; overflow-y: scroll; } |
EditorFor Template for the TagVM
The following editorfor template creates the Tags list in a hierarchical format. It uses the level field to know the level of a Tag and applies indentation based on the level.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
@model EduTools.Models.TagVM @{ Layout = null; } <div class="col-md-10"> @Html.HiddenFor(model => model.TagId) @if (Model.ParentTagId != null) { for (int n = 1; n < (Model.level - 1) * 4; n++) { @: } } @Html.CheckBoxFor(model => model.Selected, new { id = Model.TagId }) @Html.DisplayFor(model => model.TagName) </div> |
Controller
The Question controller Edit() method is responsible for creating a Question based on the questionid GET variable. Then it will create a QuestionVM object and copies from the Question object and finally calls the view.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
// GET: /Question/Edit/5 public ActionResult Edit(int? id, string referer) { if (id == null) { return new HttpStatusCodeResult(HttpStatusCode.BadRequest); } Question q = db.Questions.Find(id); if (q == null) return HttpNotFound(); if (!IsAdmin() && q.OwnerId!=CurrentUserId) return new HttpStatusCodeResult(HttpStatusCode.BadRequest); QuestionVM vm = new QuestionVM(db); vm.strMCAnswerIds = string.Join(",", q.Question_MCAnswers.Select(x => x.MCAnswerId).ToArray()); vm.CopyFrom(q); return View(vm); } [HttpPost] [ValidateAntiForgeryToken] public ActionResult Edit([Bind(Include = "QuestionId,Title,Content,QuestionTypeId,CorrectMCAnswerId,CorrectNumericAnswer,CorrectTextAnswer, CorrectBooleanAnswer, OwnerId, strMCAnswerIds, AllTagVMs")] QuestionVM qvm) { if (ModelState.IsValid) { Question q = db.Questions.Find(qvm.QuestionId); q.CopyFrom(qvm); // all all question_tag entries that were selected foreach (var x in qvm.AllTagVMs) { // only if it was not already there then add a new entry if (x.Selected && !q.Question_Tags.Any(y => y.TagId == x.TagId)) db.Question_Tags.Add(new Question_Tag() { QuestionId = qvm.QuestionId, TagId = x.TagId }); else if (!x.Selected) { if (q.Question_Tags.Any(y => y.TagId == x.TagId)) { // see if it was de-selected. If this Tag is is in the Question_Tags list then it was de-selected Question_Tag qt = q.Question_Tags.Where(y => y.TagId == x.TagId).Single(); db.Entry(qt).State = EntityState.Deleted; } } } db.SaveChanges(); return Redirect (Request.Url.ToString()); } return View(qvm); } |
The final output of the Edit request http://localhost/Question/Edit/qid is as follows. You can see that the Tags are arranged hierarchically and only the assigned Tags are pre selected. You can deselect them or select new Tags and hit Save button and the controller Edit() method will save them into the database.