jueves, 17 de octubre de 2013

Binding de números y fechas “culture-sensitive” en ASP.NET MVC

Ahora que estoy metido de lleno en un desarrollo con ASP.NET MVC 4, pensé que sería buena idea leer un buen libro sobre el mismo. Mi elección fue Pro ASP.NET MVC 4. El libró me encantó y lo recomiendo.

En el capítulo “Model Binding” (obligado título para un capítulo de un libro que hable sobre ASP.NET MVC) me chocó mucho el siguiente texto (que cito literalmente)

“The DefaultModelBinder class use different culture settings to perform type conversions from different areas of the request data. The values that are obtained from URLs (the routing and query string data) are converted using culture-insensitive parsing, but values obtained from form data are converted taking cultureinto account.

The most common problem that this causes relates to DateTime values. Culture-insensitive dates are expected to be in the universal format yyyy-mm-dd. Form date values are expected to be in the format specified by the server. This means that a server set to the UK culture will expected dates to be in the form dd-mm-yyyy, whereas a server set to the US culture will expect the format mm-dd-yyyy, though in either case yyyy-mm-dd is acceptable too.

A date value won’t be converted if it isn’t in the right format. This means that we must make sure that all dates included in the URL are expressed in the universal format. We must also be careful when processing date values that users provide—the default binder assumes that the user will express dates in using the format of the server culture, something that unlikely to always happen in an MVC application that has international users.”

Como por suerte o por desgracia, todas las aplicaciones que desarrollamos en la empresa tiene que soportan internacionalización, el tema de formato de fechas y números es un tema que me preocupa especialmente.

El párrafo citado anterior viene a decir lo siguiente:

  • Los datos provenientes de la url (querystring y route values) son culture-insensitive. Es decir, esperan un formato determinado con independencia de la cultura establecida en el servidor.
  • Los datos provenientes del form son culture-sensitives. Esto es que sí tienen en cuenta la cultura del servidor.

Vamos a hacer unos pruebas para confirmar esto y verlo en directo. La cultura del servidor será es-ES (haciendo patria) y simplemente crearemos un acción como la siguiente:

public ActionResult Index(decimal? numero, DateTime? fecha)
{
    return View();
}

Ahora y con la ayuda de ScratchPad probaremos el binding de fechas y números.

POST http://localhost:6319/ HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Content-Length: 31
Host: localhost:6319

Numero=1,5&Fecha=31%2F12%2F2013

Todo perfecto, tanto 1,5 como 31/12/2013 son formatos válidos para es-ES.

Numero=1.5&Fecha=13%2F31%2F2013

Ninguna de los 2 parámetros cumple con el formato esperado, error.

Numero=1,5&Fecha=2013%2F12%2F31

Sorprendentemente, aunque form sea culture-sensitive, también acepta el formato yyyy-mm-dd para las fechas.

GET http://localhost:6319/?numero=1,5&fecha=31-12-2013 HTTP/1.1
Host: localhost:6319

Ninguna de los 2 parámetros cumple con el formato esperado, error.

GET http://localhost:6319/?numero=1.5&fecha=2013-12-31 HTTP/1.1
Host: localhost:6319

Ningún problema, especial atención el formato de la fecha: yyyy-mm-dd

GET http://localhost:6319/?numero=1.5&fecha=12-31-2013 HTTP/1.1
Host: localhost:6319

Otra sorpresa, aunque se suponía que el formato de fecha para url tenía que ser yyyy-mm-dd, aparentemente también acepta mm-dd-yyyy

Con esta situación está claro que bindear números y fechas y soportan internacionalización en tu aplicación se va a convertir en una pesadilla si utilizamos sólo y exclusivamente DefaultModelBinder. Es por ello, que resulta necesario crear unos binders personalizados para los tipos Decimal y DateTime.

El código es el siguiente:

using System;
using System.Globalization;
using System.Linq;
using System.Web.Mvc;

namespace WebApplication1
{
public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
var value
= base.BindModel(controllerContext, bindingContext);
if (value != null)
{
return value;
}
if (controllerContext.HttpContext.Request.HttpMethod == "POST")
{
return null;
}
var valueProviderResult
= bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
if (valueProviderResult == null)
{
return null;
}
if (string.IsNullOrWhiteSpace(valueProviderResult.AttemptedValue))
{
return null;
}
var errors
= bindingContext.ModelState[bindingContext.ModelName].Errors;
if (errors.Any())
{
errors.Clear();
}
try
{
return (DateTime)valueProviderResult.ConvertTo(typeof(DateTime), CultureInfo.CurrentCulture);
}
catch (Exception ex)
{
bindingContext.ModelState.AddModelError(bindingContext.ModelName, ex);
return null;
}
}

