Wszystko, co zawsze chcieliście wiedzieć o grupowaniu

Pracując z danymi w szablonach, często trzeba pogrupować elementy, podzielić je na partie albo przechodzić przez nie zgodnie z jakimś warunkiem. Latte oferuje do tego trzy narzędzia, z których każde pasuje do nieco innej sytuacji.

Filtr |group grupuje elementy według zadanego kryterium, filtr |batch dzieli je na partie o stałym rozmiarze, a tag {iterateWhile} przechodzi przez dane krok po kroku i sam decyduje, kiedy przerwać wewnętrzną pętlę. Omówimy je po kolei.

Filtr i funkcja group

Narzędzia można używać w dwóch postaciach: jako filtra $items|group: … albo jako funkcji group($items, …). Semantycznie są równoważne – wybierz według czytelności.

Wyobraź sobie tabelę bazy danych items, której elementy należą do różnych kategorii:

id categoryId name
1 1 Jabłko
2 1 Banan
3 2 PHP
4 3 Zielony
5 3 Czerwony
6 3 Niebieski

Prosta lista wszystkich elementów w szablonie Latte wyglądałaby tak:

<ul>
{foreach $items as $item}
	<li>{$item->name}</li>
{/foreach}
</ul>

Gdybyśmy jednak chcieli mieć elementy uporządkowane w grupy według kategorii, musimy je podzielić tak, aby każda kategoria miała własną listę. Pożądany wynik wyglądałby tak:

<ul>
	<li>Jabłko</li>
	<li>Banan</li>
</ul>

<ul>
	<li>PHP</li>
</ul>

<ul>
	<li>Zielony</li>
	<li>Czerwony</li>
	<li>Niebieski</li>
</ul>

To zadanie można łatwo i elegancko rozwiązać za pomocą |group. Jako parametr podajemy categoryId, co oznacza, że elementy zostaną podzielone na mniejsze tablice według wartości $item->categoryId (gdyby $item był tablicą, użyte zostałoby $item['categoryId']):

{foreach ($items|group: categoryId) as $categoryId => $categoryItems}
	<ul>
		{foreach $categoryItems as $item}
			<li>{$item->name}</li>
		{/foreach}
	</ul>
{/foreach}

Jeśli chcesz grupować elementy według bardziej złożonych kryteriów, w parametrze filtra możesz użyć funkcji. Kluczem każdej grupy będzie wtedy wartość zwracana przez funkcję – na przykład przy grupowaniu według długości nazwy będzie to liczba znaków:

{foreach ($items|group: fn($item) => strlen($item->name)) as $length => $group}
	...
{/foreach}

Warto zauważyć, że każda grupa (w tym $categoryItems) nie jest zwykłą tablicą, lecz obiektem zachowującym się jak iterator – nie można więc sięgać po elementy przez indeks, np. $categoryItems[0]. Można natomiast policzyć elementy za pomocą count($categoryItems), a do pierwszego elementu grupy sięgnąć funkcją first().

Ta elastyczność czyni z |group wyjątkowo przydatne narzędzie do prezentowania danych.

Zagnieżdżone pętle

Wyobraźmy sobie, że nasza tabela bazy danych ma dodatkową kolumnę subcategoryId, definiującą podkategorie dla każdego elementu. Chcemy wyświetlić każdą kategorię główną w osobnej liście <ul>, a każdą podkategorię w obrębie tej kategorii głównej w osobnej, zagnieżdżonej liście <ol>:

{foreach ($items|group: categoryId) as $categoryItems}
	<ul>
		{foreach ($categoryItems|group: subcategoryId) as $subcategoryItems}
			<ol>
				{foreach $subcategoryItems as $item}
					<li>{$item->name}
				{/foreach}
			</ol>
		{/foreach}
	</ul>
{/foreach}

Razem z Nette Database

Pokażmy, jak efektywnie wykorzystać grupowanie danych w połączeniu z Nette Database. Załóżmy, że pracujemy z tabelą items ze wstępnego przykładu, powiązaną przez kolumnę categoryId z tą tabelą categories:

categoryId name
1 Owoce
2 Języki
3 Kolory

Dane z tabeli items wczytujemy przez Nette Database Explorer poleceniem $items = $db->table('items'). Iterując po tych danych, możemy sięgać nie tylko po atrybuty takie jak $item->name i $item->categoryId, ale dzięki relacji z tabelą categories również po powiązany wiersz przez $item->category. Ta relacja umożliwia ciekawe zastosowania:

{foreach ($items|group: category) as $category => $categoryItems}
	<h1>{$category->name}</h1>
	<ul>
		{foreach $categoryItems as $item}
			<li>{$item->name}</li>
		{/foreach}
	</ul>
{/foreach}

W tym przypadku używamy filtra |group do grupowania po powiązanym wierszu $item->category, a nie tylko po kolumnie categoryId. Dzięki temu klucz ($category) zawiera bezpośrednio obiekt ActiveRow danej kategorii, co pozwala wyświetlić jej nazwę przez {$category->name} i sięgnąć po dowolną inną kolumnę bez osobnego zapytania do categories.

Filtr |batch

Filtr dzieli listę elementów na partie o stałym rozmiarze. Przydaje się przy układach siatkowych, rozmieszczaniu w kolumnach albo dowolnym grupowaniu wizualnym.

Wyobraźmy sobie, że chcemy wyświetlić elementy w listach, gdzie każda lista zawiera maksymalnie trzy elementy:

