Tag helpers na poważnie

Tag helpers na poważnie

Hej, w pierwszej części artykułu pisałem o podstawach Tag Helpers w Razor. Jeśli nie czytałeś tego, to koniecznie nadrób zaległości, bo inaczej ten artykuł może być niezrozumiały.

Dzisiaj polecimy dalej i wyżej. Opowiem Ci o bardziej zaawansowanych rzeczach, które możemy zrobić z tag helperami i stworzymy coś miłego dla oka. Dlatego, na Boga, przeczytaj pierwszą część artykułu.

Co robimy?

Chcemy stworzyć tag helper, który ułatwi nam korzystanie z bootstrapowych kart. Jeśli nie wiesz, czym jest bootstrap, to w dużym skrócie można powiedzieć, że jest to zestaw styli css (framework css) gotowych do użycia na Twojej stronie, które bardzo przyspieszają zarówno tworzenie layoutu, jak i samej strony. Ponadto bootstrap posiada kilka ciekawych komponentów w JS takich jak np. karuzela, czy też okno modalne.

My zajmiemy się dzisiaj kartami. Chcemy uzyskać taki efekt:

efekt końcowy tag helpers

HTML do tego wygląda tak:

<div class="row">
    <div class="card-deck">
        <div class="card border-primary mb-3">
            <div class="card-header">Nagłówek</div>
            <img class="card-img-top" src="~/imgs/cat.jpg" alt="Obraz kota" />
            <div class="card-body">
                <h5 class="card-title">Koteł</h5>
                <h6 class="card-subtitle">Miauuu</h6>
                <p class="card-text">Kot jaki jest, każdy widzi</p>
                <a href="#" class="btn btn-primary">Guzik</a>
            </div>
            <div class="card-footer text-muted">Stopka</div>
        </div>

        <div class="card border-primary mb-3">
            <div class="card-header">Nagłówek</div>
            <img class="card-img-top" src="~/imgs/dog.jpg" alt="Obraz pieseła" />
            <div class="card-body">
                <h5 class="card-title">Pieseł</h5>
                <h6 class="card-subtitle">Hau hau</h6>
                <p class="card-text">Pieseł jaki jest, każdy widzi</p>
                <a href="#" class="btn btn-primary">Guzik</a>
            </div>
            <div class="card-footer text-muted">Stopka</div>
        </div>

        <div class="card border-primary mb-3">
            <div class="card-header">Nagłówek</div>
            <img class="card-img-top" src="~/imgs/pig.jpg" alt="Obraz świni" />
            <div class="card-body">
                <h5 class="card-title">Świnieł</h5>
                <h6 class="card-subtitle">Chrum chrum</h6>
                <p class="card-text">Chrumcia jaka jest, każdy widzi</p>
                <a href="#" class="btn btn-primary">Guzik</a>
            </div>
            <div class="card-footer text-muted">Stopka</div>
        </div>
    </div>
</div>

Nie jest to nic wybitnego, po prostu korzystamy z kart. Ten Bootstrapowy komponent jest dokładnie opisany tutaj.

Szybki opis karty

Pojedyncza karta wygląda tak:

karta bootstrap card

Na górze mamy nagłówek, pod nim obrazek, dalej tytuł (Pieseł), podtytuł (Hau hau) i zawartość. Na dole jest stopka, a cała karta jest w ramce. Wyłaniają nam się już poszczególne elementy, więc zacznijmy pisanie właśnie od tagu helper’a dla pojedynczej karty.

Zaczynamy pisanie tag helper’a

Pojedyncza karta

Jak już wiesz, karta składa się z kilku elementów. W tym momencie pominiemy ramkę, bo będzie za nią odpowiadało coś innego. A więc mamy:

  • tekst nagłówka
  • obrazek
  • tytuł i podtytuł
  • treść
  • tekst stopki

Przygotujmy taki szkielet tag helper’a:

public class CardTagHelper: TagHelper
{
	public string Header { get; set; } = string.Empty;
	public string ImgSrc { get; set; } = string.Empty;
	public string Title { get; set; } = string.Empty;
	public string SubTitle { get; set; } = string.Empty;
	public string Footer { get; set; } = string.Empty;

	public override void Process(TagHelperContext context, TagHelperOutput output)
	{
		base.Process(context, output);
	}
}

Póki co, nie ma tutaj niczego nadzwyczajnego. Zupełny szkielet tag helper’a z wymaganymi właściwościami. Zobaczmy, jak powinna wyglądać taka karta w HTML:

<div class="card mb-3">
    <div class="card-header">Nagłówek</div>
    <img class="card-img-top" src="~/imgs/dog.jpg" alt="Obraz pieseła" />
    <div class="card-body">
        <h5 class="card-title">Pieseł</h5>
        <h6 class="card-subtitle">Hau hau</h6>
        <p class="card-text">Pieseł jaki jest, każdy widzi</p>
        <a href="#" class="btn btn-primary">Guzik</a>
    </div>
    <div class="card-footer text-muted">Stopka</div>
</div>

Jak widzisz, najpierw jest główny div, trzymający w ryzach całą kartę. Następnie jest div z nagłówkiem, obrazek, div z ciałem i na końcu stopka. Zacznijmy od tego pierwszego – głównego:

public override void Process(TagHelperContext context, TagHelperOutput output)
{
	base.Process(context, output);

	output.TagName = "div";
	output.AddClass("card", HtmlEncoder.Default);
    output.AddClass("mb-3", HtmlEncoder.Default);
}

Nie ma tu niczego nowego, może poza metodą AddClass. To jest metoda helperowa, aby ją zobaczyć i użyć, musisz dodać using: Microsoft.AspNetCore.Mvc.TagHelpers. To jest po prostu pewne ułatwienie. Równie dobrze można by dodać klasy za pomocą atrybutów.

Teraz w razor (cshtml) użyjemy naszego helper’a. Wpisz po prostu:

<card></card>

Zobacz, jaki HTML został wyrenderowany:

<div class="card mb-3"></div>

Czyli można powiedzieć, że mamy już początek. Zanim pójdziemy dalej, muszę opowiedzieć Ci o 4 właściwościach z klasy TagHelperOutput, o których wcześniej nie mówiliśmy. Dla ułatwienia opowiem to łopatologicznie, niekoniecznie zgodnie z poprawnym nazewnictwem:

  • PreElement – to jest treść HTML, która będzie umieszczona zaraz przed Twoim tagiem
  • PreContent – to jest treść HTML, która będzie umieszczona w Twoim tagu, ale zaraz przed jego zawartością
  • PostContent – to jest treść HTML, która będzie umieszczona w Twoim tagu, po jego zawartości
  • PostElement – to jest treść HTML, która będzie umieszczona zaraz po Twoim tagu.

Aby to lepiej zobrazować, napiszmy sobie krótki kod. Najpierw tag helper (możesz użyć tego, nad którym aktualnie pracujemy):

public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
	output.TagName = "div";
	output.Attributes.Add("info", "main-div");
	output.PreElement.SetHtmlContent("<p>Przed elementem</p>");
	output.PreContent.SetHtmlContent("<p>Przed zawartością</p>");
	output.PostContent.SetHtmlContent("<p>Po zawartości</p>");
	output.PostElement.SetHtmlContent("<p>Po elemencie</p>");
}

Uruchom stronę z tym tag helperem i podejrzyj źródło HTML. Zobaczysz coś takiego:

<p>Przed elementem</p>
<div info="main-div">
    <p>Przed zawartością</p>
    <p>Po zawartości</p>
</div>
<p>Po elemencie</p>

Teraz tak się składa, że to, co wpisaliśmy w Razor nie ma żadnej zawartości. Wewnątrz tagu card nic się nie znajduje. Zmieńmy to:

<card>Zawartość okna</card>

Uruchom teraz swoją stronę i zobacz wynikowy HTML:

<p>Przed elementem</p>
<div info="main-div">
    <p>Przed zawartością</p>
    Zawartość okna
    <p>Po zawartości</p>
</div>
<p>Po elemencie</p>

Jak widzisz, możemy dowolnie sterować tym, co znajduje się zarówno przed tagiem, po nim, jak i w środku. Możemy tam dać dowolny i dowolnie długi kod HTML. I to właśnie wykorzystamy.

Nagłówek

Przypomnijmy sobie zatem na szybko kod bootstrap’a:

<div class="card mb-3">
    <div class="card-header">Nagłówek</div>
    <img class="card-img-top" src="~/imgs/dog.jpg" alt="Obraz pieseła" />
    <div class="card-body">
        <h5 class="card-title">Pieseł</h5>
        <h6 class="card-subtitle">Hau hau</h6>
        <p class="card-text">Pieseł jaki jest, każdy widzi</p>
        <a href="#" class="btn btn-primary">Guzik</a>
    </div>
    <div class="card-footer text-muted">Stopka</div>
</div>

Mamy zrobionego pierwszego diva. Zróbmy teraz nagłówek i obrazek:

public class CardTagHelper: TagHelper
{
	public string Header { get; set; } = string.Empty;
	public string ImgSrc { get; set; } = string.Empty;
	public string Title { get; set; } = string.Empty;
	public string SubTitle { get; set; } = string.Empty;
	public string Footer { get; set; } = string.Empty;

	public override void Process(TagHelperContext context, TagHelperOutput output)
	{
		base.Process(context, output);

		output.TagName = "div";
		output.AddClass("card", HtmlEncoder.Default);
		output.AddClass("mb-3", HtmlEncoder.Default);

		output.PreContent.AppendHtml(RenderHeader());
		output.PreContent.AppendHtml(RenderImg());
	}

	string RenderHeader()
	{
		return $"<div class=\"card-header\">{Header}</div>";
	}

	string RenderImg()
	{
		return $"<img class=\"card-img-top\" src=\"{ImgSrc}\" />";
	}
}

Zobacz, zrobiłem przy okazji dwie metody renderujące odpowiedni kod HTML. Teraz lekko zmieńmy wywołanie tego tag helper’a w Razor. Trzeba po prostu dodać brakujące treści:

<card header="Nagłówek" img-src="imgs/dog.jpg"></card>

Wynikiem tego kodu będzie:

<div class="card mb-3">
    <div class="card-header">Nagłówek</div>
    <img class="card-img-top" src="~/imgs/dog.jpg"/>
</div>

Treść karty i stopka

Super, powoli do przodu. Jeśli uruchomisz teraz program, zobaczysz wielką mordkę uśmiechniętego pieseła. Dodajmy teraz resztę – czyli treść karty i jej stopkę:

public override void Process(TagHelperContext context, TagHelperOutput output)
{
	base.Process(context, output);

	output.TagName = "div";
	output.AddClass("card", HtmlEncoder.Default);
	output.AddClass("mb-3", HtmlEncoder.Default);

	output.PreContent.AppendHtml(RenderHeader());
	output.PreContent.AppendHtml(RenderImg());
	
	output.PreContent.AppendHtml(RenderBody());
	output.PostContent.AppendHtml("</div>");

	output.PostContent.AppendHtml(RenderFooter());
}
string RenderBody()
{
	return $@"
		<div class='card-body'>
			<h5 class='card-title'>{Title}</h5>
			<h6 class='card-subtitle'>{SubTitle}</h6>";
}

string RenderFooter()
{
	return $"<div class=\"card-footer text-muted\">{Footer}</div>";
}

Metody RenderFooter nie ma co omawiać, natomiast zatrzymajmy się przy RenderBody. Jak widzisz wyprodukowaliśmy tu diva, a także tytuł i podtytuł karty. Czyli wszystko zgodnie z wzorcowym HTMLem. I teraz powinna pokazać się cała treść. A gdzie ona jest?

Spójrz jeszcze raz na metodę Process. Dodajemy kod „body” w PreContent. Następnie dodajemy diva zamykającego „body” w PostContent. Coś już świta? Spójrz teraz jak zmieniło się wywołanie tag helper’a w Razor:

<card header="Nagłówek" img-src="imgs/dog.jpg" footer="Stopka" title="Pieseł" sub-title="Hau hau">
    <p class="card-text">Pieseł jaki jest, każdy widzi</p>
    <a href="#" class="btn btn-primary">Guzik</a>
</card>

Co się okazuje? Że to, co damy między znacznikami początku i końca jest zawartością (Content) tag helpera! Nieźle. Uruchom teraz aplikację i zobacz kod wynikowy:

<div class="card mb-3">
    <div class="card-header">Nagłówek</div>
    <img class="card-img-top" src="imgs/dog.jpg">
	    <div class="card-body">
		    <h5 class="card-title">Pieseł</h5>
			<h6 class="card-subtitle">Hau hau</h6>
            <p class="card-text">Pieseł jaki jest, każdy widzi</p>
            <a href="#" class="btn btn-primary">Guzik</a>
        </div>
    <div class="card-footer text-muted">Stopka</div>
</div>

Podświetlone linijki to właśnie te, które stanowią zawartość tag helper’a. Te 11 linijek kodu zamieniliśmy w 4 zdecydowanie prostsze! Hurra ja! Ale ale… Jeszcze nie pora na piwo. To dopiero pierwszy etap zadania.

Grupa kart

Skoro mamy już opracowaną kartę, zajmiemy się teraz grupą kart. Na początek zróbmy najprościej jak się da. Przypominam, jak w HTML wygląda taka grupa:

<div class="card-deck">
    ...
</div>

Klasa będzie na tyle prosta, że sam już powinieneś to napisać:

public override void Process(TagHelperContext context, TagHelperOutput output)
{
	base.Process(context, output);

	output.TagName = "div";
	output.AddClass("card-deck", HtmlEncoder.Default);
}

