Добрый день.


Я много лет использовал WPF. Паттерн MVVM наверное один из наиболее удобных архитектурных паттернов. Я предполагал что MVC почти то же самое. Когда я на новом месте работы я увидел использование MVC на практике, то был удивлен запутанностью и одновременно отсутствием элементарной Юзабилити. Больше всего раздражает то, что валидация происходит только при перегрузке формы. Нет красных рамок подсвечивающих поле в котором ошибка, а просто выводится alert со списком ошибок. Если ошибок много, то приходится исправлять часть ошибок и жать сохранить, что бы повторить валидацию. Кнопка сохранить всегда активна. Связанные списки правда реализованы через js, но сложно и запутанно. Модель, представление и контроллер сильно связаны поэтому протестировать все это великолепие весьма сложно.
Как с этим бороться ?? Кому интересно прошу под кат.


И так что мы имеем:
Построение форм MVC в классическом виде не предполагает другого способа взаимодействия с сервером как перегрузка страницы целиком, что не удобно пользователю.
Полноценное использование фреймворков типа Reart,Angular,Vue и переход на SinglePageApplicatrion позволило бы делать более удобные интерфейсы, но к сожалению в принципе не возможно в рамках данного проекта так как:
-Много кода написано, принято и ни кто не даст переделывать.
-Мы в программисты С# и не знаем js в нужном объеме.


Кроме этого фреймворки Reart, Angular, Vue заточены под написание сложной логики на клиенте, что на мой WPF-ный взгляд не правильно. Вся логика должна быть в одном месте и это бизнес объект и(или) класс модели. View должно всего лишь отображать состояние модели не более того.
Исходя из вышесказанного я постарался найти подход позволяющий с минимум кода на js получить максимум функциональности. В первую очередь минимуме кода который нужно писать для вывода и обновления конкретного поля.
Предлагаемая мной связка VueJs + MVC выглядит так:


  • VueJs используется в простейшем варианте с подключением через cdn. Компоненты если потребуются то же можно через cdn подключать.
  • После загрузки Vue загружает данные формы через Ajax.
  • При каждом изменении формы Vue отправляет на сервер все изменения (для текстовых полей можно настроить, что изменения посылаются при потере фокуса).
  • На сервере через механизм Entity происходит валидация и на клиент возвращаются невалидные поля и признак что состояние модели изменилось по отношению к базе данных.
    -Если очередной запрос валидации произойдет раньше, чем вернулся предыдущий, то предыдущий запрос валидации будет отменен.
    MVC модель не используется. Функция ViewModel в WPF-ном понимании здесь размазана между vue и контроллером.
    Преимущества такого подхода перед классической Razor страницей:
  • интерфейс рисуется средствами Vue который заточен под рисование интерфейсов. Главное преимущество.
  • разделение слоев View от ViewModel.
  • ошибки валидации отображаются сразу.
  • удобство тестирования
    Недостатки:
  • Излишняя нагрузка на сервер запросами валидации.
  • Необходимость знать vue и js в минимальном объеме.

Итак поехали.


В качестве базы данных в своем примере я использовал учебную базу данных Northwind которую скачал с одним из примеров Devextreem.
Создание приложения, подключение Entity и создание DbContext я оставлю за кадром. Ссылка на github с примером в конце статьи.
Создаем новый пустой контроллер MVC 5. Назовем его OrdersController. В нем пока один метод.


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

Добавим еще один


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

Теперь надо перейти в папку Views/Orders и добавить две страницы Index.cshtml и Edit.cshtml
Важное замечание, что бы cshtml страница работала без модели надо обязательно добавить в начало страницы inherits System.Web.Mvc.WebViewPage.
Предполагается, что Index.cshtml содержит таблицу из которой по выделенной строке будет осуществляться переход на страницу редактирования. Пока создадим просто ссылки которые будут вести на страницу редактирования.


@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>

Теперь я хочу реализовать редактирование существующего объекта.


Первое, что необходимо сделать, это описать метод в контроллере который бы по идентификатору возвращал бы на клиент Json описание объекта.


        [HttpGet]
        public ActionResult GetById(int id)
        {
            var order = _db.Orders.Find(id);//Получили объект
            string orderStr = JsonConvert.SerializeObject(order);//Сериализовали его
            return Content(orderStr, "application/json");//отправили 
        }

Проверить, что все работает можно набрав в браузере (номер порта естественно ваш) http://localhost:63164/Orders/GetById?id=10501
Вы должны получить в браузере что то вроде


