Powered by AppSignal & Oban Pro

Phoenix.LiveView.JS à nu : des commandes qui sont des données

Tips/liveview_js_commandes.livemd

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 onclick revient au premier patch, tandis qu'un panneau ouvert par JS.toggle reste 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.toggle en quelques dizaines de millisecondes ;
  • JS.toggle_attribute fait passer aria-expanded de "false" à "true" et retour, à la souris comme au clavier, et la valeur survit aux patchs ;
  • dans une modale, JS.focus_first place le focus sur le premier bouton, et JS.push_focus puis JS.pop_focus le rendent au bouton qui l'avait ouverte ; sans focus mémorisé, pop_focus ne fait rien ;
  • placé dans une bascule, JS.focus_first s'exécute aussi à la fermeture et fait retomber le focus sur <body> ;
  • pendant l'aller-retour d'un JS.push avec loading:, l'élément visé porte la classe phx-click-loading ;
  • avec JS.push, le serveur reçoit en plus la clé "value" => "", tirée de la propriété value du bouton ;
  • un phx-click-away posé 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ésynchronise aria-expanded ;
  • phx-window-keydown s'exécute même quand l'élément est caché : avec JS.pop_focus, une touche Échap pressée ailleurs laisse le focus en place, alors qu'un JS.focus fixe le vole.

Pour tester ces gestes-là, il faut un vrai navigateur : Wallaby ou Playwright, par exemple.

À retenir

  • Une commande JS est une structure %JS{} dont le champ ops liste 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/2 et hide/2 du core_components.ex généré.
  • render_click pousse l'opération push d'une liste et ignore le reste ; sur une liste purement cliente, il lève no push or navigation command found within JS commands.
  • Phoenix.LiveViewTest ne suit pas JS.exec vers un autre élément : render_hook pousse 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