Binding de colecciones en ASP.NET MVC

Buenas! Hoy voy a comentar un temilla que me comentó un colega el otro día y que puede dar algunos quebraderos de cabeza: el binding de colecciones.

Supongamos el siguiente viewmodel:

public class QuestionModel
{
public int IdQuestion {get; set;}
public string Text { get; set; }
public int IdAnswer { get; set; } // Id de la respuesta seleccionada
public IEnumerable<Answer> Answers { get; set; }
}

public class Answer
{
public int IdAnswer{ get; set; }
public string Text { get; set; }
}

Básicamente una pregunta contiene un Id, un texto y una lista de respuestas. Bien, supongamos que tenemos un método en el controlador que nos cree una lista de 10 preguntas, cada una de las cuales con 3 posibles respuestas, y la mande a una vista:

public ActionResult Index()
{
var questionModelList = new List<QuestionModel>();
for (var i = 1; i <= 10; i++)
{
questionModelList.Add(new QuestionModel()
{
IdQuestion = i,
Text = "Texto pregunta " + i,
IdAnswer = 0,
Answers = new List<Answer>() {
new Answer() {Text = "Respuesta A de " + i, IdAnswer = 1},
new Answer() {Text = "Respuesta B de " + i, IdAnswer = 2},
new Answer() {Text = "Respuesta C de " + i, IdAnswer = 3} }
});
}
return View(questionModelList);
}

Al igual que a la vista le mandamos una List<QuestionModel>, esperamos que eso sea lo que la vista nos devuelva en el controlador:

[HttpPost]
public ActionResult Index(List<QuestionModel> questionModelList)
{
// codigo...
}

Bueno, ahora veamos una posible implementación de la vista Index:

@model List<Ejemplo.ConRadioButtonNormal.Models.QuestionModel>
@{
ViewBag.Title = "Index";
}
@using (Html.BeginForm())
{
<div>
@foreach (var question in Model)
{
<div>
@Html.LabelFor(x=>question.Text, question.Text)
@foreach (var answer in question.Answers)
{
@Html.RadioButtonFor(m => answer.Text, answer.IdAnswer)
}
</div>
}
</div>
<p><input type="submit" value="Submit" /></p>
}

Buenooo… parece una implementación correcta no? Iteramos sobre todas las preguntas y por cada pregunta generamos una label y tantas radio buttons como respuestas tenga la pregunta… El problema es que… no es correcta! 🙂

Veamos el código HTML que nos genera:

<div>
<label for="question_Text">Texto pregunta 1</label>
<input id="answer_Text" name="answer.Text" type="radio" value="1" />
<input id="answer_Text" name="answer.Text" type="radio" value="2" />
<input id="answer_Text" name="answer.Text" type="radio" value="3" />
</div>
<div>
<label for="question_Text">Texto pregunta 2</label>
<input id="answer_Text" name="answer.Text" type="radio" value="1" />
<input id="answer_Text" name="answer.Text" type="radio" value="2" />
<input id="answer_Text" name="answer.Text" type="radio" value="3" />
</div>
<!-- Y así sucesivamente... :p -->

Fijaos que tanto el id como el name de todas las radiobutton son iguales! Y eso? Eso es porque name e id se sacan de la lambda expresion que se pasa como parametro al helper Html.RadioButtonFor<>. Y esa lambda es siempre la misma. Bueno… al menos el error es fácil de detectar porque una vez marqueis una radio button, al marcar cualquier otra de cualquier otra pregunta la primera se seleccionará. Recordad que HTML agrupa las radiobuttons según el atributo “name”, y si todas tienen el mismo, todas las radiobutton forman un solo grupo, y por lo tanto sólo una puede estar seleccionada.

Nota: Recordad la clave: la expresión lambda que se pasa a Html.RadioButtonFor<> es la que se usa para generar el valor del atributo name.

La siguiente pregunta que debemos hacernos es… cual debe ser el valor correcto de los atributos name de cada radiobutton? Para eso que ver como el DefaultModelBinder enlaza los datos cuando hay colecciones, y por suerte la regla es muy sencilla. Si estamos enlazando datos de una colección el DefaultModelBinder espera que el valor del atributo name tenga el formato: parametro[idx]. Es decir, analicemos la estructura de clases que el controlador espera.

El controlador espera una List<QuestionModel> llamada questionModelList. De ese modo:

  • questionModelList[0].Text se enlazaría con la propiedad Text del primer elemento de la lista.
  • questionModelList[1].Text se enlazaría con la propiedad Text del primer elemento de la lista.
  • questionModelList[0].Answers[0].Id se enlazaria con el valor de la propiedad Id de la primera respuesta del primer elemento de la lista.
  • questionModelList[0].Answers[1].Id se enlazaria con el valor de la propiedad Id de la segunda respuesta del primer elemento de la lista.

¿Lo veis? El DefaultModelBinder espera que el valor del atributo name sea “lo mismo que usaríamos en C#”.

Nota: Si te “hace daño a los ojos” lo de questionModelList al principio del atributo name (recuerdo que esto es el nombre del parámetro), que sepas que si el controlador sólo recibe una colección (como en nuestro caso) puedes eliminar el nombre del parámetro. En nuestro caso podriamos usar [0].Text como valor del atributo name p.ej.

