В ASP.NET MVC метаданные — атрибуты, описывающие поля модели, используются как при генерации разметки (вывод названия поля, его заполнителя и т.д.), так и при валидации данных (вывод правил валидации). Условно можно выделить 2 вида валидации:
- клиентская валидация
- серверная валидация
Клиентская валидация хороша тем, что пользователь сразу же видит допущенные ошибки в заполнении полей и может внести поправки без необходимости отправлять данные серверу (ненавязчивая валидация). Именно этот тип валидации необходим в нашем случае.
Итак, необходимо реализовать автоматический вывод метаданных модели 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
(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>
Результат
Проект
Автор: aesamson