📜 ⬆️ ⬇️

VueJs + MVC minimum code maximum functionality

Good day.


I have used WPF for many years. The MVVM pattern is probably one of the most convenient architectural patterns. I assumed that MVC is almost the same. When I saw the use of MVC in practice at a new place of work, I was surprised at the complexity and lack of elementary usability. Most annoying is that validation occurs only when the form is overloaded. There are no red frames to highlight the field in which the error is, but an alert with a list of errors is simply displayed. If there are many errors, then you have to correct some of the errors and retain to save in order to repeat the validation. The save button is always active. The linked lists are true implemented through js, but difficult and confusing. The model, the view, and the controller are tightly coupled so test it all. splendor very difficult.
How to deal with this ?? Who cares please under the cat.


And so what we have is:
Building MVC forms in the classic form does not imply any other way to interact with the server as an overload of the page as a whole, which is not convenient for the user.
Full use of frameworks like Reart, Angular, Vue and switching to SinglePageApplicatrion would make more convenient interfaces, but unfortunately, in principle, it is not possible in this project because:
- A lot of code is written, accepted and no one will allow to remake.
-We are C # programmers and do not know js in the right amount.


In addition, the Reart, Angular, Vue frameworks are sharpened for writing complex logic on the client, which is not correct in my WPF. All logic should be in one place and this is a business object and / or model class. View should only display the status of the model no more.
Based on the above, I tried to find an approach that allows me to get maximum functionality with a minimum of js code. First of all, there is a minimum of code that you need to write to display and update a specific field.
The bundle of VueJs + MVC offered by me looks like this:



So let's go.


As a database in my example, I used the Northwind training database that I downloaded with one of the examples of Devextreem.
Creating an application, connecting the Entity and creating a DbContext I will leave behind the scenes. Link to github with an example at the end of the article.
Create a new empty MVC 5 controller. Let's call it OrdersController. There is only one method in it.


public ActionResult Index() { return View(); } 

Add one more


  public ActionResult Edit() { return View(); } 

Now you need to go to the Views / Orders folder and add two pages Index.cshtml and Edit.cshtml
The important note is that in order for a cshtml page to work without a model, you must add to the top of the page the inherits System.Web.Mvc.WebViewPage.
It is assumed that Index.cshtml contains a table from which the selected line will be taken to the edit page. For now, let's just create links that will lead to the edit page


 @inherits System.Web.Mvc.WebViewPage <table > @foreach (var item in ViewBag.Orders) { <tr><td><a href="Edit?id=@item.OrderID">@item.OrderID</a></td></tr> } </table> 

Now I want to implement editing an existing object.


The first thing to do is to describe a method in the controller that would return the object description to the Json client by ID.


  [HttpGet] public ActionResult GetById(int id) { var order = _db.Orders.Find(id);//  string orderStr = JsonConvert.SerializeObject(order);//  return Content(orderStr, "application/json");// } 

You can check that everything works by typing in the browser (the port number is naturally yours) http: // localhost: 63164 / Orders / GetById? Id = 10501
You should get something in the browser


 { "OrderID": 10501, "CustomerID": "BLAUS", "EmployeeID": 9, "OrderDate": "1997-04-09T00:00:00", "RequiredDate": "1997-05-07T00:00:00", "ShippedDate": "1997-04-16T00:00:00", "ShipVia": 3, "Freight": 8.85, "ShipName": "Blauer See Delikatessen", "ShipAddress": "Forsterstr. 57", "ShipCity": "Mannheim", "ShipRegion": null, "ShipPostalCode": "68306", "ShipCountry": "Germany" } 

Well, and (or) writing a simple test. However, let's leave testing outside of this article.


  [Test] public void OrderControllerGetByIdTest() { var bdContext = new Northwind(); var id = bdContext.Orders.First().OrderID; //    var orderController = new OrdersController(); var json = orderController.GetById(id) as ContentResult; var res = JsonConvert.DeserializeObject(json.Content,typeof(Order)) as Order; Assert.AreEqual(id, res.OrderID); } 

Next you need to create a Vue form.


 @inherits System.Web.Mvc.WebViewPage <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <title> </title> <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script> </head> <body> <div id="app"> <h1>A  </h1> <table > <tr v-for="(item,i) in order"> @*      *@ <td> {{i}}</td> <td> <input type="text" v-model="order[i]"/> </td> </tr> </table> </div> <script> new Vue({ el: "#app", data: { order: { OrderID: 10501, CustomerID: "BLAUS", EmployeeID: 9, OrderDate: "1997-04-09T00:00:00", RequiredDate: "1997-05-07T00:00:00", ShippedDate: "1997-04-16T00:00:00", ShipVia: 3, Freight: 8.85, ShipName: "Blauer See Delikatessen", ShipAddress: "Forsterstr. 57", ShipCity: "Mannheim", ShipRegion: null, ShipPostalCode: "68306", ShipCountry: "Germany" } } }); </script> </body> </html> 

If everything is done correctly, then the prototype of the future form should be displayed in the browser.



As we can see, Vue displayed all the fields exactly as the model was. But the data in the model is still static and the first thing to do next is to implement loading data from the database using the method just written.
To do this, add the fetchOrder () method and call it in the mounted section:


  new Vue({ el: "#app", data: { id: @ViewBag.Id, order: { OrderID: 0, CustomerID: "", EmployeeID: 0, OrderDate: "", RequiredDate: "", ShippedDate: "", ShipVia: 0, Freight: 0, ShipName: "0", ShipAddress: "", ShipCity: "", ShipRegion: null, ShipPostalCode: "", ShipCountry: "" }, }, methods: { //  fetchOrder() { var path = "../Orders/GetById?key=" + this.id; console.log(path); this.fetchJson(path, json => this.order = json); }, //    fetch fetchJson(path, collback) { try { fetch(path, { mode: 'cors' }) .then(response => response.json()) .then(function(json) { collback(json); } ); } catch (ex) { alert(ex); } } }, mounted: function() { this.fetchOrder(); } }); 

Well, since the object identifier should now come from the controller, then in the controller it is necessary to transfer the identifier to the dynamic ViewBag object in order to get it in the View.


  public ActionResult SimpleEdit(int id = 0) { ViewBag.Id = id; return View(); } 

It is enough that the data be read at boot.
It's time to customize the form.
That would not overload the article, I brought a minimum of fields. I suggest to begin to understand how to work with linked lists.


  <table > <tr> <td> </td> <td > <input type="number" v-model="order.Freight" /> </td> </tr> <tr> <td>  </td> <td> <input type="text" v-model="order.ShipCountry" /> </td> </tr> <tr> <td> </td> <td> <input type="text" v-model="order.ShipCity" /> </td> </tr> <tr> <td> </td> <td> <input type="text" v-model="order.ShipAddress" /> </td> </tr> </table> 

The ShipCountry and ShipAddress fields are the best candidates for linked lists.
Here are the controller methods. As you can see, it's pretty simple. All filtering is done with Linq.


  /// <summary> ///    c     ///       ,    /// </summary> /// <param name="country"></param> /// <param name="region"></param> /// <returns></returns> [HttpGet] public ActionResult AvaiableCityList( string country,string region=null) { var avaiableCity = _db.Orders.Where(c => ((c.ShipRegion == region) || region == null)&& (c.ShipCountry == country) || country == null).Select(a => a.ShipCity).Distinct(); var jsonStr = JsonConvert.SerializeObject(avaiableCity); return Content(jsonStr, "application/json"); } /// <summary> ///    c   ///    ,    /// </summary> /// <param name="region"></param> /// <returns></returns> [HttpGet] public ActionResult AvaiableCountrys(string region=null) { var resList = _db.Orders.Where(c => (c.ShipRegion == region)||region==null).Select(c => c.ShipCountry).Distinct(); var json = JsonConvert.SerializeObject(resList); return Content(json, "application/json"); } 

But in the View code added significantly more.
In addition to the actual functions of the recital of countries and cities, you have to add a watch that monitors object changes, unfortunately the old value of a complex vue object does not save, so you need to save it manually, for which I came up with the saveOldOrderValue method: for now I only save the country in it. This allows you to re-read the list of cities only when the country changes. In the rest of the code I think the same is clear. In the example, I showed only a single-level linked list (according to this principle, it is not difficult to make nesting at any level).


 @inherits System.Web.Mvc.WebViewPage <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <title> </title> <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script> </head> <body> <div id="app"> <table> <tr> <td>C </td> <td> <input type="number" v-model="order.Freight" /> </td> </tr> <tr> <td>  </td> <td> <select v-model="order.ShipCountry" class="input"> <option v-for="item in AvaialbeCountrys" :v-key="item">{{item}} </option> </select> </td> </tr> <tr> <td> </td> <td> <select v-model="order.ShipCity" > <option v-for="city in AvaialbeCitys" :v-key="city">{{city}} </option> </select> </td> </tr> <tr> <td> </td> <td> <input type="text" v-model="order.ShipAddress" /> </td> </tr> </table> </div> <script> new Vue({ el: "#app", data: { id: @ViewBag.Id, order: { OrderID: 0, CustomerID: "", EmployeeID: 0, OrderDate: "", RequiredDate: "", ShippedDate: "", ShipVia: 0, Freight: 0, ShipName: "0", ShipAddress: "", ShipCity: "", ShipRegion: null, ShipPostalCode: "", ShipCountry: "" }, oldOrder: { ShipCountry: "" }, AvaialbeCitys: [], AvaialbeCountrys: [] }, methods: { //  fetchOrder() { var path = "../Orders/GetById?Id=" + this.id; this.fetchJson(path, json => this.order = json); }, fetchCityList() { //     var country = this.order.ShipCountry; if (country == null || country === "") { country = ''; } var path = "../Orders/AvaiableCityList?country=" + country; this.fetchJson(path, json => {this.AvaialbeCitys = json;}); }, fetchCountrys() { var path = "../Orders/AvaiableCountrys"; this.fetchJson(path,jsonResult => {this.AvaialbeCountrys = jsonResult;}); }, //    fetch fetchJson(path, collback) { try { fetch(path, { mode: 'cors' }) .then(response => response.json()) .then(function(json) { collback(json); } ); } catch (ex) { alert(ex); } }, saveOldOrderValue:function(){ this.oldOrder.ShipCountry = this.order.ShipCountry; } }, watch: { order: { handler: function (after) { if (this.oldOrder.ShipCountry !== after.ShipCountry)//    { this.fetchCityList();//       } this.saveOldOrderValue(); }, deep: true } }, mounted: function () { this.fetchCountrys();//   //    ,      this.fetchOrder();//  this.saveOldOrderValue();//   } }); </script> </body> </html> 

Separate topic Validation. From the point of view of optimizing the speed of execution, of course, it is necessary to do validation on the client. But this will lead to duplication of code, so I am setting an example with validation at the Entity level (As it should be, ideally). When this code is at a minimum, the validation itself occurs fairly quickly and also asynchronously. As practice has shown, even with a very slow Internet, everything works more than normal.
Problems arise only if the text is typed quickly in the text field, and the typing speed is 260 characters per minute. The simplest optimization option for text fields is to install a lazy v-model update .lazy = "order.ShipAddress", then validation will occur when the focus is changed. A more advanced option is to delay Validation + for these fields. If the next validation request is called before receiving the response, ignore the processing of the previous request.
I got the following methods for processing validation in control.


  [HttpGet] public ActionResult Validate(int id, string json) { var order = _db.Orders.Find(id); JsonConvert.PopulateObject(json, order); var errorsD = GetErrorsJsArrey(); return Content(errorsD.ToString(), "application/json"); } private String GetErrorsAndChanged() { var changed= _db.ChangeTracker.HasChanges(); var errors = _db.GetValidationErrors(); return GetErrorsAndChanged(errors,changed); } private static string GetErrorsAndChanged(IEnumerable<DbEntityValidationResult> errors,bool changed) { dynamic dynamic = new ExpandoObject(); dynamic.IsChanged = changed;//  IsChanged var errProperty = new Dictionary<string, object>();//      dynamic.Errors = new DynObject(errProperty);//        foreach (DbEntityValidationResult validationError in errors)//   { foreach (DbValidationError err in validationError.ValidationErrors)//   { errProperty.Add(err.PropertyName,err.ErrorMessage); } } var json = JsonConvert.SerializeObject(dynamic); return json; } 

     DynObject 

  public sealed class DynObject : DynamicObject { private readonly Dictionary<string, object> _properties; public DynObject(Dictionary<string, object> properties) { _properties = properties; } public override IEnumerable<string> GetDynamicMemberNames() { return _properties.Keys; } public override bool TryGetMember(GetMemberBinder binder, out object result) { if (_properties.ContainsKey(binder.Name)) { result = _properties[binder.Name]; return true; } else { result = null; return false; } } public override bool TrySetMember(SetMemberBinder binder, object value) { if (_properties.ContainsKey(binder.Name)) { _properties[binder.Name] = value; return true; } else { return false; } } } 

It is quite verbose, but this code is written once to the entire application and does not require additional settings for a specific object or field. As a result, the method works on the client’s json object with the IsChanded and Errors properties. These properties naturally need to be created in our Vue and populated with each change of the object.
To get validation errors, you need to set this validation somewhere. The time is right now to add several validation attributes in our description of the Entity of the Order object.


  [MinLength(10)] [StringLength(60)] public string ShipAddress { get; set; } [CheckCityAttribute(" ShipCity   ")] public string ShipCity { get; set; } 

MinLength and StringLength are standard attributes, but for ShipCity I created a custom attribute


  /// <summary> /// Custom Attribute Example /// </summary> [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] public class CheckCityAttribute : ValidationAttribute { public CheckCityAttribute(string message) { this.ErrorMessage = message; } protected override ValidationResult IsValid(object value, ValidationContext validationContext) { ValidationResult result = ValidationResult.Success; string[] memberNames = new string[] { validationContext.MemberName }; string val = value?.ToString(); Northwind _db = new Northwind(); Order order = (Order)validationContext.ObjectInstance; bool exsist = _db.Orders.FirstOrDefault(o => o.ShipCity == val && o.ShipCountry == order.ShipCountry)!=null; if (!exsist) { result = new ValidationResult(string.Format(this.ErrorMessage,order.ShipCity , val), memberNames); } return result; } } 

However, let's leave the topic of Entity validation, too, beyond the scope of this article.
In order to display errors you need to add a link to Css and slightly modify the form.
This is how our modified form should now look:


 @inherits System.Web.Mvc.WebViewPage <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <title> id=@ViewBag.Id</title> <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script> <link rel="stylesheet" type="text/css" href="~/Content/vueError.css" /> </head> <body> <div id="app"> <table> <tr> <td> </td> <td class="tooltip"> <input type="number" v-model="order.Freight" v-bind:class="{error:!errors.Freight==''} " class="input" /> <span v-if="errors.Freight!==''" class="tooltiptext">{{errors.Freight}}</span> </td> </tr> <tr> <td>  </td> <td> <select v-model="order.ShipCountry" class="input"> <option v-for="item in AvaialbeCountrys" :v-key="item">{{item}} </option> </select> </td> </tr> <tr> <td> </td> <td class="tooltip"> <select v-model="order.ShipCity" v-bind:class="{error:!errors.ShipCity==''}" class="input"> <option v-for="city in AvaialbeCitys" :v-key="city">{{city}} </option> </select> <span v-if="!errors.ShipCity==''" class="tooltiptext">{{errors.ShipCity}}</span> </td> </tr> <tr> <td> </td> <td class="tooltip"> <input type="text" v-model.lazy="order.ShipAddress" v-bind:class="{error:!errors.ShipAddress=='' }" class="input" /> <span v-if="!errors.ShipAddress==''" class="tooltiptext">{{errors.ShipAddress}}</span> </td> </tr> <tr> <td> </td> <td> <button v-on:click="Save()" :disabled="IsChanged===false" || hasError class="alignRight">Save</button> </td> </tr> </table> </div> <script> new Vue({ el: "#app", data: { id: @ViewBag.Id, order: { OrderID: 0, CustomerID: "", EmployeeID: 0, OrderDate: "", RequiredDate: "", ShippedDate: "", ShipVia: 0, Freight: 0, ShipName: "0", ShipAddress: "", ShipCity: "", ShipRegion: null, ShipPostalCode: "", ShipCountry: "" }, oldOrder: { ShipCountry: "" }, errors: { OrderID: null, CustomerID: null, EmployeeID: null, OrderDate: null, RequiredDate: null, ShippedDate: null, ShipVia: null, Freight: null, ShipName: null, ShipAddress: null, ShipCity: null, ShipRegion: null, ShipPostalCode: null, ShipCountry: null }, IsChanged: false, AvaialbeCitys: [], AvaialbeCountrys: [] }, computed : { hasError: function () { for (var err in this.errors) { var error = this.errors[err]; if (error !== '' || null) return true; } return false; } }, methods: { //  fetchOrder() { var path = "../Orders/GetById?Id=" + this.id; this.fetchJson(path, json => this.order = json); }, fetchCityList() { //     var country = this.order.ShipCountry; if (country == null || country === "") { country = ''; } var path = "../Orders/AvaiableCityList?country=" + country; this.fetchJson(path, json => {this.AvaialbeCitys = json;}); }, fetchCountrys() { var path = "../Orders/AvaiableCountrys"; this.fetchJson(path,jsonResult => {this.AvaialbeCountrys = jsonResult;}); }, //    fetch Validate() {this.Action("Validate");}, Save() {this.Action("Save");}, Action(action) { var myJSON = JSON.stringify(this.order); var path = "../Orders/" + action + "?id=" + this.id + "&json=" + myJSON; this.fetchJson(path, jsonResult => { this.errors = jsonResult.Errors; this.IsChanged = jsonResult.IsChanged; }); }, fetchJson(path, collback) { try { fetch(path, { mode: 'cors' }) .then(response => response.json()) .then(function(json) { collback(json); } ); } catch (ex) { alert(ex); } }, saveOldOrderValue:function(){ this.oldOrder.ShipCountry = this.order.ShipCountry; } }, watch: { order: { handler: function (after) { this.IsChanged=true; if (this.oldOrder.ShipCountry !== after.ShipCountry)//    { this.fetchCityList();//       } this.saveOldOrderValue(); this.Validate(); }, deep: true } }, mounted: function () { this.fetchCountrys();//   //    ,      this.fetchOrder();//  this.saveOldOrderValue();//   } }); </script> </body> </html> 

CSS looks like


 .tooltip { position: relative; display: inline-block; border-bottom: 1px dotted black; } .tooltip .tooltiptext { visibility: hidden; width: 120px; background-color: #555; color: #fff; text-align: center; border-radius: 6px; padding: 5px 0; position: absolute; z-index: 1; bottom: 125%; left: 50%; margin-left: -60px; opacity: 0; transition: opacity 0.3s; } .tooltip .tooltiptext::after { content: ""; position: absolute; top: 100%; left: 50%; margin-left: -5px; border-width: 5px; border-style: solid; border-color: #555 transparent transparent transparent; } .tooltip:hover .tooltiptext { visibility: visible; opacity: 1; } .error { color: red; border-color: red; border-style: double; } .input { width: 200px ; } .alignRight { float: right } 

And this is the result of the work.



To understand how validation works, let's take a close look at the markup describing one field:


 <td class="tooltip"> <input type="number" **v-model="order.Freight" v-bind:class="{error:!errors.Freight==''} " **class="input" /> <span v-if="errors.Freight!==''" class="tooltiptext">{{errors.Freight}}</span> </td> 

Here are 2 important key points:


This part of the markup connects the style responsible for the red frame around the v-bind element : class = "{error:! Errors.Freight == ''} here vue connects the class to the css condition.


And this one for the pop-up window is shown when the mouse cursor is over the element:


  <span v-if="errors.Freight!==''" class="tooltiptext">{{errors.Freight}}</span> 

in addition, the parent element must contain the class = "tooltip" attribute.


In the latter version, a save button has been added that is configured so that it is only available if saving is possible.
In order to simplify the markup necessary for validation, I propose to write the simplest component that would take all the validation on itself.


 Vue.component('error-aborder', { props: { error: String }, template: '<div class="tooltip" >' + '<div v-bind:class="{error:!error==\'\' }" >' + '<slot>test</slot>' + '</div>' + '<p class="tooltiptext" v-if="!error==\'\'" >{{error}}</p>' + '</div>' }); 

now the markup looks more neat.


  <error-aborder v-bind:error="errors.Freight"> <input type="number" v-model="order.Freight" class="input" /> </error-aborder> 

Development is reduced to the location of fields on the form, setting up validation in Entyty and the formation of lists. If the lists are static and not large, then they can be set in the code.


C # part of the code is well tested. In the near future plans to deal with testing Vue.


That's all that I wanted to tell.
I would be very grateful for constructive criticism.


Here is the link to the source code .


In the example, the form is called SimpleEdit and contains the latest version. Those who are interested in preliminary options can go through the committees.
In the example, implemented optimization: interrupting the validation request if, without waiting for the validation response, call validation a second time.


')

Source: https://habr.com/ru/post/455367/


All Articles