Así, p.ej. podríamos modificar la vista para que quede parecida a:

@model List<Ejemplo.ConRadioButtonNormal.Models.QuestionModel>
@{
ViewBag.Title = "Index";
}
@using (Html.BeginForm())
{
<div>
@for (int idx = 0; idx < Model.Count; idx++)
{
<div>
<label>@Model[idx].Text</label>
@for (int idxAnswer = 0; idxAnswer < Model[idx].Answers.Count(); idxAnswer++)
{
var lstAnswers = Model[idx].Answers.ToList();
<input type="radio"
name="@string.Format("questionModelList[{0}].Answers[0].IdAnswer", idx)"
value="@lstAnswers[idxAnswer].IdAnswer" />
}
</div>
}
</div>
<p><input type="submit" value="Submit" /></p>
}

Eso genera un HTML como el siguiente:

<div>
<label>Texto pregunta 1</label>
<input type="radio"
name="questionModelList[0].Answers[0].IdAnswer" value="1" />
<input type="radio"
name="questionModelList[0].Answers[0].IdAnswer" value="2" />
<input type="radio"
name="questionModelList[0].Answers[0].IdAnswer" value="3" />
</div>
<div>
<label>Texto pregunta 2</label>
<input type="radio"
name="questionModelList[1].Answers[0].IdAnswer" value="1" />
<input type="radio"
name="questionModelList[1].Answers[0].IdAnswer" value="2" />
<input type="radio"
name="questionModelList[1].Answers[0].IdAnswer" value="3" />
</div>

Ahora los campos siguen la convención que espera el DefaultModelBinder. Con lo que los datos ahora si que serán recibidos por el controlador.

Bueno… ¿pero esto que sentido tiene?

Si os fijáis veréis que las radiobuttons de cada pregunta tienen todas el mismo name. Eso es correcto ya que sólo una respuesta puede seleccionarse por cada pregunta. Así todas las radiobuttons de la primera pregunta tienen el valor de questionModelList[0].Answers[0].IdAnswer y así sucesivamente.

Así pues, cual es la información que viaja del cliente al servidor? Aunque tengamos tres radiobuttons por pregunta, todas tienen el mismo name y sólo una viajará. Es decir, dadas las 10 radiobuttons viajarán 10 valores del cliente al servidor:

  • questionModelList[0].Answers[0].IdAnswer
  • questionModelList[1].Answers[0].IdAnswer
  • questionModelList[9].Answers[0].IdAnswer

Es decir, para el servidor tendremos una lista de 10 preguntas cada una de las cuales tendrá UNA sola respuesta (la seleccionada por el usuario). Y eso es exactamente lo que se bindea en el controlador:

image

Fijaos como p.ej. el campo Text de la pregunta es “null”. Normal, la vista no lo está enviando al controlador.

Parémonos un momento en este punto. Desde el controlador hemos mandado a la vista toda una List<QuestionModel> llena. Es decir la vista si que recibe el texto de las preguntas y las respuestas… Por que cuando estamos de vuelta en el controlador hemos perdido esta información?

Si te estás preguntando esto: bienvenido al mundo stateless de la web. Efectivamente el controlador envía toda una List<QuestionModel> a la vista y la vista usa esta información para generar un HTML, que es mandado al cliente. En este punto el trabajo del servidor ha terminado. El controlador muere y será creado de nuevo cuando se reciba otra petición del browser. Esta otra petición llega cuando pulsamos el botón de submit, pero el controlador sólo recibe lo que la vista envía. Y la vista sólo envia lo que está en el formulario: los IDs de las respuestas seleccionadas. La vista no envia los textos ni de la pregunta, ni de la respuesta. Por eso no los tenemos de vuelta. Recordad: la web es stateless. Si venís de Webforms, webforms os ocultaba esto y os hacía creer que la web es statefull. Pero MVC no oculta nada de nada: lo que enseña es, simplemente, lo que hay.

Bueno… dicho esto, ahora preguntaos, si tiene sentido que este método del controlador reciba una lista de QuestionModel. El tema es que si esta vista tiene que enviar las respuestas eso debería ser lo que debería esperar el controlador. Es decir, la vista recibe un List<QuestionModel> pero devuelve un array con los IDs de las preguntas seleccionadas… Total… la vista no va a devolver el texto de las preguntas y las respuestas al controlador, no? Si lo que la vista devuelve son los IDs de las respuestas seleccionadas esto es lo que debería recibir el controlador:

image

Fijaos simplemente que el controlador recibe una colección con los IDs de las respuestas seleccionados. Así idRespuestas[0] el ID de la respuesta seleccionada de la primera pregunta y así sucesivamente. La información del texto de las preguntas (en caso de ser necesaria) la obtendría el propio controlador (leyéndola de la BBDD, de la cache, de donde fuera).

Y como nos quedaría la vista? Pues como ahora no mandamos una estructura tan compleja al controlador, generar el nombre de los atributos name es mucho más fácil:

