Phoenix.LiveView.JS à nu : des commandes qui sont des données
Section
Retour vers le sommaire des tips Accueil
Mix.install([
{:phoenix, "~> 1.8"},
{:phoenix_live_view, "~> 1.2"},
{:jason, "~> 1.4"},
{:lazy_html, ">= 0.1.0"}
])
De quoi s'agit-il ?
Phoenix.LiveView.JS permet d'ouvrir un panneau, de basculer un attribut ou de déplacer le focus sans attendre le serveur. Ce notebook regarde ces commandes de l'intérieur, et montre qu'elles ne sont pas du JavaScript : ce sont des données, construites en Elixir, sérialisées en JSON dans un attribut, puis exécutées par le client LiveView.
Il montre aussi, honnêtement, la limite de l'exercice. Sans navigateur, on voit ce que la page contient et ce que le serveur reçoit. On ne voit pas un panneau apparaître ni le focus se déplacer : ces comportements ont été vérifiés dans un vrai navigateur pour l'article jumeau, et le notebook se contente de les rappeler.
L'article jumeau, qui raconte la construction d'un panneau de filtres dans une application complète : Phoenix.LiveView.JS : ouvrir un panneau sans aller-retour serveur et sans écrire de hook.
1. Une commande est une structure
JS.toggle/1 ne fait rien quand on l'appelle. Elle rend une structure %JS{} dont le champ ops est une liste d'opérations, chacune formée d'un nom et d'options.
alias Phoenix.LiveView.JS
ouvrir = JS.toggle(to: "#filtres")
ouvrir.ops
Chaque fonction du module accepte une structure en premier argument et y ajoute son opération. C'est ce qui permet de les enchaîner avec le pipe, dans l'ordre où le client les exécutera.
ouvrir_accessible =
JS.toggle(to: "#filtres")
|> JS.toggle_attribute({"aria-expanded", "true", "false"})
ouvrir_accessible.ops
Puisque ce sont des données, on les range dans des fonctions comme n'importe quelle valeur. Le fichier core_components.ex généré par Phoenix 1.8 le fait déjà : ses fonctions show/2 et hide/2 prennent une structure, y ajoutent une transition, et la rendent. En voici une sur le même modèle.
defmodule Gestes do
alias Phoenix.LiveView.JS
# referme le panneau et remet l'attribut ARIA à jour
def fermer_filtres(js \\ %JS{}) do
js
|> JS.hide(to: "#filtres")
|> JS.set_attribute({"aria-expanded", "false"}, to: "#bouton-filtres")
end
# mémorise le focus, montre la modale et place le focus dans son contenu
def ouvrir_confirmation(js \\ %JS{}) do
js
|> JS.push_focus()
|> JS.show(to: "#confirmation")
|> JS.focus_first(to: "#confirmation")
end
end
%{fermer: Gestes.fermer_filtres().ops, confirmer: Gestes.ouvrir_confirmation().ops}
2. Ce que la page reçoit
Quand une structure %JS{} est placée dans un attribut HEEx, elle est encodée en JSON. C'est cette chaîne, et rien d'autre, que le navigateur reçoit dans phx-click.
ouvrir_accessible
|> Phoenix.HTML.Safe.to_iodata()
|> IO.iodata_to_binary()
Les " viennent de l'échappement HTML de l'attribut. Le client LiveView lit cet attribut au moment du clic, décode le JSON et exécute les opérations une à une. Aucun code n'a été écrit en JavaScript, et aucun aller-retour n'a eu lieu.
3. Le décor : un endpoint en mémoire et un navigateur de test
Pour voir ce que le serveur reçoit, il faut une LiveView qui tourne. Un endpoint Phoenix démarre sans écouter aucun port avec server: false. Les lignes ExUnit déclarent le processus du notebook comme processus de test, ce qu'exigent les fonctions de Phoenix.LiveViewTest, et un répertoire vide dans le chemin de code tient lieu d'application :demo.
Application.put_env(:demo, Demo.Endpoint,
secret_key_base: String.duplicate("s", 64),
live_view: [signing_salt: "commandes"],
pubsub_server: Demo.PubSub,
server: false
)
ExUnit.start(autorun: false)
ExUnit.OnExitHandler.register(self())
repertoire_demo = Path.join(System.tmp_dir!(), "demo/ebin")
File.mkdir_p!(repertoire_demo)
Code.append_path(repertoire_demo)
# Phoenix journalise chaque requête et chaque connexion : inutile ici
Logger.configure(level: :warning)
La page tient une liste de commandes. Trois boutons couvrent les trois cas : une commande purement cliente, une commande qui prévient le serveur et masque la ligne sans l'attendre, et une commande exec qui va chercher son geste dans un attribut d'un autre élément. La vue note chaque événement reçu.
defmodule Demo.CommandesLive do
use Phoenix.LiveView
alias Phoenix.LiveView.JS
def mount(_params, _session, socket), do: {:ok, assign(socket, recus: [])}
def handle_event(evenement, params, socket) do
{:noreply, update(socket, :recus, &(&1 ++ [{evenement, params}]))}
end
def render(assigns) do
~H"""
<button id="bouton-filtres" phx-click={JS.toggle(to: "#filtres")}>Filtres</button>
<div id="filtres" class="hidden">Statut, client, période</div>
<ul>
<li id="ligne-2">
Commande 2
<button
id="archiver-2"
phx-click={JS.push("archiver", value: %{id: 2}) |> JS.hide(to: "#ligne-2")}
>
Archiver
</button>
</li>
</ul>
<button id="ouvrir" phx-click={JS.exec("data-ouvrir", to: "#porte")}>Ouvrir</button>
<div id="porte" data-ouvrir={JS.push("ouvrir")}></div>
"""
end
end
defmodule Demo.Router do
use Phoenix.Router
import Phoenix.LiveView.Router
pipeline :navigateur do
plug :fetch_session
end
scope "/" do
pipe_through :navigateur
live "/", Demo.CommandesLive
end
end
defmodule Demo.Endpoint do
use Phoenix.Endpoint, otp_app: :demo
plug Plug.Session, store: :cookie, key: "_demo", signing_salt: "commandes"
plug Demo.Router
end
Supervisor.start_link([{Phoenix.PubSub, name: Demo.PubSub}, Demo.Endpoint],
strategy: :one_for_one
)
Le module Navigateur enveloppe live/2, qui a besoin de connaître l'endpoint, et lit les événements que la vue a reçus directement dans ses assigns. La page ne les affiche pas : lire l'état du processus évite de déchiffrer du HTML échappé.
defmodule Navigateur do
import Phoenix.ConnTest
import Phoenix.LiveViewTest
@endpoint Demo.Endpoint
def ouvrir(chemin) do
{:ok, vue, _html} = live(build_conn(), chemin)
vue
end
def recus(vue), do: :sys.get_state(vue.pid).socket.assigns.recus
# clique, et rend soit les événements reçus, soit le message de l'exception
def cliquer(vue, selecteur) do
vue |> element(selecteur) |> render_click()
{:ok, recus(vue)}
rescue
erreur -> {:leve, Exception.message(erreur)}
end
end
vue = Navigateur.ouvrir("/")
4. Ce que le serveur voit
Le bouton qui prévient le serveur est le cas simple. render_click trouve l'opération push dans la liste, et pousse l'événement avec sa valeur. Les autres opérations de la liste, ici hide, sont ignorées : elles ne concernent que le client.
Les paramètres ne sont pas tout à fait ceux du navigateur. Ici la vue reçoit %{"id" => 2}, alors qu'un vrai navigateur envoie %{"id" => 2, "value" => ""}, en ajoutant la propriété value du bouton cliqué. Un motif %{"id" => id} dans handle_event accepte les deux, et un test qui compare la map entière doit en tenir compte.
Navigateur.cliquer(vue, "#archiver-2")
Le bouton purement client est plus instructif. Il n'y a rien à pousser, et render_click le dit en levant une exception plutôt que de faire semblant d'avoir cliqué.
Navigateur.cliquer(vue, "#bouton-filtres")
C'est une information utile pour écrire ses tests. Un bouton dont le phx-click ne contient que des commandes clientes n'a rien à tester côté serveur, et un test qui tente de le cliquer signale l'erreur au lieu de passer en silence.
La même exception tombe avec JS.exec, alors même que l'attribut désigné contient un push. Phoenix.LiveViewTest lit la liste d'opérations du bouton, sans suivre l'indirection vers un autre élément.
Navigateur.cliquer(vue, "#ouvrir")
Dans le navigateur, ce troisième bouton pousse bien ouvrir. Un test qui doit couvrir ce chemin appellera plutôt render_hook(vue, "ouvrir", %{}), qui pousse l'événement directement.
Phoenix.LiveViewTest.render_hook(vue, "ouvrir", %{})
Navigateur.recus(vue)
5. Ce que le test ne voit pas
Après le clic sur « Archiver », la ligne est toujours dans le HTML rendu côté serveur, et le panneau de filtres porte toujours sa classe hidden. Le serveur n'a aucune idée de ce que le client a masqué ou affiché.
import Phoenix.LiveViewTest
%{
ligne_encore_rendue: has_element?(vue, "#ligne-2"),
filtres_toujours_caches: has_element?(vue, "#filtres.hidden")
}
C'est le sens même de ces commandes : l'état visuel qu'elles posent vit dans le navigateur, et le serveur continue de rendre son propre HTML. Le client LiveView retient ce que les commandes ont fait et le réapplique après chaque patch, ce qu'un onclick écrit à la main ne fait pas.
Pour l'article, ces comportements ont été vérifiés dans Chromium, avec Phoenix 1.8.16 et LiveView 1.2.13, sur une page dont un compteur se met à jour chaque seconde :
- une classe retirée par un
onclickrevient au premier patch, tandis qu'un panneau ouvert parJS.togglereste ouvert ; - avec une latence simulée de 800 ms, le panneau piloté par le serveur s'ouvre en environ 1,8 s, celui de
JS.toggleen quelques dizaines de millisecondes ; JS.toggle_attributefait passeraria-expandedde"false"à"true"et retour, à la souris comme au clavier, et la valeur survit aux patchs ;- dans une modale,
JS.focus_firstplace le focus sur le premier bouton, etJS.push_focuspuisJS.pop_focusle rendent au bouton qui l'avait ouverte ; sans focus mémorisé,pop_focusne fait rien ; - placé dans une bascule,
JS.focus_firsts'exécute aussi à la fermeture et fait retomber le focus sur<body>; - pendant l'aller-retour d'un
JS.pushavecloading:, l'élément visé porte la classephx-click-loading; - avec
JS.push, le serveur reçoit en plus la clé"value" => "", tirée de la propriétévaluedu bouton ; - un
phx-click-awayposé sur une zone qui englobe le bouton et le panneau n'entre pas en conflit avec la bascule, alors que posé sur le panneau seul, il désynchronisearia-expanded; phx-window-keydowns'exécute même quand l'élément est caché : avecJS.pop_focus, une touche Échap pressée ailleurs laisse le focus en place, alors qu'unJS.focusfixe le vole.
Pour tester ces gestes-là, il faut un vrai navigateur : Wallaby ou Playwright, par exemple.
À retenir
- Une commande
JSest une structure%JS{}dont le champopsliste des opérations ; le pipe les enchaîne dans l'ordre où le client les exécutera. - Placée dans un attribut HEEx, elle devient du JSON que le client LiveView lit au clic : on n'écrit pas de JavaScript, et on peut ranger ses gestes dans des fonctions, comme
show/2ethide/2ducore_components.exgénéré. render_clickpousse l'opérationpushd'une liste et ignore le reste ; sur une liste purement cliente, il lèveno push or navigation command found within JS commands.Phoenix.LiveViewTestne suit pasJS.execvers un autre élément :render_hookpousse l'événement directement.- L'état posé par les commandes vit dans le navigateur et survit aux patchs ; le HTML rendu côté serveur ne le reflète pas, et seul un vrai navigateur peut le tester.
- Documentation : Phoenix.LiveView.JS