{
  "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"
}

Ну и (или) написав простейший тест. Однако оставим тестирование за рамками данной статьи


       [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);
        }

Далее необходимо создать Vue форму.


@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>

Если все сделано правильно, то в браузере должен отобразиться прототип будущей формы.



Как мы видим Vue отобразил все поля ровно так, как было модели. Но данные в модели пока статические и первое что нужно сделать дальше, это реализовать загрузку данных из базы через только что написанный метод.
Для этого добавим метод fetchOrder() и будем вызывать его в секции mounted:


        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();
            }
        });

Ну и так, как идентификатор объекта теперь должен приходить из контроллера, то в контроллере надо передaть идентификатор в динамический объект ViewBag, что бы его можно было получить во View.


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

Этого достаточно что бы данные начитывались при загрузке.
Настало время кастомизировать форму.
Что бы не перегружать статью я вывел минимум полей. Предлагаю для началa разобраться как работать с связанными списками.


  <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>

Поля ShipCountry и ShipAddress лучшие кандидаты на связанные списки.
Вот методы контроллера. Как видите все довольно просто.Вся фильтрация осуществляется с помощью 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");
        }

А вот во View кода прибавилось значительно больше.
Кроме собственно функций начитки стран и городов приходится добавить watch который следит за изменениями объекта, к сожалению старое значение сложного объекта vue не сохраняет поэтому нужно сохранять его в ручную, для чего я придумал метод saveOldOrderValue: пока я сохраняю в нем только страну. Это позволяет перечитывать список городов только при изменении страны. В остальном код то же думаю понятен. В примере я показал только одноуровневый связанный список ( по этому принципу не сложно сделать вложенность любого уровня).


@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>

Отдельная тема Валидация. С точки зрения оптимизации скорости выполнения конечно надо сделать валидацию на клиенте. Но это приведет к дублированию кода, поэтому я показываю пример с валидацией на уровне Entity (Как собственно и должно быть в идеале). Кода при этом минимум, сама валидация происходит достаточно быстро и к тому же асинхронно. Как показала практика даже при весьма медленном интернете все работает более чем нормально.
Проблемы возникают только, если быстро набирается текст в текстовом поле, а скорость набора текста этак символов 260 в минуту. Простейший вариант оптимизации для текстовых полей установить ленивое обновление v-model.lazy="order.ShipAddress", тогда валидация произойдет при смене фокуса. Более продвинутый вариант сделать для этих полей задержку валидации.
Методы обработки валидации в контроле у меня получились вот такие.


      [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;
            }
        }
    }

Довольно многословно, но данный код пишется один раз на все приложение и не требует донастройки под конкретный объект или поле. В результате работы метода на клиент json объект со свойствами IsChanded и Errors. Эти свойства естественно нужно создать в нашем Vue и заполнять их при каждом изменении объекта.
Что бы получить ошибки валидации нужно эту валидацию где то задать. Самое время сейчас в нашем описании Entity объекта Order добавить несколько атрибутов валидации.


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

        [CheckCityAttribute("Поле ShipCity обязательно для заполнения")]
        public string ShipCity { get; set; }

MinLength и StringLength стандартные атрибуты, а вот для ShipCity я создал кастомный атрибут


   /// <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;
        }
    }

Однако давайте оставим тему валидации Entity тоже за рамками этой статьи.
Для того что бы отображать ошибки нужно добавить ссылку на Css и слегка доработать форму.
Вот так должна теперь выглядеть наша доработанная форма:


@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>

Tак выглядит CSS


.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
}

А вот так результат работы.



Что бы разобраться как работает валидация давайте внимательно посмотрим на разметку описывающую одно поле:


<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>

Здесь 2 важных ключевых момента:


Эта часть разметки подключает стиль ответственный за красную рамку вокруг элемента v-bind:class="{error:!errors.Freight==''} тут vue подключает по условию css класс .


А вот эта за всплывающее окно показываемое когда курсор мыши над над элементом:


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

кроме этого элемент родительский элемент должен содержать атрибут class="tooltip".


В последнем варианте, добавлена кнопка сохранить настроенная что, бы быть доступной только если сохранение возможно.
Разработка сводится к расположению полей на форме, настройке валидации в Entyty и формированию списков. Если списки статичные и не большие, то их вполне можно задавать в коде.
C# часть кода отлично тестируется. В ближайших планах разобраться с тестированием Vue.


Вот собственно и все что я хотел рассказать.
Буду очень признателен за конструктивную критику.