@model List<Ejemplo.ConRadioButtonNormal.Models.QuestionModel>
@{
ViewBag.Title = "Index";
}
@using (Html.BeginForm())
{
<div>
@for (int idx = 0; idx < Model.Count; idx++)
{
<div>
<label>@Model[idx].Text</label>
@for (int idxAnswer = 0; idxAnswer < Model[idx].Answers.Count(); idxAnswer++)
{
var lstAnswers = Model[idx].Answers.ToList();
<input type="radio"
name="@string.Format("[{0}]", idx)"
value="@lstAnswers[idxAnswer].IdAnswer" />
}
</div>
}
</div>
<p><input type="submit" value="Submit" /></p>
}

Vale, fijaos en dos cosillas:

  1. La vista sigue recibiendo una List<QuestionModel>. Normal, porque esto es lo que el controlador le sigue mandando. Pero eso NO implica que la vista deba mandar de vuelta esto mismo!
  2. El valor de los atributos “name” de las distintas radiobutton, es simplemente [0],[1],… y así sucesivamente (recordad como hemos comentado antes que si el controlador recibe una sola colección no es necesario poner el nombre del parámetro en el atributo name).

Una nota final…

Bueno, hemos visto como funciona el binding de colecciones en ASP.NET MVC, pero os quiero comentar sólo una cosilla más 🙂

Que ocurriria si le enviasemos al controlador los siguientes campos (sus valores son irrelevantes)?

  1. [0], [1], [3], [4]
  2. [1], [2], [3], [4]

En el primer caso vemos que falta [2]. Entonces el DefaultModelBinder se para en este punto. Es decir el controlador recibirá una colección con dos elementos (los valores de [0] y [1]. Los valores de [3] y [4] se han perdido).

En el segundo caso vemos que falta el primer elemento [0]. Entonces el DefaultModelBinder ni enlaza el parámetro. Es decir el controlador no recibirá una colección vacía, no… recibirá null en el parámetro.

En un próximo post veremos como podemos evitar esto 😉

Un saludo!

ds

9 comentarios sobre “Binding de colecciones en ASP.NET MVC”

  1. Gran post, Eduard!

    Como dices, el binding de colecciones es siempre fuente de quebraderos de cabeza, pero creo que, como siempre, lo has explicado de maravilla :-))

    Y es muy importante la observación que haces de que una cosa es lo que envías a la vista y otra lo que recibes en el controlador desde ella; como en este caso, puede ayudar a simplificar bastante los escenarios, y eso siempre viene bien.

    En mi caso, siempre que sea posible, prefiero que sea el framework el que genere los nombres, por lo que
    cuando necesito hacer un binding de este tipo suelo pasar a la vista arrays o listas de forma que pueda acceder a los elementos por índice utilizando bucles (como los de tu ejemplo).

    Al tener acceso a los elementos por índice ya puedes crear los campos utilizar lambdas, dado que la ruta completa hasta el elemento se almacena en el árbol de expresión (por ejemplo questionModelList[0].Answers[0].IdAnswer) y los nombres los generaría correctamente.

    Eso sí, hay veces que ni lambdas, ni helpers, ni historias… html puro y duro, que para eso tenemos esta posibilidad :-))

    Un abrazo, fenómeno!

  2. Excelente blog para aprender MVC, tengo una pregunta que no tiene que ver con el tema.
    Tengo un dataocker para un control de fecha. selecciono la fecha en el control con el formato correcto, pero cuando llega el valor al controlador tiene otro formato de fecha.
    Gracias

  3. Podrías explicarme porfa como pasar una colección de objetos del tipo de mi entidad(modelo de datos) a una vista? Es decir, hago un select, lo guardo en un datatable, pero después, como paso eso a la vista fuertemente tipada? Gracias

  4. @Nora
    Dependerá de como tengas declara la vista. Si tu vista declara que su modelo es un DataTable (@model DataTable) pues entonces le puedes pasar el DataTable directamente (return View(myDataTable);) desde el controlador.
    Si la vista declara que recibe una colección de entidades (algo parecido a @model IEnumerable) entonces deberás convertir los datos de la DataTable a una colección de MiEntidad. Esto con LINQ es sumamente sencillo y rápido, pero un foreach sobre las filas del DataTable, crear objetos MiEntidad, añadirlos a una lista y mandar la lista a la vista te servirá igualmente 😀

    No se si he respondido a tu pregunta 🙂

    @José M.
    Por supuesto, tienes razón! (para variar… :p) 😉
    Lo que pasa es que quería centrame más en el tema del atributo name en este post. Pero efectivamente con los helpers es igual y además con la ventaja de que los helpers saben «recuperar» el value…

    @camilo
    Pues… necesitaria más info 🙂 Como recoges la fecha, como la pasas al contro, como la recibes… 😉

    @Laura
    Muchas gracias, encantado de que haya gustado el post!

  5. Si Web Forms me engañaba, prefiero que me engañe a tener que lidiar con algo que me acostumbre a que fuera sencillo 😉

    Tomo nota por si algún día me hacen trabajar en ello…

    RAD forerver

Deja un comentario

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