public class DecimalModelBinder : DefaultModelBinder
{
public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
var value
= base.BindModel(controllerContext, bindingContext);
if (value != null)
{
return value;
}
if (controllerContext.HttpContext.Request.HttpMethod == "POST")
{
return null;
}
var valueProviderResult
= bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
if (valueProviderResult == null)
{
return null;
}
if (string.IsNullOrWhiteSpace(valueProviderResult.AttemptedValue))
{
return null;
}
var errors
= bindingContext.ModelState[bindingContext.ModelName].Errors;
if (errors.Any())
{
errors.Clear();
}
try
{
return (Decimal)valueProviderResult.ConvertTo(typeof(Decimal), CultureInfo.CurrentCulture);
}
catch (Exception ex)
{
bindingContext.ModelState.AddModelError(bindingContext.ModelName, ex);
return null;
}
}
}
}
Para hacerlo funcionar en MVC, también es necesario registrar nuestros nuevos y flamantes binders desde el global.asax:
ModelBinders.Binders.Add(typeof(DateTime), new DateTimeModelBinder());
ModelBinders.Binders.Add(typeof(DateTime?), new DateTimeModelBinder());
ModelBinders.Binders.Add(typeof(Decimal), new DecimalModelBinder());
ModelBinders.Binders.Add(typeof(Decimal?), new DecimalModelBinder());

Con esto, además del comportamiento predeterminado del DefaultModelBinder, ahora tanto url (querystring y route values) como form serán culture-sensitive.



Un saludo!

domingo, 13 de octubre de 2013

Carga condicional de recursos con yepnope en ASP.NET MVC

En la empresa estamos desarrollando una aplicación web en ASP.NET MVC para la que queremos dar cierto soporte a dispositivos móviles. Digo lo de “cierto” porque nuestra intención es que el mismo front-end sirva para todos los dispositivos por igual (ya sean desktop o mobile). Sin embargo, hay ocasiones donde en función del tipo de dispositivo nos gustaría cargar uno u otro recurso (ya sea un .js o un .css).

La solución por la que hemos optado ha sido la carga condicional de recursos a través de yepnope. En este blog ya se escribió sobre yepnope y la verdad es que resulta muy sencillo de utilizar.

Por otro lado, todos usamos Modernizr para detectar características del navegador. La idea es detectar si el navegador es táctil o no y en función de esto cargar una u otra hoja de estilos.

El propio Modernizr suministra un cargador de recursos basado en yepnope a través del método Moderniz.load. Por defecto, el paquete de Nuget de Moderniz que se incluye en las plantillas de los proyectos de MVC no incluye esta característica. Un simple vistazo a la cabecera del fichero .js de Modernizr nos da la clave del asunto:

*
* Modernizr has an optional (not included) conditional resource loader
* called Modernizr.load(), based on Yepnope.js (yepnopejs.com).
* To get a build that includes Modernizr.load(), as well as choosing
* which tests to include, go to www.modernizr.com/download/
*

Por otro lado, sí que hay un paquete de Nuget que incluye el cargador de recursos, el paquete se llama Modernizr.full. Entiendo que este paquete sería lo mismo que descargar Modernizr desde la web e incluir todas opciones disponibles (el método Modernizr.load está marcado como predeterminado en la sección “Extra”).

En cualquier caso, nuestra decisión ha sido utilizar yepnope directamente sin pasar por Modernizr. Me gustaría pensar que puedo actualizar tanto Modernizr como yeopnope sin tener que preocuparme de si uno afecta a otro o viceversa.

En esta situación, la carga condicional del fichero .css sería como sigue:

<script>
    yepnope({
        test: Modernizr.touch,
        yep: '@Url.Content("~/Content/mobile.css")',
        nope: '@Url.Content("~/Content/desktop.css")'
    });
</script>


Esto ya funciona y no hay ningún problema. Sin embargo no estamos utilizando bundles y para mí son casi obligados simplemente por el hecho de no tener que preocuparme de si está o no cacheado en cliente el recurso ya que cualquier cambio en el servidor provocará que el cliente refresque el recurso automáticamente.


