Вывод метаданных модели MVC в динамическую разметку

в 7:48, , рубрики: .net, asp.net mvc, axaj, backbone, javascript, Песочница, метки: , , , , ,

В ASP.NET MVC метаданные — атрибуты, описывающие поля модели, используются как при генерации разметки (вывод названия поля, его заполнителя и т.д.), так и при валидации данных (вывод правил валидации). Условно можно выделить 2 вида валидации:

  • клиентская валидация
  • серверная валидация

Клиентская валидация хороша тем, что пользователь сразу же видит допущенные ошибки в заполнении полей и может внести поправки без необходимости отправлять данные серверу (ненавязчивая валидация). Именно этот тип валидации необходим в нашем случае.

в чем собственно проблема ?
При использовании классического подхода к генерации разметки все работает автоматически, но что если мы используем ajax и формируем html разметку динамически на клиенте? В этом случае автоматически ничего не добавится в разметку. Можно конечно же все необходимое добавить вручную и казалось бы проблема исчерпана, но здесь встает проблема дублирования кода, так как одни и те же данные приходится описывать дважды — на сервере и на клиенте, что в свою очередь влечет другие проблемы. В ряде случаев динамическая разметка очень удобна, но здесь встает вопрос о выводе метаданных модели и валидации данных на стороне клиента. Об этом речь пойдет далее.

Итак, необходимо реализовать автоматический вывод метаданных модели MVC на клиентскую сторону и ненавязчивую валидацию.

Основные идеи:

  • метаданные клиенту будем предавать через http заголовок
  • метаданные будут добавляться автоматически посредством фильтров
  • перед передачей данных сделаем encoding строки в base64 (это необходимо, так как заголовок http передается в ASCII).
  • на клиенте в Backbone модели переопределим метод parse — в нем мы перехватим переданные метаданные и выполним decode из base64
  • на клиенте необходимо подключить Backbone библиотеку backbone-validation.js — библиотека валидации

Идея передачи метаданных через http заголовок взята из этой статьи.
Подробнее с библиотекой backbone-validation.js можно познакомиться здесь.

серверная часть

На сервере напишем 2 фильтра:

  • фильтр для вывода в заголовок метаданных модели
  • фильтр для вывода в заголовок правил валидации модели

фильтр метаданных (название поля, заполнитель и т.д.)
public class MetaToHeader : ActionFilterAttribute
    {
        private readonly string header;

        public MetaToHeader(string header)
        {
            this.header = header;
        }

        public override void OnActionExecuted(ActionExecutedContext filterContext)
        {
            var result = filterContext.Result as JsonResult;

            if (result != null && result.Data != null)
            {
                var meta = GetMeta(result.Data);
                var jsonMeta = new JavaScriptSerializer().Serialize(meta);
                var jsonMetaBytes = Encoding.UTF8.GetBytes(jsonMeta);

                filterContext.HttpContext.Response.Headers.Add(header, Convert.ToBase64String(jsonMetaBytes));
            }

            base.OnActionExecuted(filterContext);
        }

        private static IDictionary<string, object> GetMeta(object model)
        {
            var meta = ModelMetadataProviders.Current.GetMetadataForType(() => model, model.GetType());

            return meta.Properties.ToDictionary(
                p => p.PropertyName,
                p => new
                {
                    label = p.GetDisplayName(),
                    title = p.Description,
                    placeholder = p.Watermark,
                    readOnly = p.IsReadOnly
                } as object);
        }
    }

фильтр правил валидации

