Improving UX in web pages (ASP.MVC 4, jQuery Accordion and Knockout.JS)
In one of the pages in a Web Application project, an admin user has the option to set up a list of questions for a form template, which will later be used in the application by regular users, to submit a list of answers specific to that form. The question configuration was looking like below:
The first problem with that configuration, as it can be seen, is the fact that we can have plenty of questions in that form (in this case 30). In the screenshot above only 3 can be seen, imagine the page/form with 30 questions. It’s 10 times bigger.
Another problem is the fact that you must pay a fair amount of attention to notice where a question ends and where the next one starts.
Although there is no clear indication anywhere, each question has an order, which makes this the third problem. Closely related to this, the fourth problem, is the fact that if you decide to change the order of a question in the form template, you have to do that by manually deleting the questions (by clicking the X button next to the question name) and create them again in the order you want.
→ The Accordion
→ The drag and drop question reordering
Drag
Drop
The watch after drag and drop:
1. jQuery accordion
var newOrder = new Array(); $(document).ready(function () { $("#accordion").accordion({ collapsible: true, header: "> div > h3", icons: false, autoFill: true, active: false, header: 'h3' }).sortable({ axis: "y", handle: "h3", stop: function (event, ui) { newOrder = new Array(); $('div.questions > div.accordionPanel > input.orderId').each(function () { //get the id var id = $(this).attr("value"); newOrder.push(id); }); appViewModel.refresh(newOrder) } }); $('#addQuestionLink').click(function () { appViewModel.addQuestion(uuidv4(), "", newQuestionsJSON.Type); $('.questions').accordion("refresh"); $("#accordion").accordion({ active: -1 }); return false; }); }); $('#accordion').on('accordionactivate', function (event, ui) { if (ui.newPanel.length) { $('#accordion').sortable('disable'); } else { $('#accordion').sortable({ enable: 'enable', }); } });
2. Knockout view model
function AppViewModel() { var self = this; self.questionList = ko.observableArray(); self.addQuestion = function (qid, qOrder, title, qIsMandatory) { var question = { id: ko.observable(qid), order: ko.observable(qOrder), isMandatory: ko.observable(qIsMandatory), questionText: ko.observable(title), questionType: ko.observable(qType) }; var delayedQuestionType = ko.pureComputed(question.questionType).extend({ throttle: 50 }); delayedQuestionType.subscribe(function (value) { $('.questions').accordion("refresh"); }); self.questionList.push(question); }; self.removeQuestion = function () { self.questionList.remove(this); } self.refresh = function (newOrder) { var newList = []; newOrder.forEach(function (id, index) { var item = ko.utils.arrayFirst(self.questionList(), function (item) { return item.id() === id; }); if (item != null) { item.order(index); newList.push(item); } }); self.questionList(newList); } self.setQuestions = function (questions) { questions.forEach(function (question, index) { self.addQuestion(question.Id, question.Order, question.Text,question.IsMandatory, question.Type) }); } }
→ The list itself:
@model List<MyWebApplication.ViewModels.QuestionViewModel> @{ Layout = null; } <div class="panel-body"> <div id="accordion" class="questions form-template-questions"> @Html.EditorFor(model=>model[0],"Question") </div> </div>
→The list item (QuestionViewModel):
<!-- ko foreach: questionList --> <div class="accordionPanel namePathRoot"> @Html.HiddenFor(model => model.Id, new { data_bind = "value: id(), namePath: true" ,@class = "orderId"}) @Html.HiddenFor(model => model.Order, new { data_bind = "value: order(), namePath: true" }) <h3 data-bind="text: $index() + 1 + '. ' + questionText()"><span class="required" data-bind="visible: isRequired() ">*</span></h3> <div style="-ms-align-content: stretch; -webkit-align-content:stretch; align-content:stretch"> <div class="input-group"> @Html.TextBoxFor(x => x.Text, new { maxLength = 400, @class = "form-control", data_bind = "value: questionText, namePath: true" }) <div class="action input-group-addon"> <a href="#" data-bind="click: $parent.removeQuestion" class="remove glyphicon glyphicon-remove" title="@MyWebApplication.Resources. Manage.Tooltip_Question_Delete"></a> </div> @Html.ValidationMessageFor(x => x.Text) </div> <div class="form-group"> @Html.LabelFor(x=>x.IsMandatory) @Html.CheckBoxWithNamePathBinding("IsMandatory",Model.IsMandatory, new { data_bind = "checked: isMandatory, namePath: true" } ) </div> <div class="questionType form-group"> @Html.LabelFor(x=>x.Type) @{ @Html.EnumDropDownListFor(x => x.Type, typeof(MyWebApplication. Domain.QuestionType), new { @class = "form-control", data_bind = "value: questionType, namePath: true" }, typeof(MyWebApplication.Resources.Enums), false) } </div>
a. The items reordering – as it can be seen, it’s done when an item stops being dragged, by calling appViewModel.refresh(newOrder). Then the Knockout binding makes sure that the new order is bound into the ASP.NET MVC model.
It was a good exercise to combine all these 3 technologies, ASP.NET MVC, jQuery and Knockout.JS. Many other things can be accomplished in a similar manner, however if you have complex objects or if Knockout.JS was not one of your original choices while your application evolved, then things can easily get complicated. However, if you do have simple objects, or you are working on new features, then Knockout.JS can help provide a significant improvement to the UX, as well as helping keep your code clean and sane, by taking away some uninteresting concerns (like binding – or passing values from client to server side) on client side.
Finally, each piece of technology can make our lives easier only if used correctly. It is up to us to make sure that that happens.
If you enjoyed reading this, you can also check my previous article: