Tout ce que vous avez toujours voulu savoir sur le groupement

Quand vous travaillez avec des données dans les templates, vous avez souvent besoin de regrouper des éléments, de les découper en lots ou de les parcourir selon une condition. Latte propose trois outils pour cela, chacun adapté à une situation un peu différente.

Le filtre |group regroupe les éléments selon un critère donné, le filtre |batch les découpe en lots de taille fixe, et la balise {iterateWhile} parcourt les données pas à pas en décidant elle-même quand interrompre la boucle interne. Nous allons les passer en revue un par un.

Filtre et fonction group

L'outil s'utilise sous deux formes : comme filtre $items|group: … ou comme fonction group($items, …). Sémantiquement, les deux sont équivalents : choisissez selon la lisibilité.

Imaginons une table de base de données items dont les éléments appartiennent à différentes catégories :

id categoryId name
1 1 Apple
2 1 Banana
3 2 PHP
4 3 Green
5 3 Red
6 3 Blue

Une simple liste de tous les éléments à l'aide d'un template Latte ressemblerait à ceci :

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

Mais si nous voulions organiser les éléments en groupes par catégorie, il nous faut les répartir de sorte que chaque catégorie ait sa propre liste. Le résultat souhaité ressemblerait à ceci :

<ul>
	<li>Apple</li>
	<li>Banana</li>
</ul>

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

<ul>
	<li>Green</li>
	<li>Red</li>
	<li>Blue</li>
</ul>

Cette tâche se résout simplement et élégamment avec |group. Nous indiquons categoryId en paramètre, ce qui répartit les éléments en tableaux plus petits selon la valeur de $item->categoryId (si $item était un tableau, ce serait $item['categoryId']) :

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

Si vous voulez regrouper les éléments selon des critères plus complexes, vous pouvez passer une fonction en paramètre du filtre. La clé de chaque groupe sera alors la valeur de retour de la fonction : en groupant par longueur du nom, ce sera par exemple le nombre de caractères :

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

Il est important de noter que chaque groupe ($categoryItems compris) n'est pas un tableau ordinaire, mais un objet qui se comporte comme un itérateur : vous ne pouvez donc pas accéder aux éléments par index, par ex. $categoryItems[0]. Vous pouvez en revanche compter les éléments avec count($categoryItems) et, pour accéder au premier élément du groupe, utiliser la fonction first().

Cette souplesse fait de |group un outil remarquablement utile pour présenter des données.

Boucles imbriquées

Imaginons que notre table de base de données comporte une colonne supplémentaire subcategoryId, qui définit les sous-catégories des différents éléments. Nous voulons afficher chaque catégorie principale dans une liste <ul> séparée et chaque sous-catégorie de cette catégorie principale dans une liste <ol> imbriquée séparée :

{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}

En combinaison avec Nette Database

Montrons comment utiliser efficacement le regroupement de données en combinaison avec Nette Database. Supposons que nous travaillions avec la table items de l'exemple d'introduction, liée via la colonne categoryId à cette table categories :

categoryId name
1 Fruits
2 Languages
3 Colors

Nous chargeons les données de la table items avec Nette Database Explorer par la commande $items = $db->table('items'). En itérant sur ces données, nous pouvons accéder non seulement à des attributs comme $item->name et $item->categoryId, mais aussi, grâce à la relation avec la table categories, à la ligne liée via $item->category. Cette relation ouvre des usages intéressants :

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

Dans ce cas, nous utilisons le filtre |group pour regrouper par la ligne liée $item->category, et non seulement par la colonne categoryId. Résultat : la clé ($category) contient directement l'objet ActiveRow de la catégorie, ce qui nous permet d'afficher son nom avec {$category->name} et d'accéder à n'importe quelle autre colonne sans faire de requête séparée sur categories.

Filtre |batch

Le filtre découpe une liste d'éléments en lots de taille fixe. C'est pratique pour les mises en page en grille, les dispositions en colonnes ou tout regroupement visuel.

Imaginons que nous voulions afficher les éléments dans des listes contenant chacune au maximum trois éléments :

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

Dans cet exemple, la liste $items est découpée en groupes plus petits, chaque groupe ($batch) contenant jusqu'à trois éléments. Chaque lot est ensuite affiché dans une liste <ul> séparée.

Si le dernier groupe ne contient pas assez d'éléments pour atteindre le nombre voulu, le deuxième paramètre du filtre permet de définir ce qui viendra le compléter. C'est idéal pour aligner esthétiquement des éléments, là où une rangée incomplète paraîtrait bancale.

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

Balise {iterateWhile}

Nous allons traiter les mêmes tâches que celles résolues avec le filtre |group, cette fois avec la balise {iterateWhile}. La principale différence entre les deux approches est que |group traite et regroupe d'abord toutes les données d'entrée, tandis que {iterateWhile} pilote le déroulement de la boucle par une condition et progresse séquentiellement.

Commençons par rendre le tableau avec les catégories à l'aide de {iterateWhile} :

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

Là où {foreach} délimite la partie extérieure du cycle, c'est-à-dire le rendu des listes de chaque catégorie, la balise {iterateWhile} délimite la partie intérieure, c'est-à-dire les éléments eux-mêmes. La condition de la balise fermante dit que la répétition se poursuit tant que l'élément courant et le suivant appartiennent à la même catégorie ($iterator->nextValue est l'élément suivant ; pour le dernier élément, la boucle interne se termine car il n'y a plus d'élément suivant, Latte vérifiant $iterator->hasNext() avant d'évaluer la condition, si bien que la comparaison avec null n'a jamais lieu).

Si la condition était toujours vraie, tous les éléments seraient rendus dans le premier <ul> :

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

Le résultat ressemblerait à ceci :

<ul>
	<li>Apple</li>
	<li>Banana</li>
	<li>PHP</li>
	<li>Green</li>
	<li>Red</li>
	<li>Blue</li>
</ul>

Quel est l'intérêt d'utiliser {iterateWhile} de cette façon ? Comme le <ul> se trouve à l'intérieur du {foreach} extérieur, rien n'est rendu du tout quand l'entrée est vide : pas de <ul></ul> orphelin. Sans {iterateWhile}, il faudrait traiter le même cas avec un {if} avant la balise ouvrante, ou via {foreachelse}.

Si nous plaçons la condition dans la balise ouvrante {iterateWhile}, le comportement change : la condition (et le passage à l'élément suivant) est évaluée au début du cycle interne, et non à la fin. Ainsi, alors qu'on entre toujours dans {iterateWhile} sans condition, on n'entre dans {iterateWhile $cond} que si la condition $cond est remplie. Et, en même temps, l'élément suivant est écrit dans $item.

C'est utile lorsque nous voulons rendre le premier élément de chaque catégorie autrement que les autres, par exemple ainsi :

<h1>Apple</h1>
<ul>
	<li>Banana</li>
</ul>

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

<h1>Green</h1>
<ul>
	<li>Red</li>
	<li>Blue</li>
</ul>

(Le <ul></ul> vide de la catégorie PHP n'est là que pour illustrer le mécanisme ; dans du vrai code, vous géreriez le rendu du <ul> avec un {if}.)

Nous modifions le code d'origine pour rendre d'abord l'élément sous forme de titre, puis utiliser la boucle interne {iterateWhile} pour rendre les éléments suivants de la même catégorie sous forme d'éléments de liste :

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

Au sein d'une même boucle, nous pouvons créer plusieurs boucles internes et même les imbriquer. Vous pouvez ainsi regrouper sur plusieurs niveaux à la fois, par exemple les sous-catégories sous les catégories.

Supposons que la table comporte une colonne supplémentaire subcategoryId et que, en plus d'avoir chaque catégorie dans un <ul> séparé, chaque sous-catégorie soit dans un <ol> séparé :

{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}
version: 3.x