El fallo de ASP.NET MVC y el helper Html.DropDownFor

Buenas! Este post es para describir un fallo que he encontrado en el helper Html.DropDownFor y el workaround asociado. Quizá alguien entiende que no es un fallo y quizá es capaz de decirme que razón se esconde bajo este comportamiento… Desde mi punto de vista ninguno, pero bueno… ni tengo (ni pretendo tener) la verdad absoluta.

El problema…

Veamos… Para el helper Html.DropDownFor se usa para crear combos y tiene varias formas de uso (yo mismo escribí un post hace algún tiempo al respecto sobre las combos en ASP.NET MVC). En una de sus formas de uso, podemos mostrar una lista de cervezas y guardar la cerveza seleccionada con el siguiente código:

Clase Beer
  1.     public class Beer
  2.     {
  3.         public int Id { get; set; }
  4.         public string Name { get; set; }
  5.     }

En el controlador tenemos una lista de cervezas (_beers) y un par de acciones para mandar una SelectList con esas cervezas.

Acciones del controlador
  1.         public ActionResult Test()
  2.         {
  3.             ViewBag.Beers = new SelectList(_beers, "Id", "Name", 2);
  4.             return View();
  5.         }
  6.  
  7.         [HttpPost]
  8.         public ActionResult Test(BeerSelectViewModel data)
  9.         {
  10.             ViewBag.Beers = new SelectList(_beers, "Id", "Name", data.SelectedBeerId);
  11.             return View();
  12.         }

La vista y el método que gestionan el POST usan un ViewModel para mantener el ID de la cerveza seleccionada:

ViewModel
  1.     public class BeerSelectViewModel
  2.     {
  3.         public int SelectedBeerId { get; set; }
  4.     }

El código de la vista es sencillo:

Vista Test.cshtml
  1. @model WebApplication3.Controllers.BeerSelectViewModel
  2.  
  3. @using (Html.BeginForm())
  4. {
  5.     @Html.DropDownListFor(m => m.SelectedBeerId, ViewBag.Beers as SelectList)
  6.     <p>
  7.         <input type="submit" class="btn-default" value="submit" />
  8.     </p>
  9. }

Fijaos que usamos el constructor de SelectList que acepta el objeto seleccionado (en este caso el ID de la cerveza seleccionada). La primera vez se usa el 2, de forma que Epidor será la cerveza seleccionada por defecto, la primera vez.

Ahora extendamos a que el usuario pueda seleccionar no una, si no DOS cervezas seleccionadas (con dos combos).

Para ello hacemos los siguientes cambios (que al menos a mi me parecen lógicos). Extendemos el ViewModel para que tenga un array de elementos seleccionados:

Nuevo ViewModel
  1. public class BeerSelectViewModel
  2.   {
  3.       public IEnumerable<int> SelectedBeerIds { get; set; }
  4.   }

En el controlador pasamos en el ViewBag la lista de cervezas (en lugar del SelectList), ya que el SelectList lo construiremos en la vista:

Acciones del controlador
  1. public ActionResult Test()
  2.   {
  3.       ViewBag.Beers = _beers;
  4.       var model = new BeerSelectViewModel()
  5.       {
  6.           SelectedBeerIds = new[] {1, 2}
  7.       };
  8.       return View(model);
  9.   }
  10.  
  11.   [HttpPost]
  12.   public ActionResult Test(BeerSelectViewModel data)
  13.   {
  14.       ViewBag.Beers = _beers;
  15.       return View(data);
  16.   }

En la vista iteramos sobre la propiedad SelectedBeersIds y por cada valor construimos una SelectList cuyo cuarto parámetro (elemento seleccionado) sea el ID por el que estamos iterando:

Vista Test.cshtml
  1. @using WebApplication3.Controllers
  2. @model WebApplication3.Controllers.BeerSelectViewModel
  3. @{
  4.     var beers = ViewBag.Beers as IEnumerable<Beer>;
  5. }
  6.  
  7. @using (Html.BeginForm())
  8. {
  9.     foreach (var id in Model.SelectedBeerIds)
  10.     {
  11.         <p>
  12.             @Html.DropDownListFor(m => m.SelectedBeerIds, new SelectList(beers, "Id", "Name", id))
  13.         </p>
  14.     }
  15.     <p>
  16.         <input type="submit" class="btn-default" value="submit" />
  17.     </p>
  18. }

