Miłosz Orzeł

.net, js, html, arduino, java... no rants or clickbaits.

Radio buttony dla elementów listy w MVC 4 – problem z unikalnością id

Załóżmy, że mamy pewien model, który posiada listę i chcemy wytworzyć radio buttony dla elementów tej listy. Rzuć okiem na poniższy przykład.

Główna klasa modelu zawierająca listę:

using System.Collections.Generic;

public class Team
{
    public string Name { get; set; }
    public List<Player> Players { get; set; }
}

Klasa dla elementów listy:

public class Player
{
    public string Name { get; set; }
    public string Level { get; set; }
}

Istnieją trzy akceptowalne wartości dla właściwości Level:  BEG (Beginner), INT (Intermediate) oraz ADV (Advanced), chcemy więc wyświetlić trzy radio buttony (wraz z etykietami) dla każdego z zawodników w drużynie. Normalnie pewnie użylibyśmy enum dla property Level ale w imię prostoty przykładu zostaniemy przy typie string...

Metoda kontrolera zwracająca przykładowe dane:

public ActionResult Index()
{
    var team = new Team() {
        Name = "Some Team",
        Players = new List<Player> {
               new Player() {Name = "Player A", Level="BEG"},
               new Player() {Name = "Player B", Level="INT"},
               new Player() {Name = "Player C", Level="ADV"}
        }
    };

    return View(team);
}

Oto widok Index.cshtml:

@model Team

<section>
    <h1>@Model.Name</h1>        

    @Html.EditorFor(model => model.Players)            
</section>

Zauważ, że markup dla właściwości Players nie jest tworzony wewnątrz pętli. Zamiast tego użyty jest EditorTemplate. Jest to zalecane podejście ponieważ sprawia, że kod jest bardziej przejrzysty i prostszy w utrzymaniu. Framwork jest na tyle sprtytny, że używa kodu z szablonu edytora dla każdego z zawodników na liście ponieważ Team.Players implementuje interfejs IEnumerable.

A oto wspomniany Players.cshtml EditorTemplate:

@model Player
<div>
    <strong>@Model.Name:</strong>

    @Html.RadioButtonFor(model => model.Level, "BEG")
    @Html.LabelFor(model => model.Level, "Beginner")

    @Html.RadioButtonFor(model => model.Level, "INT")
    @Html.LabelFor(model => model.Level, "Intermediate")

    @Html.RadioButtonFor(model => model.Level, "ADV")
    @Html.LabelFor(model => model.Level, "Advanced")        
</div>

Kod wygląda w porządku, używa silnie typowanych helperów opartych o wyrażenia lambda (dla wychwycenia błędów podczas kompilacji i łatwiejszego refaktoringu)... niestety, jest pewien problem: HTML wygenerowany przez taki kod ma bardzo poważną wadę. Sprawdź poniższy fragment kodu źródłowego strony, który został wygenerowany dla pierwszego zawodnika z listy:

<div>
    <strong>Player A:</strong>  
 
    <input name="Players[0].Level" id="Players_0__Level" type="radio" checked="checked" value="BEG">
    <label for="Players_0__Level">Beginner</label>
 
    <input name="Players[0].Level" id="Players_0__Level" type="radio" value="INT">
    <label for="Players_0__Level">Intermediate</label>
 
    <input name="Players[0].Level" id="Players_0__Level" type="radio" value="ADV">
    <label for="Players_0__Level">Advanced</label>        
</div>

Identyfikator Players_0__Level jest użyty dla trzech różnych radio buttonów! Brak unikalności nie tylko narusza specyfikacje HTML i utrudnia oskryptowanie, powoduje też, że etykiety (tagi label) nie działają prawidłowo (tj. kliknięcie nie powoduje zaznaczenia odpowiedniego elementu input). 

Na szczęście biblioteka MVC posiada klasę TemplateInfo z metodą GetFullHtmlFieldId. Metoda ta zwraca identyfikator dla elementu DOM. Identyfikator jest budowany poprzez połączenie argumentu przekazanego do metody z automatycznie wygenerowanym prefiksem. Prefiks uwzględnia poziom zagnieżdżenia i indeks elementu listy. Wewnętrznie, GetFullHtmlFieldId odwołuje się do właściwości TemplateInfo.HtmlFieldPrefix i metody TagBuilder.CreateSanitizedId więc w przypadku przekazania do sufiksu znaków niemogących być częścią id, znaki te zostaną usunięte.

Oto zmodyfikowany kod EditorTemplate:
@model Player
<div>
    <strong>@Model.Name:</strong>

    @{string rbBeginnerId = ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldId("rbBeginner"); }
    @Html.RadioButtonFor(model => model.Level, "BEG", new { id = rbBeginnerId })
    @Html.LabelFor(model => model.Level, "Beginner",  new { @for = rbBeginnerId} )

    @{string rbIntermediateId = ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldId("rbIntermediate"); }
    @Html.RadioButtonFor(model => model.Level, "INT", new { id = rbIntermediateId })
    @Html.LabelFor(model => model.Level, "Intermediate",  new { @for = rbIntermediateId })

    @{string rbAdvancedId = ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldId("rbAdvanced"); }
    @Html.RadioButtonFor(model => model.Level, "ADV", new { id = rbAdvancedId })
    @Html.LabelFor(model => model.Level, "Advanced",  new { @for = rbAdvancedId })
</div>

Wywołania metody ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldId pozwalają na pozyskanie id dla radio buttonów, identyfikatory te są wykorzystane również do ustawienia atrybutów for elementów label. W MVC 3 nie było przeładowanej metody LabelFor, która akceptowałaby obiekt dla htmlAttributes. Na szczęście wersja 4 ma wbudowany stosowny overload.

Powyższy kod tworzy taki markup:

<div>
    <strong>Player A:</strong>
 
    <input name="Players[0].Level" id="Players_0__rbBeginner" type="radio" checked="checked" value="BEG">
    <label for="Players_0__rbBeginner">Beginner</label>
 
    <input name="Players[0].Level" id="Players_0__rbIntermediate" type="radio" value="INT">
    <label for="Players_0__rbIntermediate">Intermediate</label>
 
    <input name="Players[0].Level" id="Players_0__rbAdvanced" type="radio" value="ADV">
    <label for="Players_0__rbAdvanced">Advanced</label>
</div>

Teraz inputy mają unikalne identyfikatory a labele poprawnie odwołują się do radio buttonów poprzez atrybut for. I w porządku :)

Przy okazji: dziwna nazwa „radio button” pochodzi od odbiorników radiowych, które posiadały panele z przyciskami do wyboru stacji (wciśnięcie jednego guzika automatycznie wybijało pozostałe).