📜 ⬆️ ⬇️

MVC model metadata output to dynamic markup

In ASP.NET MVC, metadata — attributes that describe model fields — are used both when generating markup (displaying the name of a field, its placeholder, etc.) and when validating data (outputting validation rules). Conventionally, there are 2 types of validation:

Client validation is good because the user immediately sees the mistakes made in filling in the fields and can make corrections without having to send data to the server (unobtrusive validation). This type of validation is necessary in our case.

what is the actual problem?
When using the classic approach to generating markup, everything works automatically, but what if we use ajax and generate html markup dynamically on the client? In this case, nothing is automatically added to the markup. Of course, you can add everything you need manually and it would seem that the problem has been exhausted, but the problem of code duplication arises here, since the same data has to be described twice - on the server and on the client, which in turn leads to other problems. In some cases, dynamic markup is very convenient, but here the question arises of the withdrawal of model metadata and data validation on the client side. This will be discussed further.

So, it is necessary to implement automatic output of the MVC model metadata to the client side and unobtrusive validation.

Key ideas:


The idea of ​​transferring metadata via the http header is taken from this article.
You can learn more about the backbone-validation.js library here .

server part

On the server, write 2 filters:

metadata filter (field name, placeholder, etc.)
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); } } 


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


It is worth paying attention to the fact that the filters work with the data sent to the client in Json format.
')
client part

on the client, create:

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


The parse method is redefined to intercept metadata and validation rules from the http header, which will then be used by Backbone by the backbone-validation.js library
basic 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; } }); })(); 


Here, in Backbone View, the entry point to the validation library is first initialized - backbone-validation.js:
 Backbone.Validation.bind(this, { offFlatten: true //  .    backbone-validation.js : var flatten = function (obj, into, prefix) { }); 

Second, the callback functions (valid, invalid) necessary for highlighting errors are initialized. Also, the remote validation attribute is initialized here:
 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; } 

On the client side, the attribute of remote validation is only an ajax method (the type of the method can be specified in the model description on the server side), which accepts as a response a variable indicating the state of the validated field:

Remote validation is required when validation is not possible or, for some reason, difficult to do on the client side. In the code on the server side, the remote validation attribute is described as follows.
 [Remote("RemoteEmailValidation", "Friends", ErrorMessage = "   ")] 


Using

On the server side, you need to create a method that will send data in Json format. To it and apply the created filters:
 [MetaToHeader("data-meta")] [ValidationToHeader("data-validation")] public ActionResult GetData() { return Json(new Friend(), JsonRequestBehavior.AllowGet); } 

The parameters in the filters specify the name of the http header. The same names are used on the client in the parse method.

On the client side, create a Backbone FriendModel model that inherits from the created Base Backbone model DataMetaModel , in which the parse method is redefined:
 (function() { var models = window.App.Models; models.FriendModel = models.DataMetaModel.extend({ initialize: function(options) { models.DataMetaModel.prototype.initialize.call(this, options); } }); })(); 


We will also create a Backbone View NewFriend inherited from the created 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(); } }); })(); 

Here in the render method, after performing all the actions, you must call the base render method.
 views.dataMetaView.prototype.render.call(this); 
in order to add metadata (name, placeholder, etc.) to the drawn fields in accordance with the model description on the server side. However, the validation rules passed to the client are not added to the DOM. They are only used by the backbone-validation.js library.

Example

Create a Friend model:
 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; } } 

In the controller to the method that returns the model, add two filters:
 [MetaToHeader("data-meta")] [ValidationToHeader("data-validation")] public ActionResult GetData() { return Json(new Friend(), JsonRequestBehavior.AllowGet); } 

on the client, we will create a model and View specified in the use clause, and also define a template that will be used by the View to generate dynamic markup:
 <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> 


The entry point on the page is the 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> 


Result






Project

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


All Articles