Recordad eso: Estoy indicando a cada Html.DropDownListFor cual es su elemento seleccionado a través del cuarto parámetro de la SelectList que le asocio. Eso debería funcionar… pero no. NO FUNCIONA. En el código HTML generado ningún tag <option> tiene el atributo selected, así que ambas combos muestran el primer elemento… Ahí está el fallo. Le digo a Html.DropDownFor cual debe ser su elemento seleccionado pero el helper hace caso omiso a esta indicación…

… Y la solución

Después de dar vueltas al asunto, llegué a una solución… Primero lo intenté sin usar el helper Html.DropDownFor y usar tan solo Html.DropDown pero el error era el mismo. Al final la solución que encontré fue usar Html.DropDownFor pero contra otra propiedad del ViewModel. Es decir usar una propiedad (SelectedBeerIds para rellenar el elemento seleccionado de las combos y otra propiedad (SelectedBeerIdsNew) para obtener el valor de vuelta (los nuevos elementos seleccionados). Pero ojo, si desde el el método que gestiona el POST debía devolver de nuevo la vista (p. ej. en el caso de que el ModelState no sea válido) entonces debía hacer lo siguiente:

  • Copiar el valor de la propiedad SelectedBeerIdsNew en SelectedBeerIds
  • Eliminar (poner a null) el valor de SelectedBeerIdsNew
  • Eliminar del ModelState la propiedad SelectedBeerIdsNew.

Si no hacemos las dos últimas cosas las combos no respetarán el elemento seleccionado que les pasamos en el SelectList (si no hacemos la primera nos mostrarán los elementos seleccionados anteriores).

El código en el controlador es:

Acciones Controlador
  1. public ActionResult Test()
  2. {
  3.     ViewBag.Beers = _beers;
  4.     var model = new BeerSelectViewModel()
  5.     {
  6.         SelectedBeerIds = new[] {1, 2}
  7.     };
  8.     return View(model);
  9. }
  10.  
  11. [HttpPost]
  12. public ActionResult Test(BeerSelectViewModel data)
  13. {
  14.     ViewBag.Beers = _beers;
  15.     // En este punto en data.SelectedBeerIdsNew tenemos
  16.     // las nuevas cervezas seleccionadas
  17.     data.SelectedBeerIds = new List<int>(data.SelectedBeerIdsNew);
  18.     data.SelectedBeerIdsNew = null;
  19.     ModelState.Remove("SelectedBeerIdsNew");
  20.     return View(data);
  21. }

Fíjate en el código necesario en el método que gestiona el POST. Si no establecemos SelectedBeerIdsNew a null y no eliminamos la clave SelectedBeerIdsNew del ModelState no funciona.

El resto de código es igual excepto que en la vista el Html.DropDownFor es para la propiedad SelectedBeerIdsNew (aunque iteramos sobre SelectedBeerIds):

Codigo de la vista
  1. @using WebApplication3.Controllers
  2. @model WebApplication3.Controllers.BeerSelectViewModel
  3. @{
  4.     var beers = ViewBag.Beers as IEnumerable<Beer>;
  5. }
  6.  
  7. @using (Html.BeginForm())
  8. {
  9.     foreach (var id in Model.SelectedBeerIds)
  10.     {
  11.         <p>
  12.             @Html.DropDownListFor(m => m.SelectedBeerIdsNew, new SelectList(beers, "Id", "Name", id))
  13.         </p>
  14.     }
  15.     <p>
  16.         <input type="submit" class="btn-default" value="submit" />
  17.     </p>
  18. }

Y esto es mas o menos todo… En mi opinión es un bug, porque insisto: en todo momento uso la sobrecarga de SelectList que le indica el elemento seleccionado. Si no la usase entendería el comportamiento (hasta sería lógico), pero la estoy usando. No entiendo porque no hace caso de lo que le indica el SelectList en este caso.

¿Qué opináis vosotros?

Saludos!

Un comentario en “El fallo de ASP.NET MVC y el helper Html.DropDownFor”

  1. Me pasó lo mismo el otro días, y en mi opinión creo que es un fallo. Al final acabé haciendo un helper custom para el DropDownFor. Básicamente lo que hacía era buscar en la lista de SelectListItem’s y marcar el “selected” a mano. Algo parecido a esto, pero tampoco me convence de todo:

    public static MvcHtmlString MyDropDownFor(this HtmlHelper html, Expression> expression, IEnumerable list)
    {
    var metadata = ModelMetadata.FromLambdaExpression(expression, html.ViewData);
    var item = items.FirstOrDefault(i => i.Value == metadata.SimpleDisplayText);
    item.Selected = true;

    return html.DropDownListFor(expression, items);
    }

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *