AJAX et Snippets

Les applications web modernes fonctionnent aujourd'hui pour moitié sur un serveur et pour moitié dans un navigateur. AJAX est un facteur d'unité essentiel. Quel est le support offert par le Nette Framework ?

  • l'envoi de fragments de modèles (appelés snippets)
  • le passage de variables entre PHP et JavaScript
  • débogage des applications AJAX

Demande AJAX

Une requête AJAX ne diffère pas d'une requête classique : le diffuseur est appelé avec une vue et des paramètres spécifiques. C'est également au présentateur de décider comment y répondre : il peut utiliser sa propre routine, qui renvoie un fragment de code HTML (extrait HTML), un document XML, un objet JSON ou du code JavaScript.

Côté serveur, une requête AJAX peut être détectée à l'aide de la méthode de service encapsulant la requête HTTP $httpRequest->isAjax() (détection basée sur l'en-tête HTTP X-Requested-With). Dans le présentateur, un raccourci est disponible sous la forme de la méthode $this->isAjax().

Il existe un objet prétraité appelé payload dédié à l'envoi de données au navigateur en JSON.

public function actionDelete(int $id): void
{
	if ($this->isAjax()) {
		$this->payload->message = 'Success';
	}
	// ...
}

Pour un contrôle total de votre sortie JSON, utilisez la méthode sendJson dans votre présentateur. Elle met immédiatement fin au présentateur et vous vous passerez de modèle :

$this->sendJson(['key' => 'value', /* ... */]);

Si nous voulons envoyer du HTML, nous pouvons soit définir un modèle spécial pour les demandes AJAX :

public function handleClick($param): void
{
	if ($this->isAjax()) {
		$this->template->setFile('path/to/ajax.latte');
	}
	// ...
}

Naja

La bibliothèque Naja est utilisée pour gérer les requêtes AJAX du côté du navigateur. Installez-la en tant que paquet node.js (à utiliser avec Webpack, Rollup, Vite, Parcel et plus) :

npm install naja

…ou insérez-la directement dans le modèle de page :

<script src="https://unpkg.com/naja@2/dist/Naja.min.js"></script>

Pour créer une requête AJAX à partir d'un lien normal (signal) ou d'une soumission de formulaire, il suffit de marquer le lien, le formulaire ou le bouton concerné avec la classe ajax:

<a n:href="go!" class="ajax">Go</a>

<form n:name="form" class="ajax">
    <input n:name="submit">
</form>

or
<form n:name="form">
    <input n:name="submit" class="ajax">
</form>

Extraits de texte

Il existe un outil bien plus puissant que le support AJAX intégré : les snippets. Leur utilisation permet de transformer une application ordinaire en une application AJAX en utilisant seulement quelques lignes de code. La façon dont tout cela fonctionne est démontrée dans l'exemple Fifteen dont le code est également accessible dans le build ou sur GitHub.