public class ValidationToHeader :  ActionFilterAttribute
    {
        private readonly string header;
        private static readonly Dictionary<string, Func<ModelClientValidationRule, List<object>>> Rules;

        public ValidationToHeader(string header)
        {
            this.header = header;
        }

        static ValidationToHeader()
        {
            Rules = new Dictionary<string, Func<ModelClientValidationRule, List<object>>>()
                {
                    {
                        "length", r =>
                            {
                                var result = new List<object>();

                                if (r.ValidationParameters.ContainsKey("max"))
                                    result.Add(new {maxLength = r.ValidationParameters["max"]});

                                if (r.ValidationParameters.ContainsKey("min"))
                                    result.Add(new {minLength = r.ValidationParameters["min"]});

                                result.Add(new { msg = r.ErrorMessage });

                                return result;
                            }
                    },
                    {
                        "range", r =>
                            {
                                var result = new List<object>();

                                if (r.ValidationParameters.ContainsKey("max"))
                                    result.Add(new {max = r.ValidationParameters["max"]});

                                if (r.ValidationParameters.ContainsKey("min"))
                                    result.Add(new {min = r.ValidationParameters["min"]});

                                result.Add(new {msg = r.ErrorMessage});

                                return result;
                            }
                    },
                    {
                        "remote", r =>
                            {
                                var result = new Dictionary<string, object>();

                                if (r.ValidationParameters.ContainsKey("url"))
                                    result.Add("url", r.ValidationParameters["url"]);

                                if (r.ValidationParameters.ContainsKey("type"))
                                    result.Add("type", r.ValidationParameters["type"]);

                                result.Add("msg", r.ErrorMessage);

                                return new List<object>
                                    {
                                        new {remote = result}
                                    };
                            }
                    },
                    {
                        "required", r => new List<object>
                            {
                                new
                                    {
                                        required = true,
                                        msg = r.ErrorMessage
                                    }
                            }
                    },
                    {
                        "number", r => new List<object>
                            {
                                new
                                    {
                                        pattern = "number",
                                        msg = r.ErrorMessage
                                    }
                            }
                    }
                };
        }

        public override void OnActionExecuted(ActionExecutedContext filterContext)
        {
            var result = filterContext.Result as JsonResult;

            if (result != null && result.Data != null)
            {
                var meta = GetRules(result.Data, filterContext.Controller.ControllerContext);
                var jsonMeta = new JavaScriptSerializer().Serialize(meta);
                var jsonMetaBytes = Encoding.UTF8.GetBytes(jsonMeta);

                filterContext.HttpContext.Response.Headers.Add(header, Convert.ToBase64String(jsonMetaBytes));
            }

            base.OnActionExecuted(filterContext);
        }

        public static IDictionary<string, object> GetRules(object model, ControllerContext context)
        {
            var meta = ModelMetadataProviders.Current.GetMetadataForType(() => model, model.GetType());

            return meta.Properties.ToDictionary(
                p => p.PropertyName,
                p => PropertyRules(p, context) as object);
        }

        private static object[] PropertyRules(ModelMetadata meta, ControllerContext controllerContext)
        {
            return meta.GetValidators(controllerContext)
                .SelectMany(v => v.GetClientValidationRules())
                .SelectMany(r => Rules[r.ValidationType](r))
                .ToArray();
        }
    }

Стоит обратить внимание на то, что фильтры работаю с данными, отправляемыми клиенту в Json формате.

клиентская часть

на клиенте создадим:

  • базовую Backbone модель, в которой переопределим метод parse
  • базовую Backbone View

базовая модель
(function () {
    var models = window.App.Models;

    models.DataMetaModel = Backbone.Model.extend({
        metaHeader: 'data-meta',
        validationHeader: 'data-validation',
        urlRoot: '',

        initialize: function (options) {
            this.urlRoot = options.url;
        },
        
        parse: function (response, xhr) {

            var metaData = xhr.xhr.getResponseHeader(this.metaHeader);
            var validationData = xhr.xhr.getResponseHeader(this.validationHeader);

            this.meta = metaData ? $.parseJSON(Base64.decode(metaData)) : undefined;
            
            this.validation = validationData ? $.parseJSON(Base64.decode(validationData)) : undefined;

            return response;
        }
    });
})();

Метод parse переопределяем для того, что бы из http заголовка перехватить метаданные и правила валидации, которые затем будут использоваться Backbone библиотекой backbone-validation.js

базовый Backbone View