Вот ссылка на исходный код.


В примере форма называется SimpleEdit и содержит последнюю версию. Кому интересны предварительные варианты можно пройти по комитам.
В примере реализовал оптимизацию: прерывание запроса валидации если не дожидаясь ответа валидации вызвать валидацию второй раз.

Комментарии (13)


  1. D1verGW
    09.06.2019 18:24

    Когда не умеешь в валидацию на клиенте.

    Вы извините за, возможно, резкость, но вам вью зачем? Чтобы организовать биндинг данных?
    Не нужно вам для валидации отправлять все действия в форме на сервер, и ждать ответа.


  1. SergejSh Автор
    09.06.2019 18:33

    Поверьте умею. Но тогда я должен настраивать валидацию два раза. Первый раз на уровне Entity а второй на клиенте. Eсли валидация завязана на длину полей в базе, или пустое значения, то конечно перетащить не сложно, а если логика более сложная и требует обращения к базе?
    Здесь все единообразно и настраивается в одном месте.


    1. dady_KK
      09.06.2019 21:30
      +1

      Вы ещё скажите что модель MVC мешает вместо алертов делать изменение класса инпута чтобы бордер красным был и Вы уверены, что сейчас описывали проблемы модели, а не конкретной кривой реализации, а то связанность модели, контроллера и представления можно сделать кроме того что по-разному, так ещё связанность будет разной в разных реализациях. А так очень похоже на «было криво» заменили на «свой более удобный велосипед(собственную реализацию работы фронта)» и при этом считаете, что то что было раньше — это косяки или ограничения патерна.


      1. SergejSh Автор
        09.06.2019 22:15
        -1

        Сама модель не мешает, просто классический подход в том что нужно отправить форму на сервер, что бы увидеть ошибки валидации, а перезагрузка формы сжирает трафика в разы больше чем валидация.
        В том то и удобство Single Page Application что сайт загружается один раз, а далее вся работа идет через Ajax асинхронно. К сожалению такой подход не возможен в моем случае. Поэтому приходится использовать загрузку каждой страницы отдельно. Но далее страница работает без перезагрузок используя возможности VueJs. Вполне стандартным для этого фреймвока способом.
        Такой подход получается чуть проще (нет ни каких сборщиков роутеров и пр. ) достаточно подгрузить базовую библиотеку через cdn


    1. artem_se
      09.06.2019 22:15

      Клиент с медленным соединением не будет доволен таким подходом. Вы решаете свою (несущественную) проблему и убиваете UX. Хотите сделать единообразно — опишите валидацию и генерируйте для клиента и сервера.


      1. SergejSh Автор
        09.06.2019 22:28
        -1

        Я рассматривал такой подход для стандартных случаев когда валидация заключаться в проверке длины и тп. Только кода получится больше. И он будет на много сложнее.
        Да чем собственно клиент может быть не доволен.
        Сейчас он жмет на кнопку сохранить и получает список ошибок через минуту например.
        В данном случае он успеет нажать кнопку сохранить не дожидаясь валидации и так же через минуту, а то и раньше увидит красные рамки вокруг невалидных полей.


        1. mikaakim
          10.06.2019 12:35

          Выходит что вы все равно шлете всю модель на сервер для валидации, а при подходе к валидации на клиенте и сервере у вас с клиента будут летать только проверки на необходимые поля и, скорее всего, полная валидация, при отправке результирующей формы, которая будет скорее всего валидна, не удивит пользователя миллионам красных полей или попапов в вашем случае.


          1. SergejSh Автор
            10.06.2019 14:54

            Да именно так.
            И полагаю, что при минимальной оптимизации нагрузка на сервер не сильно возрастет ибо json с данными как не крути на порядок меньше Html формы отображающей эти данные.


  1. unchase
    10.06.2019 03:51

    Автору статьи нужно хорошо поработать над пунктуацией и орфографией, а после уже публиковать её. Вы же хотите, чтобы Ваши статьи было приятно и интересно читать, тогда, пожалуйста, постарайтесь над этим поработать.


  1. xpg934
    10.06.2019 15:56

    Увидел заголовок и ожидал чего-то полезного (с коллегами никак не может решиться на выделение фронта в отдельный проект на vue.js). А в итоге… ерунда какая-то. Прочитав такую статью — хочется забиться в угол и больше никогда не смотреть в сторону vue.js, а спокойно кодить на ламповом C#, сидя в удобном кресле, с надписью «asp.net core».

    Если по существу — зачем это всё? Если вы хотите сосредоточиться на C#, то и надо это делать. Подобные, да и куда более сложные, задачи прекрасно решаются штатными средствами asp.net mvc + razor + jquery unobtrusive validation, с практически полным отсутствием js-кода, применяя ajax-формы (спасибо в asp.net core с tag-хелперами всё это стало прям красивым и удобным). Даже штатные атрибуты валидации модели (data annotations) дадут вам базовую клиентскую валидацию, с последующей валидацией модели на сервере. В итоге все счастливы — кода минимум, базовые проверки идут на клиенте (да, с выделением красными рамочками и прочей любой красотой), на сервер модель приедет уже примерно ровная, и допроверяв её — возвращаем результат, как нам удобно. Сплошной C#, исходника на эту же задачу наверное в 3 раза меньше, и всё ровно, красиво, по рекомендациям. Ну и главное — логика вся пишется в одном месте.

    А так, не зная Vue.js на должном уровне, зачем вообще лезть в него?

    Пример может не лучший, но первый попавшийся из гугла:
    damienbod.com/2018/11/09/asp-net-core-mvc-ajax-form-requests-using-jquery-unobtrusive


    1. SergejSh Автор
      10.06.2019 18:37

      Зачем люди придумали Vue(angular, react) когда то же можно сделать на jquery?
      Для простой формы, да может быть у вас кода будет меньше.
      Если форма будет иметь много полей, таблицы и какую то элементарную логику типа скрыть часть полей по условию и все такое не уверен.
      В моем примере дальнейшее наращивание количества полей не добавит много кода.
      Зато всю «отрисовку» можно делать используя Vue, что поверьте значительно удобнее (он для того и создан). Короче пример минимизирован для показа элементарных возможностей.
      Когда же надо будет нарисовать действительно сложную и навороченную форму можно и компонентов добавить и webpack подключить и роутер ну короче использовать всю мощь Vue.
      Зачем лезть в Vue.js? Что бы узнать его на должном уровне.
      Ну кроме всего прочего следующий проект надеюсь мы будет Single Page Application.


      1. xpg934
        10.06.2019 18:55

        У вас написано «Мы в программисты С# и не знаем js в нужном объеме» — при таком начале как-то странно пытаться использовать Vue.js, предполагающий как раз хорошие знания js.

        Правильнее было бы как раз не смешивать мух с котлетами, а отделить фронт от бэка, отдать его разработку специалисту по js, а на бэке оставить C#, предоставляющий API.
        Вот тогда будет то, что имеет смысл и позволит в дальнейшем расширять и наращивать функционал. Если наворотить как предложено в этом посте, смешав Vue.js с MVC и Razor — будет только хуже, особенно с ростом функционала.

        А так у вас с одной стороны «Зато всю «отрисовку» можно делать используя Vue», а с другой «При каждом изменении формы Vue отправляет на сервер все изменения». Очень странное сочетание. Об это кратко и ёмко указал автор первого комментария.

        И тут не очень понятно, что такое «отрисовка» в случае использования Vue.js. Шаблон у вас написан на html + razor + байндинг модели от Vue.js. Просто убрав отсюда Vue.js и применив например ajax-формы — получим тоже самое, абсолютно, но без js-кода.


        1. SergejSh Автор
          10.06.2019 20:53

          К сожалению мои знания js и vue далеки от идеала я его только изучаю.
          Учитывая, что расширения штата что бы иметь отдельного программиста для фронта у нас не предвидится хочется предложить коллегам работающий и простой шаблон с которого можно начать делать для начала хоть простые формы.

          А так у вас с одной стороны «Зато всю «отрисовку» можно делать используя Vue», а с другой «При каждом изменении формы Vue отправляет на сервер все изменения». Очень странное сочетание.

          Мне не кажется странным. Vue строит выпадающие списки в том числе связанные, динамически подключает нужный CSS и прочее. А на сервер отправляет для валидации (что бы не грузить сервер можно использовать «ленивый биндинг» отправляющий поля при потере фокуса.)
          Это разные задачи, но они обе важны.
          Шаблон у вас написан на html + razor + байндинг модели от Vue.js

          Razor здесь занимает минимум места, только для передачи констант.
          На Html написан шаблон vue. Это стандарт в данном случае.
          Как я уже писал Vue в данном примере кроме биндинга строит выпадающие списки и подключает CSS по условию. Может естественно больше. Просто хотелось продемонстрировать базовые часто встречающиеся задачи.