Siendo así, mismo ejemplo pero ahora con bundles:



<script>
    yepnope({
        test: Modernizr.touch,
        yep: '@Styles.Url("~/Content/mobile")',
        nope: '@Styles.Url("~/Content/desktop")'
    });
</script>


Simplemente hemos cambiado Url.Content por Styles.Url, pero obtenemos el siguiente error de javascript:


image


El problema está en que yepnope trata a nuestro fichero .css descargado como si fuera un fichero .js. Esto lo hace porque el recurso no acaba con la extension .css y entonces no sabe que lo que estamos cargando es una hoja de estilos en vez de un fichero de script. Fíjate que el código javascript que estamos cargando finalmente en cliente es el siguiente:


<script>
    yepnope({
        test: Modernizr.touch,
        yep: '/Content/mobile?v=lZic6x4eHbbXGYLQzArfcY-IMpq4H2ZVRQXn0B1-3Bc1',
        nope: '/Content/desktop?v=BvoYWkJHqnyVrPiZcSn963sWrvuVmBvTQ4y7_w9WabE1'
    });
</script>


¿Cómo decirle entonces a yepnope que trate el recurso como css en vez de como js? Pues a través del prefijo !css.Según la documentación de yepnope es justo para casos como en el que estamos:


The css! prefix is for those people who need to load css files without a `.css` extension. Since yepnope 1.5 - yepnope can detect the presence of query parameters without the help of the css prefix, so this is usually for cases when the css files have a php ending or something similar.


Este prefijo no viene de serie con yepnope y hay que descargarlo desde aquí https://github.com/SlexAxton/yepnope.js/blob/master/prefixes/yepnope.css-prefix.js


Una vez descargado, lo utilizaremos y ahora sí funcionará yepnope junto a nuestros bundles:


<script>
    yepnope({
        test: Modernizr.touch,
        yep: 'css!@Styles.Url("~/Content/mobile")',
        nope: 'css!@Styles.Url("~/Content/desktop")'
    });
</script>


Cierto es que se podría haber evitado la carga condicional de recursos en este caso concreto utilizando las las clases de Modernizr .touch y .no-touch que agrega automáticamente al elemento html. Es decir, lo siguiente es válido y no requiere nada de código:


<style>
    .touch body {
        color: red;
    }
    .no-touch body {
        color: yellow;
    }
</style>


Sin embargo sigo pensando que depende de en que escenarios prefiero la carga condicional de recursos.


Un saludo!

jueves, 3 de octubre de 2013

Esqueleto de un plugin de jQuery

Si quieres escribir un plug-in de jQuery, la mejor documentación está disponible en la propia página de jQuery How to Create a Basic Plugin. Sin embargo, y después de haber escrito unos cuantos, me doy cuenta de que siempre sigo un mismo patrón que bien podría incorporar como un snippet en Visual Studio y así me ahorraría el estar copiando código o revisitando en enlace anterior.

El esqueleto básico de cualquier plugin (según mi opinión, claro) sería el siguiente:

(function ($) {

     var pluginName = "yourPluginName";

 

     function run($el, settings) {

          //do something important

     }

 

     var methods = {

          init: function (options) {

                return this.each(function () {

                     var $this = $(this);

                     if (!$this.data(pluginName)) {

                          var settings = $.extend(true, {}, $.fn[pluginName].defaults, $this.data(), options);

                          $this.data(pluginName, settings);

                          run($this, settings);

                     }

                });

          },

          destroy: function () {

                return this.each(function () {

                     var $this = $(this);

                     var data = $this.data(pluginName);

                     if (data) {

                          $this.removeData(pluginName);

                          $this.off("." + pluginName);

                     }

                });

          }

     };

     $.fn[pluginName] = function (method) {

          if (methods[method]) {

                return methods[method].apply(this, Array.prototype.slice.call(arguments, 1));

          } else if (typeof method === "object" || !method) {

                return methods.init.apply(this, arguments);

          } else {

                $.error("Method " + method + " does not exist on jQuery." + pluginName);

          }

     };

     $.fn[pluginName].defaults = {

          myProperty: "myValue"

     };

 

    var staticMethods = {};

    extend[pluginName] = (function () {

        return {

            oneMethod: function() {               

            },

            secondMethod: function() {               

            }

        }

    })();

    $.extend(staticMethods);

    //$.extend({

    //    yourPluginNameGoesHere: (function () {

    //        return {

    //            oneMethod: function() { },

    //            secondMethod: function() { }

    //        }

    //    })()

    //});

})(jQuery);