(function () {
    var views = window.App.Views;

    views.dataMetaView = Backbone.View.extend({
        events: {
            'submit': 'evSubmit',
            'blur input[type=text]': 'evBlur',
        },

        initialize: function (options) {
            _.extend(Backbone.Validation.callbacks, {
                valid: this.validCallback,
                invalid: this.invalidCallback,
            });

            _.extend(Backbone.Validation.validators, {
                remote: this.remoteValidator
            });

            Backbone.Validation.bind(this, {
                offFlatten: true  // выключает флаттен. Смотри исх код backbone-validation.js метод: var flatten = function (obj, into, prefix) {
            });
        },
        
        render: function () {
            this.addMeta();
        },
        
        addMeta: function () {
            _.each(this.model.meta, function (meta, name) {
                $('label[for=' + name + ']').text(meta.label);
                $('input[name=' + name + ']').attr({
                    title: meta.title,
                    placeholder: meta.placeholder,
                    readonly: meta.readOnly
                });
            });
        },

        evBlur: function (e) {
            var $el = $(e.target);
            this.model.set($el.attr('name'), $el.val(), {validate: true, validateAll: false});
        },
        
        evSubmit: function (e) {
            if (!this.model.isValid(true)) return false;
        },
        
        validCallback: function (view, attr, selector) {
            var control = view.$('[' + selector + '=' + attr + ']');
            var group = control.parents(".control-group");
            group.removeClass("error");

            if (control.data("error-style") === "tooltip") {
                // CAUTION: calling tooltip("hide") on an uninitialized tooltip
                // causes bootstraps tooltips to crash somehow...
                if (control.data("tooltip"))
                    control.tooltip("hide");
            }
            else if (control.data("error-style") === "inline") {
                group.find(".help-inline.error-message").remove();
            }
            else {
                group.find(".help-block.error-message").remove();
            }
        },
        
        invalidCallback: function (view, attr, error, selector) {
            var control = view.$('[' + selector + '=' + attr + ']');
            var group = control.parents(".control-group");
            group.addClass("error");

            if (control.data("error-style") === "tooltip") {
                var position = control.data("tooltip-position") || "right";
                control.tooltip({
                    placement: position,
                    trigger: "manual",
                    title: error
                });
                control.tooltip("show");
            }
            else if (control.data("error-style") === "inline") {
                if (group.find(".help-inline").length === 0) {
                    group.find(".controls").append("<span class="help-inline error-message small-text"></span>");
                }
                var target = group.find(".help-inline");
                target.text(error);
            }
            else {
                if (group.find(".help-block").length === 0) {
                    group.find(".controls").append("<p class="help-block error-message small-text"></p>");
                }
                var target = group.find(".help-block");
                target.text(error);
            }
        },
        
        remoteValidator: function (value, attr, customValue, model) {
            var result, data = model.toJSON();

            data[attr] = value;
            $.ajax({
                type: customValue.type || 'GET',
                data: data,
                url: customValue.url,
                async: false,
                success: function (state) {
                    if (!state) result = customValue.msg || 'remote validation error';
                },
                error: function () {
                    result = "remote validation error";
                }
            });

            return result;
        }
    });
})();

Здесь в Backbone View во первых инициализируется точка входа в библиотеку валидации — backbone-validation.js:

Backbone.Validation.bind(this, {
                offFlatten: true  // выключает флаттен. Смотри исх код backbone-validation.js метод: var flatten = function (obj, into, prefix) {
            });

Во вторых инициализируются callback функции (valid, invalid), необходимые для подсветки ошибок. Так же здесь инициализируется атрибут удаленной валидации:

remoteValidator: function (value, attr, customValue, model) {
    var result, data = model.toJSON();

    data[attr] = value;
    $.ajax({
        type: customValue.type || 'GET',
        data: data,
        url: customValue.url,
        async: false,
        success: function (state) {
             if (!state) result = customValue.msg || 'remote validation error';
        },
        error: function () {
            result = "remote validation error";
        }
    });

    return result;
}

На клиентской стороне атрибут удаленной валидации представляет из себя лишь ajax метод (тип метода можно указать в описании модели на серверной стороне), принимающий в качестве ответа переменную, указывающию состояние валидируемого поля:

  • true — поле валидно
  • false — невалидно

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

[Remote("RemoteEmailValidation", "Friends", ErrorMessage = "Не корректный почтовый ящик")]

Использование

На серверной стороне необходимо создать метод, который будет отправлять данные в Json формате. К нему и применим созданные фильтры:

[MetaToHeader("data-meta")]
[ValidationToHeader("data-validation")]
public ActionResult GetData()
{
    return Json(new Friend(), JsonRequestBehavior.AllowGet);
}

В качестве параметров в фильтрах указывается название http заголовка. Эти же названия используются на клиенте в методе parse.

На клиентской стороне создадим Backbone модель FriendModel наследованную от созданной базовой Backbone модели DataMetaModel, в которой переопределен метод parse:

(function() {
    var models = window.App.Models;

    models.FriendModel = models.DataMetaModel.extend({
        initialize: function(options) {
            models.DataMetaModel.prototype.initialize.call(this, options);
        }
    });
})();

Так же создадим Backbone View NewFriend, наследованный от созданного базового Backbone View dataMetaView:

(function () {
    var views = window.App.Views;

    views.NewFriend = views.dataMetaView.extend({
        initialize: function (options) {
            views.dataMetaView.prototype.initialize.call(this);

            this.model.on('sync', this.render, this);
            
            this.template = _.template($(options.template).html());
        },

        render: function () {
            this.$el.html(this.template(this.model.toJSON()));
            
            views.dataMetaView.prototype.render.call(this);

            return this;
        },
        
        load: function () {
            this.model.fetch();
        }
    });
})();

Здесь в методе render после выполнения всех действий необходимо вызвать базовый метод render

views.dataMetaView.prototype.render.call(this);

для того, что бы добавить к отрисованным полям метаданные (название, заполнитель и т.д.) в соответствии с описанием модели на серверной стороне. При этом правила валидации, переданные клиенту в DOM не добавляются. Они лишь используются библиотекой backbone-validation.js.

Пример

Создадим модель Friend:

public class Friend
{
    public int Id { get; set; }

    [Display(Name = "Имя", Prompt = "Введите имя", Description = "Имя друга")]
    [Required(ErrorMessage = "First name required")]
    [StringLength(50, MinimumLength = 2)]
    public string FirstName { get; set; }

    [Display(Name = "Фамилия", Prompt = "Введите фамилию", Description = "Фамилия друга")]
    [Required(ErrorMessage = "Last name required")]
    [StringLength(50, MinimumLength = 2)]
    public string LastName { get; set; }

    [Display(Name = "Возраст", Prompt = "Введите возраст", Description = "Возраст друга")]
    [Required(ErrorMessage = "Age required")]
    [Range(0, 120, ErrorMessage = "Age must be between 0 and 120")]
    public int? Age { get; set; }

    [Display(Name = "Почтовый ящик", Prompt = "Введите почтовый ящик", Description = "Почтовый ящик друга")]
    [Required(ErrorMessage = "Email required")]
    [Email(ErrorMessage = "Not a valid email")]
    [Remote("RemoteEmailValidation", "Friends", ErrorMessage = "Не корректный почтовый ящик")]
    public string Email { get; set; }
}

в котроллере к методу, который возвращает модель добавим два фильтра:

[MetaToHeader("data-meta")]
[ValidationToHeader("data-validation")]
public ActionResult GetData()
{
    return Json(new Friend(), JsonRequestBehavior.AllowGet);
}

на клиенте создадим модель и View, указанные в пункте использование, а так же определим шаблон, который будет использоваться View для генерации динамической разметки:

<script type='text/template' id='dataMeta-template'>
    <form action="/Friends/Create" method="post">
        <div class="control-group">
            <label for="FirstName"></label>
            <div class="controls">
                <input type='text' name="FirstName" value='<%- FirstName %>' />
            </div>
        </div>
    
        <div class="control-group">
            <label for="LastName"></label>
            <div class="controls">
                <input type='text' name="LastName" value='<%- LastName %>' />
            </div>
        </div>

        <div class="control-group">
            <label for="Age"></label>
            <div class="controls">
                <input type='text' name="Age" value='<%- Age %>' />
            </div>
        </div>
    
        <div class="control-group">
            <label for="Email"></label>
            <div class="controls">
                <input type='text' name="Email" value='<%- Email %>' />
            </div>
        </div>
    
        <p><button class="btn" type="submit">Create</button></p>
    </form>
</script>

Точкой входа на странице служит скрипт:

<script>
    (function($) {
        var models = window.App.Models,
            views = window.App.Views;

        var dataMetaModel = new models.FriendModel({
            urlRoot: '/Friends/GetData'
        });

        var dataMetaView = new views.NewFriend({
            el: '#dataMeta',
            model: dataMetaModel,
            template: '#dataMeta-template'
        });

        dataMetaView.load();
            
    })(jQuery);
</script>
Результат

Вывод метаданных модели MVC в динамическую разметку
Вывод метаданных модели MVC в динамическую разметку
Вывод метаданных модели MVC в динамическую разметку
Вывод метаданных модели MVC в динамическую разметку

Проект

Автор: aesamson

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js