MVVM
In this article I will show how you can use the MVVM pattern to the fullest using the Knockout.JS framework, with the mapping plug-in add-on. The goal of this article is to show that you can define your view model at the back-end only, and have it "mapped" dynamically at the client side without the need to code it by hand. To make the coding simpler I have decided to use Typescript and test it with JQuery, and Knockout. You can learn more about the mapping plug-in here.
Typescript
To learn more about typescript I recommend you visit this site.Lets get the environment setup
You will need to get a few Nuget packages to get going... here are some of them:
"Next you will create a new TypeScript file and add the following dependencies:
The ViewModel
Notice that I only define the view model properties at the back-end. You will not see the fields of the ViewModel on the client side
public class DomainItem { public string Name { get; set; } public string Description { get; set; } } public class DomainViewModel { public DomainViewModel() { Items = new List(); Status = "Loaded"; } public string Name { get; set; } public List Items { get; set; } public string Status { get; set; } }
The Controller
public class HomeController : Controller { // // GET: /Configuration/ public ActionResult Index() { return View(); } // // GET: /Configuration/Details/5 public ActionResult List() { DomainViewModel vm = new DomainViewModel(); vm.Name = "Name1"; var list = new List(); vm.Items.Add(new DomainItem { Name = "item 1", Description = "This is item 1" }); vm.Items.Add(new DomainItem { Name = "item 2", Description = "This is item 2" }); vm.Items.Add(new DomainItem { Name = "item 3", Description = "This is item 3" }); return Json(vm, JsonRequestBehavior.AllowGet); } public ActionResult Refresh() { Random random = new Random(); int number = random.Next(1, 50); DomainViewModel vm = new DomainViewModel(); vm.Name = "Name" + number; var list = new List (); for (int i = 0; i < number; i++ ) { vm.Items.Add(new DomainItem { Name = "item " + i, Description = "This is item " + i }); } return Json(vm, JsonRequestBehavior.AllowGet); } [HttpPost] public JsonResult SubmitViewModel(DomainViewModel viewModel) { viewModel.Status = "Saved..."; return Json(viewModel); } }
List
returns list of items, this data will be converted into a view model on the clientRefresh
simulates a data update on the back-end and sending the updated the data to the clientSubmitViewModel
Simulates a save operation on the view model, and updating the status field to "Saved..."
The Dynamic ViewModel base class (in Typescript)
This is the base class that holds the necessary operations to dynamically map the view model from the server into the client. It also contains some re-usable flags to indicates if we are in the process of getting data from the back-end, I use this data to notify the user when communication with the back-end occurs.
class DynamicViewModel { // set to the true when the data is loaded from the back-end isLoaded = ko.observable(false); // set to true while the data is loading from the back-end isLoading = ko.observable(false); constructor() { } // show a hidden html id show(id: string) { $(id).show(); } // does an http get to the server, take a url returns the viewmodel. Once the request // is done, the view model is added to this class httpGet(url: string, callback?: (vm) => void) { // keep a pointer to this view model var self = this; // update flags self.isLoaded(false); self.isLoading(true); // make the REST call using GET $.ajax(url, { type: "GET", cache: false, }).done((vm) => { // map the view model data we got from the server into this viewmodel ko.mapping.fromJS(vm, {}, self); // update flags self.isLoaded(true); self.isLoading(false); // if there is a callback, then call it. if (callback !== undefined) return callback(vm) }); } // makes a post call to the server, and updates the viewmodel response httpPost(url: string, onSuccess?: (vm) => void) { // keep a pointer to this viewmodel var self = this; // update flags self.isLoaded(false); self.isLoading(true); // make a POST call to the server $.ajax({ url: url, type: 'post', // pass this viewmodel data: ko.mapping.toJSON(self), contentType: 'application/json', success: function (vm) { // update flags self.isLoaded(true); self.isLoading(false); // update the view model ko.mapping.fromJS(vm, {}, self); // if there is a callback, call it if (onSuccess !== undefined) return onSuccess(vm); } }); } }
ko.mapping.fromJS(vm, {}, self);
this is the code that dynamically expends the view model, and adds the observable items based on the JSON coming back from the back-endko.mapping.toJSON(self)
is used to serialize the view model to JSON so it can be sent to the back-end (it is used on the post)- Making making a post to the server, to send the view model, the
code ko.mapping.toJSON(self)
the magic - Notice that I had to use self, that's because the this keyword changes scope even with Typescript
- the url will be passed from the child class
Child ViewModel
class DomainViewModel extends DynamicViewModel { initUrl: string; postUrl: string; refreshUrl: string; constructor(initUrl: string, refreshUrl: string, postUrl: string) { super(); this.initUrl = initUrl; this.postUrl = postUrl; this.refreshUrl = refreshUrl; } // Initialize the view model for the first time. initializeAction() { super.httpGet(this.initUrl, (data) => { // apply the binding ko.applyBindings(this); // show the window super.show("#Main"); }); } // Update the data on the UI updateAction() { super.httpGet(this.refreshUrl); } submitAction() { super.httpPost(this.postUrl); } }
Notice that the DomainViewModel deals mostly with commands and doesn't actually define the data in the view model. This is because the base class will handle injecting the data in when doing a GET or a Post
The View
@{ ViewBag.Title = "Index"; } @section scripts { <script type="text/javascript"> $(document).ready(function () { // get some url configuration for the view model to do its work var initializeUrl = "@Url.Action("List")"; var refreshUrl = "@Url.Action("Refresh")"; var submitUrl = "@Url.Action("SubmitViewModel")"; var domainViewModel = new DomainViewModel(initializeUrl, refreshUrl, submitUrl); // initialize the view model domainViewModel.initializeAction(); }); </script> @*typed scripted geneated*@ <script src="~/Scripts/TypeScript/ViewModel.js"></script> } <h2>Configuration Controller</h2> <div id="Main" class="part" style="display : none"> <div class="part"> <h3>Binding to a selection box</h3> <select data-bind="options: Items, optionsText: 'Name', optionsCaption: 'Choose...'" size="5" multiple="true"></select> </div> <div class="part"> <h3>Binding to a text box</h3> <input type="text" data-bind="value: Name" /> </div> <div class="part scrollDiv"> <h3>Binding to a table</h3> <table class="table"> <thead> <tr><th>First name</th><th>Last name</th></tr> </thead> <tbody data-bind="foreach: Items"> <tr> <td data-bind="text: Name"></td> <td data-bind="text: Description"></td> </tr> </tbody> </table> </div> <div class="part"> <label data-bind="text: Status"></label> </div> <div class="part"> <button data-bind="click: updateAction">Refresh data</button> </div> <div class="part"> <button data-bind="click: submitAction">Submit data</button> </div> <div class="part"> <label data-bind="if:isLoading">Loading from server...</label> <label data-bind="if:isLoaded"> Loading from server... done</label> </div> </div>
No need to code the ViewModel by hand in Javascript anymore
So thanks to the mapping plug-in for knockout, you don't need to worry about typing all the view model observable by hand anymore. You can use the base class view model and use it to do most of the work for you. This code is not production quality code, and only used to show the use of a dynamic view model using the knockout mapper plug-in
.
Nice article.
ReplyDeleteBut,
you didn't use the power of Typescript here since when you use mapping plugin - fromJson you get object. No code completion and type safety will be provided.
If you find a way to deal with that, let us know :)
Hi, great article, very helpfull.
ReplyDeleteJust a question about it, I saw you are mapping the DynamicViewModel with JS data type, and by the other hand the get method from MVC returns data as JSON.
¿Does the mapper works with "incopatible" data types alike?