Le fonctionnement des snippets est le suivant : la page entière est transférée lors de la requête initiale (c'est-à-dire non-AJAX), puis à chaque sous-requête AJAX (requête de la même vue du même présentateur), seul le code des parties modifiées est transféré dans le dépôt payload mentionné précédemment.

Les snippets vous rappellent peut-être Hotwire pour Ruby on Rails ou Symfony UX Turbo, mais Nette les a inventés quatorze ans plus tôt.

Invalidation des Snippets

Chaque descendant de la classe Control (ce qu'est aussi un Presenter) est capable de se souvenir si des changements sont intervenus au cours d'une requête qui nécessitent un nouveau rendu. Il existe une paire de méthodes pour gérer cela : redrawControl() et isControlInvalid(). Un exemple :

public function handleLogin(string $user): void
{
	// L'objet doit être rendu à nouveau après que l'utilisateur se soit connecté.
	$this->redrawControl();
	// ...
}

Nette offre cependant une résolution encore plus fine que les composants entiers. Les méthodes listées acceptent le nom d'un “snippet” comme paramètre optionnel. Un “snippet” est en fait un élément de votre modèle marqué à cet effet par une tag Latte, nous y reviendrons plus tard. Il est donc possible de demander à un composant de ne redessiner que des parties de son modèle. Si le composant entier est invalidé, tous ses snippets sont redessinés. Un composant est également “invalide” si l'un de ses sous-composants est invalide.

$this->isControlInvalid(); // -> false

$this->redrawControl('header'); // invalide le snippet nommé 'header'.
$this->isControlInvalid('header'); // -> true
$this->isControlInvalid('footer'); // -> false
$this->isControlInvalid(); // -> true, au moins un extrait est invalide.

$this->redrawControl(); // invalide l'ensemble du composant, chaque extrait.
$this->isControlInvalid('footer'); // -> true

Un composant qui reçoit un signal est automatiquement marqué pour être redessiné.

Grâce au redessin de snippet, nous savons exactement quelles parties de quels éléments doivent être redessinées.

Balise {snippet} … {/snippet}

Le rendu de la page se déroule de manière très similaire à une requête ordinaire : les mêmes modèles sont chargés, etc. L'essentiel est toutefois de laisser de côté les parties qui ne sont pas censées atteindre la sortie ; les autres parties doivent être associées à un identifiant et envoyées à l'utilisateur dans un format compréhensible pour un gestionnaire JavaScript.

Syntaxe

Si le modèle contient un contrôle ou un extrait, nous devons l'envelopper à l'aide de la balise de paire {snippet} ... {/snippet}. Elle veillera à ce que l'extrait rendu soit “découpé” et envoyé au navigateur. Elle l'enfermera également dans une balise auxiliaire <div> (il est possible d'en utiliser une autre). Dans l'exemple suivant, un extrait nommé header est défini. Il peut tout aussi bien représenter le modèle d'un composant :

{snippet header}
	<h1>Hello ... </h1>
{/snippet}

Si vous souhaitez créer un snippet avec un élément contenant différent de <div> ou ajouter des attributs personnalisés à l'élément, vous pouvez utiliser la définition suivante :

<article n:snippet="header" class="foo bar">
	<h1>Hello ... </h1>
</article>

Dynamic Snippets

Dans Nette, vous pouvez également définir des snippets avec un nom dynamique basé sur un paramètre d'exécution. C'est la solution la plus appropriée pour les listes diverses où nous devons modifier une seule ligne mais où nous ne voulons pas transférer toute la liste avec elle. Un exemple de ceci serait :

<ul n:snippet="itemsContainer">
	{foreach $list as $id => $item}
		<li n:snippet="item-$id">{$item} <a class="ajax" n:href="update! $id">update</a></li>
	{/foreach}
</ul>

Il y a un snippet statique appelé itemsContainer, contenant plusieurs snippets dynamiques : item-0, item-1 et ainsi de suite.

Vous ne pouvez pas redessiner un extrait dynamique directement (redessiner item-1 n'a aucun effet), vous devez redessiner son extrait parent (dans cet exemple itemsContainer). Le code du snippet parent est alors exécuté, mais seuls ses sous-snippets sont envoyés au navigateur. Si vous souhaitez n'envoyer qu'un seul des sous-ensembles, vous devez modifier l'entrée du snippet parent pour ne pas générer les autres sous-ensembles.

Dans l'exemple ci-dessus, vous devez vous assurer que, pour une requête AJAX, un seul élément sera ajouté au tableau $list. Par conséquent, la boucle foreach n'imprimera qu'un seul extrait dynamique.

class HomePresenter extends Nette\Application\UI\Presenter
{
	/**
	 * This method returns data for the list.
	 * Usually this would just request the data from a model.
	 * For the purpose of this example, the data is hard-coded.
	 */
	private function getTheWholeList(): array
	{
		return [
			'First',
			'Second',
			'Third',
		];
	}

	public function renderDefault(): void
	{
		if (!isset($this->template->list)) {
			$this->template->list = $this->getTheWholeList();
		}
	}

	public function handleUpdate(int $id): void
	{
		$this->template->list = $this->isAjax()
				? []
				: $this->getTheWholeList();
		$this->template->list[$id] = 'Updated item';
		$this->redrawControl('itemsContainer');
	}
}

Extraits dans un modèle inclus

Il peut arriver que le snippet se trouve dans un modèle qui est inclus à partir d'un autre modèle. Dans ce cas, nous devons envelopper le code d'inclusion dans le second modèle avec la tag snippetArea, puis nous redessinons à la fois la snippetArea et le snippet lui-même.

La tag snippetArea garantit que le code qu'elle contient est exécuté mais que seul l'extrait réel du modèle inclus est envoyé au navigateur.

{* parent.latte *}
{snippetArea wrapper}
	{include 'child.latte'}
{/snippetArea}
{* child.latte *}
{snippet item}
...
{/snippet}
$this->redrawControl('wrapper');
$this->redrawControl('item');

Vous pouvez également la combiner avec des extraits dynamiques.

Ajout et suppression

Si vous ajoutez un nouvel élément dans la liste et que vous invalidez itemsContainer, la requête AJAX renvoie des extraits incluant le nouvel élément, mais le gestionnaire javascript ne sera pas en mesure de le rendre. Cela est dû au fait qu'il n'y a pas d'élément HTML avec l'ID nouvellement créé.

Dans ce cas, le moyen le plus simple est d'envelopper toute la liste dans un autre extrait et de l'invalider :

{snippet wholeList}
<ul n:snippet="itemsContainer">
	{foreach $list as $id => $item}
	<li n:snippet="item-$id">{$item} <a class="ajax" n:href="update! $id">update</a></li>
	{/foreach}
</ul>
{/snippet}
<a class="ajax" n:href="add!">Add</a>
public function handleAdd(): void
{
	$this->template->list = $this->getTheWholeList();
	$this->template->list[] = 'New one';
	$this->redrawControl('wholeList');
}

Il en va de même pour la suppression d'un élément. Il serait possible d'envoyer un extrait vide, mais les listes peuvent généralement être paginées et il serait compliqué d'implémenter la suppression d'un élément et le chargement d'un autre (qui se trouvait sur une page différente de la liste paginée).

Envoi de paramètres au composant

Lorsque nous envoyons des paramètres au composant via une requête AJAX, qu'il s'agisse de paramètres de signal ou de paramètres persistants, nous devons fournir leur nom global, qui contient également le nom du composant. Le nom global du paramètre renvoie la méthode getParameterId().

$.getJSON(
	{link changeCountBasket!},
	{
		{$control->getParameterId('id')}: id,
		{$control->getParameterId('count')}: count
	}
});

Et traiter la méthode avec les paramètres correspondants dans le composant.

public function handleChangeCountBasket(int $id, int $count): void
{

}
version: 4.0