I to właściwie tyle. Zaskoczony? Popatrz teraz na Razor:

<card-group>
    <card header="Nagłówek" img-src="imgs/cat.jpg" footer="Stopka" title="Koteł" sub-title="Miauuu">
        <p class="card-text">Koteł jaki jest, każdy widzi</p>
        <a href="#" class="btn btn-primary">Guzik</a>
    </card>

    <card header="Nagłówek" img-src="imgs/dog.jpg" footer="Stopka" title="Pieseł" sub-title="Hau hau">
        <p class="card-text">Pieseł jaki jest, każdy widzi</p>
        <a href="#" class="btn btn-primary">Guzik</a>
    </card>

    <card header="Nagłówek" img-src="imgs/pig.jpg" footer="Stopka" title="Świnieł" sub-title="Chrum chrum">
        <p class="card-text">Chrumcia jaka jest, każdy widzi</p>
        <a href="#" class="btn btn-primary">Guzik</a>
    </card>
</card-group>

Dodajemy ramki

Czego jeszcze brakuje? Brakuje ramek. Ale ja chcę, żeby o istnieniu lub nieistnieniu ramki decydował card-group, a nie card. Jak to uzyskać? Z pomocą przychodzi pierwszy parametr metody Process – TagHelperContext.

TagHelperContext

To kontekst, który może być przekazywany między tag helperami. Ten kontekst będzie przekazany do metody Process (lub ProcessAsync) wszystkim tag helperom, które są w środku głównego TagHelpera.

Nie jest to wybitna klasa, ale ma jedną bardzo cenną właściwość: Items. Items to słownik, w którym zarówno kluczem i wartością jest object. Co znaczy, że możesz tam wrzucić dosłownie wszystko. Ten słownik posłuży do przekazywania danych między tag helperami. Stwórzmy sobie klasę o nazwie CardData:

class CardData
{
	public bool ShowBorder { get; set; } = false;
}

Ta klasa przechowuje wartość, czy obramowanie ma być widoczne, czy nie. Uzupełnijmy teraz card-group o ten border:

public class CardGroupTagHelper: TagHelper
{
	public bool ShowBorder { get; set; }
	public override void Process(TagHelperContext context, TagHelperOutput output)
	{
		base.Process(context, output);

		output.TagName = "div";
		output.AddClass("card-deck", HtmlEncoder.Default);

		CardData data = new CardData();
		data.ShowBorder = ShowBorder;
		context.Items[nameof(CardData)] = data;
	}
}

Utworzyliśmy tu klasę CardData i przypisaliśmy jej właściwość ShowBorder. Następnie umieściliśmy utworzony obiekt w słowniku. Widziałem też kody, w których zamiast nameof(CardData) jako klucz, było typeof(CardData). Możesz sobie tam nawet wpisać „Słoń”. Dopóki wiesz jak odczytać te dane, nie ma to żadnego znaczenia. Jednak użycie nameof lub typeof wydaje się najbardziej racjonalne. Więc teraz dokończymy kod karty:

public override void Process(TagHelperContext context, TagHelperOutput output)
{
	base.Process(context, output);

	output.TagName = "div";
	output.AddClass("card", HtmlEncoder.Default);
	output.AddClass("mb-3", HtmlEncoder.Default);

	CardData data = context.Items[nameof(CardData)] as CardData;
	if (data.ShowBorder)
		output.AddClass("border-primary", HtmlEncoder.Default);

	output.PreContent.AppendHtml(RenderHeader());
	output.PreContent.AppendHtml(RenderImg());
	
	output.PreContent.AppendHtml(RenderBody());
	output.PostContent.AppendHtml("</div>");

	output.PostContent.AppendHtml(RenderFooter());
}

Pobraliśmy tutaj obiekt CardData stworzony w rodzicu tego tag helpera (card-group) i reszta jest już oczywista. To na tyle… Prawie…

Ograniczenia

Co się stanie, jeśli do card-group nie dodamy card, tylko coś innego? Np. jakiś obrazek? Być może nic złego, ale na pewno coś dziwnego. Nie chcemy takiej sytuacji. Chcemy się upewnić, że dziećmi card-group mogą być tylko elementy card. Z pomocą przychodzi…

RestrictChildren

To jest atrybut nakładany na głównego tag helpera. Powoduje, że jego dziećmi mogą być jedynie konkretne klasy, np:

[RestrictChildren("card")]
public class CardGroupTagHelper: TagHelper
{
//...
}

Spowoduje to, że dziećmi taga card-group mogą być jedynie tagi o nazwie card. Jeśli teraz w razor umieścisz taki kod:

<card-group show-border="true">
    <mail>Mail</mail>
    <card header="Nagłówek" img-src="imgs/cat.jpg" footer="Stopka" title="Koteł" sub-title="Miauuu">
        <p class="card-text">Koteł jaki jest, każdy widzi</p>
        <a href="#" class="btn btn-primary">Guzik</a>
    </card>
</card-group>

to program się nie skompiluje i dostaniesz błąd: error RZ2010: The tag is not allowed by parent tag helper. Only child tags with name(s) 'card' are allowed.

Oczywiście ograniczenie nie dotyczy tylko tag helperów. Jeśli dasz tam jakiegoś diva (zamiast mail), img, czy cokolwiek innego, to program też się nie skompiluje.

Nie musimy się ograniczać do jednego typu dziecka. W końcu „wszystkie dzieci nasze są”. Możemy podać ich kilka, używając drugiego parametru atrybutu RestrictChildren:

[RestrictChildren("card", "img", "mail")]
public class CardGroupTagHelper: TagHelper
{
//...
}

Możemy podać tyle tagów, ile tylko chcemy.

Nic nie renderuj – czyli SupressOutput

Istnieje pewna metoda, która niejako niweczy całą pracę wykonaną przez TagHelpera. W klasie TagHelperOutput znajduje się SupressOutput. Ona po prostu powoduje brak wyświetlenia czegokolwiek w tym tagu. I tu pewnie zapytasz się – po co to komu? Czasem się to przydaje. Weźmy pod uwagę taki prosty kod:

class Data
{
	public List<string> Children { get; set; } = new List<string>();
}
public class ParentTagHelper: TagHelper
{
	public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
	{
		await base.ProcessAsync(context, output);

		output.TagName = "div";

		Data data = new Data();
		context.Items[nameof(Data)] = data;
		await output.GetChildContentAsync();

		output.Attributes.SetAttribute("count", data.Children.Count);

		string content = "";
		foreach (var child in data.Children)
			content += child + "<br />";

		output.Content.SetHtmlContent(content);
	}
}

public class ChildTagHelper: TagHelper
{
	public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
	{
		await base.ProcessAsync(context, output);

		TagHelperContent content = await output.GetChildContentAsync();
		
		Data data = context.Items[nameof(Data)] as Data;
		data.Children.Add(content.GetContent());
		
		output.SuppressOutput();
	}
}

Przeanalizujmy go. Najpierw deklarujemy klasę Data, która będzie przechowywać jakieś dane tagów. Następnie mamy tag helper’a – Parent. Spójrzmy co on robi:

  • zmienia swój tag na div
  • tworzy obiekt klasy Data
  • dodaje ten obiekt do kotekstu
  • i wreszcie wywołuje metodę GetChildContentAsync.

Metoda GetChildContentAsync() powoduje, że ruszają wszystkie tagi (metoda ProcessAsync()), będące dziećmi tego. Spójrzmy teraz na kod w ChildTagHelper, który właśnie w tym momencie zostanie wywołany:

  • tag helper pobiera swoją zawartość
  • pobiera obiekt data
  • na koniec dodaje swoją zawartość do listy

Wywołanie SupressOutput() spowoduje, że ten tag helper niczego nie wyświetli. Po prostu przekazał pewne dane do kontekstu i na tym zakończył swoją pracę.

Wróćmy teraz do ParentTagHelper. Gdy wszystkie dzieci wykonają swoją robotę, GetChildContentAsync się skończy i ParentTagHelper będzie kontynuował swoją pracę. W tym momencie odczyta z listy ilość dzieci i ustawi ją jako atrybut.

Następnie między wszystkie stringi (które pochodzą z dzieci) wstawi znacznik <br /> i wszystko to ustawi jako swoją zawartość.

Zobacz, jak to wygląda od strony Razor:

<parent>
    <child>Wiersz 1</child>
    <child>Wiersz 2</child>
    <child>Wiersz 3</child>
</parent>

A wyrenderowany HTML będzie wyglądał tak:

<div count="3">Wiersz 1<br />Wiersz 2<br />Wiersz 3<br /></div>

Więc czasem ten SupressOutput jest przydatny. Nie tylko, żeby coś renderować warunkowo, ale też jeśli potrzebujesz mieć w Razor jakieś dzieci, ale tak naprawdę całą zawartością będzie z jakiegoś powodu sterował główny tag.

To na tyle, jeśli chodzi o TagHelpery w Razor. Mam nadzieję, że wszystko jest jasne. Jeśli jednak masz jakiś problem albo znalazłeś w artykule błąd, podziel się w komentarzu.

Podziel się artykułem na: