VueJs + MVC minimum code maximum functionality

Published on June 09, 2019

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.splendorvery difficult.
How to deal with this ?? Who cares please under the cat.


And so what we have:
Building MVC forms in the classic form does not imply any other way of interacting with the server as an entire page load, 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 within this project because:
-A lot of code is written, accepted and no one will allow to redo.
-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:


  • VueJs is used in the simplest version with connection via cdn. Components if required the same can be connected via cdn.
  • After loading, Vue loads form data via Ajax.
  • Every time a form is changed, Vue sends all changes to the server (for text fields, you can configure that changes are sent when the focus is lost).
  • On the server, through the Entity mechanism, validation occurs and non-valid fields are returned to the client and a sign that the model state has changed in relation to the database.
    -If another validation request occurs earlier than the previous one returned, the previous validation request will be canceled.
    MVC model is not used. The WPF function ViewModel is spread out here between vue and the controller.
    The advantages of this implementation over the classic Razor page:
  • The interface is drawn by means of Vue which is sharpened for drawing interfaces. The main advantage.
  • separation of View layers from ViewModel.
  • Validation errors are displayed as the form is filled.
  • convenience of testing
    Disadvantages:
  • Excessive server load with validation requests.
  • The need to know vue and js in the minimum amount.


    I consider this approach as the initial template for working with the form.
    In a real application for a specific form, it is desirable to optimize:
    1) To send a validation request only when the fields are changed, the validation of which is necessary to be performed on the server.
    2) Validations are long, fields are full, etc. perform on the client.



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.
Important note that if the cshtml page works without a model, you must add the inherits System.Web.Mvc.WebViewPage to the top of the page .
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 verify 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 like 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, everything is 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 execution speed, of course, you need 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.