{foreach ($items|batch: 3) as $batch}
	<ul>
		{foreach $batch as $item}
			<li>{$item->name}</li>
		{/foreach}
	</ul>
{/foreach}

W tym przykładzie lista $items jest dzielona na mniejsze grupy, gdzie każda grupa ($batch) zawiera do trzech elementów. Każda partia jest następnie wyświetlana w osobnej liście <ul>.

Jeśli ostatnia grupa nie zawiera dość elementów, aby osiągnąć pożądaną liczbę, drugi parametr filtra pozwala określić, czym ta grupa zostanie uzupełniona. Idealnie nadaje się to do estetycznego wyrównania elementów tam, gdzie niepełny wiersz wyglądałby nieporządnie.

{foreach ($items|batch: 3, '—') as $batch}
	...
{/foreach}

Tag {iterateWhile}

Te same zadania, które rozwiązywaliśmy filtrem |group, pokażemy teraz z użyciem tagu {iterateWhile}. Główna różnica między oboma podejściami polega na tym, że |group najpierw przetwarza i grupuje wszystkie dane wejściowe, podczas gdy {iterateWhile} steruje przebiegiem pętli za pomocą warunku, a iteracja postępuje sekwencyjnie.

Najpierw wyrenderujmy tabelę z kategoriami za pomocą {iterateWhile}:

{foreach $items as $item}
	<ul>
		{iterateWhile}
			<li>{$item->name}</li>
		{/iterateWhile $item->categoryId === $iterator->nextValue->categoryId}
	</ul>
{/foreach}

Podczas gdy {foreach} wyznacza zewnętrzną część cyklu, czyli rysowanie list dla każdej kategorii, tag {iterateWhile} wyznacza część wewnętrzną, czyli poszczególne elementy. Warunek w tagu zamykającym mówi, że powtarzanie będzie trwać, dopóki bieżący i następny element należą do tej samej kategorii ($iterator->nextValue to następny element; dla ostatniego elementu wewnętrzna pętla kończy się, bo następnego elementu nie ma – Latte sprawdza $iterator->hasNext() przed obliczeniem warunku, więc do porównania z null nigdy nie dochodzi).

Gdyby warunek był zawsze prawdziwy, wszystkie elementy zostałyby wyrenderowane wewnątrz pierwszego <ul>:

{foreach $items as $item}
	<ul>
		{iterateWhile}
			<li>{$item->name}
		{/iterateWhile true}
	</ul>
{/foreach}

Wynik wyglądałby tak:

<ul>
	<li>Jabłko</li>
	<li>Banan</li>
	<li>PHP</li>
	<li>Zielony</li>
	<li>Czerwony</li>
	<li>Niebieski</li>
</ul>

Jaka jest korzyść z takiego użycia {iterateWhile}? Ponieważ <ul> znajduje się wewnątrz zewnętrznego {foreach}, przy pustym wejściu nie wyrenderuje się nic – żadnego osamotnionego <ul></ul>. Bez {iterateWhile} ten sam przypadek trzeba by obsłużyć przez {if} przed tagiem otwierającym albo przez {foreachelse}.

Jeśli podamy warunek w tagu otwierającym {iterateWhile}, zachowanie się zmienia: warunek (i przejście do następnego elementu) jest wykonywany na początku wewnętrznego cyklu, a nie na końcu. Do {iterateWhile} bez warunku wchodzi się więc zawsze, natomiast do {iterateWhile $cond} tylko wtedy, gdy warunek $cond jest spełniony. Jednocześnie do $item zapisywany jest następny element.

Przydaje się to w sytuacjach, gdy chcemy wyrenderować pierwszy element każdej kategorii inaczej niż pozostałe, na przykład tak:

<h1>Jabłko</h1>
<ul>
	<li>Banan</li>
</ul>

<h1>PHP</h1>
<ul>
</ul>

<h1>Zielony</h1>
<ul>
	<li>Czerwony</li>
	<li>Niebieski</li>
</ul>

(Puste <ul></ul> dla kategorii PHP to tylko ilustracja mechaniki – w prawdziwym kodzie renderowanie <ul> obsłużyłbyś przez {if}.)

Modyfikujemy pierwotny kod tak, aby najpierw wyrenderował element jako nagłówek, a następnie za pomocą wewnętrznej pętli {iterateWhile} wyrenderował kolejne elementy z tej samej kategorii jako pozycje listy:

{foreach $items as $item}
	<h1>{$item->name}</h1>
	<ul>
		{iterateWhile $item->categoryId === $iterator->nextValue?->categoryId}
			<li>{$item->name}</li>
		{/iterateWhile}
	</ul>
{/foreach}

W obrębie jednej pętli możemy utworzyć wiele pętli wewnętrznych, a nawet je zagnieżdżać. W ten sposób da się grupować na kilku poziomach naraz – na przykład podkategorie w kategoriach.

Załóżmy, że tabela ma jeszcze jedną kolumnę subcategoryId i że oprócz tego, że każda kategoria jest w osobnym <ul>, każda podkategoria będzie w osobnym <ol>:

{foreach $items as $item}
	<ul>
		{iterateWhile}
			<ol>
				{iterateWhile}
					<li>{$item->name}
				{/iterateWhile $item->subcategoryId === $iterator->nextValue->subcategoryId}
			</ol>
		{/iterateWhile $item->categoryId === $iterator->nextValue->categoryId}
	</ul>
{/foreach}
wersja: 3.x