Los puntos claves son:

  • Declara una variable pluginName con el nombre del plugin.
    • Esto es porque sino hay que acordarse de cambiar la “magic string” en un montón de sitios y puede conllevar errores más tarde.
  • Soporta chaining
    • Esto es un “must-have” porque sino no sería un plugin como Dios manda y al usarlo te acordarías de su creador… que curiosamente eres tú!
  • Controla si el plugin está inicializado sobre el elemento para no volver a inicializarlo una segunda vez.
  • Para el tema de las opciones tenemos disponibles las siguientes:
    • Las opciones por defecto para cualquier nueva instancia del plugin son las que están en $.fn[pluginName].defaults.
      • Estas opciones se pueden cambiar antes de llamar al plugin para que siguientes instancias tomen como predeterminadas las que nosotros queramos.
    • Opciones que tomará el plugin automáticamente a partir de atributos data-
      • Tomarán precedencia sobre los valores predeterminados.
      • Esta idea la vi el otro el día en un webcast de buenas prácticas de jQuery de desarrolloweb.com y me pareció acertadísima, le da mucha versatilidad al código.
      • La parte negativa es que si son varios los plugins que se anexan al elemento, $this.data() irá devolviendo la acumulación de todos los valores de todos los plugins vigentes en el elemento, siendo así hay que tener mucho cuidado con su utilización e incluso a veces prescindir de esta opción o realizar un tratamiento más concreto para sólo leer los atributos data que queremos tratar.
    • Las opciones que podemos incluir en la inicialización del plugin, el escenario más común.
      • Tomarán precedencia sobre los valores predeterminados y sobre los atributos data-.
  • A no ser que sea muy costoso de implementar, me gusta dar un método destroy que de serie borra los datos guardados y los eventos anexados (por espacio de nombres) e incluso deshace si procede todo lo que hizo el plugin al inicializarse.
  • Por último, si queremos incluir algún método estático, es decir, poder invocarlo de la forma $.yourPluginName.method() pongo ahí el código necesario, que cierto es se complica un poco por el tema de no querer hardcodear el nombre del plugin.

Por poner un ejemplo sencillo, imaginemos que queremos un input que al tomar el foco cambe su color de fondo al perder el foco se quede como estaba inicialmente.

(function ($) {

       var pluginName = "bgColorFocus";

 

       function run($el, settings) {

             settings.originalBgColor = $el.css("background-color");

             $el.on("focus."+ pluginName, function () {

                    $el.css("background-color", settings.bgColor);

             }).on("blur."+ pluginName, function () {

                    $el.css("background-color", settings.originalBgColor);

             });

       }

 

       var methods = {

             init: function (options) {

                    return this.each(function () {

                           var $this = $(this);

                           if (!$this.data(pluginName)) {

                                  var settings = $.extend(true, {}, $.fn[pluginName].defaults, $this.data(), options);

                                  $this.data(pluginName, settings);

                                  run($this, settings);

                           }

                    });

             },

             destroy: function () {

                    return this.each(function () {

                           var $this = $(this);

                           var data = $this.data(pluginName);

                           if (data) {

                                  $this.removeData(pluginName);

                                  $this.off("." + pluginName);

                           }

                    });

             }

       };

       $.fn[pluginName] = function (method) {

             if (methods[method]) {

                    return methods[method].apply(this, Array.prototype.slice.call(arguments, 1));

             } else if (typeof method === "object" || !method) {

                    return methods.init.apply(this, arguments);

             } else {

                    $.error("Method " + method + " does not exist on jQuery." + pluginName);

             }

       };

       $.fn[pluginName].defaults = {

             bgColor: "yellow"

       };

})(jQuery);

Si nos fijamos, los únicos cambios necesarios han sido el nombre del plugin, los defaults y el cuerpo del método run.

También es interesante ver como los eventos asociados al elemento usan un espacio de nombres, es decir, en vez de focus el evento es focus.bgColorFocus, así después en destroy podremos sólo eliminar nuestros eventos sin interferir con el resto.

Un saludo.