Nous allons créer une application Phoenix avec LiveView.
LiveView est utilisé pour créer des applications web avec un développement côté serveur. Nous définissons des pages avec Elixir/Phoenix/Liveview et ces pages s’affichent dans le navigateur. Chaque événement sur la page communique avec le serveur et exécute une fonction sur le serveur qui renvoie la mise à jour à faire dans la page. Le développement se fait côté serveur uniquement sans avoir à gérer le javascript de communication entre la page et le serveur.
La gestion des requêtes avec LiveView
Nous avons vu dans l’article sur la création d’une page html pour une application Phoenix que les requêtes sont habituellement traitées avec :
- endpoint.ex : le point d’entrée de la requête
- router.ex : qui analyse la requête et choisi comment sera traité la requête
- _controller.ex : les contrôleurs qui exécutent les traitements et dont les noms sont composés avec le suffixe _controller.
Dans le cas de LiveView, les _controller.ex sont remplacés par des _live.ex.
La création de l’application phœnix avec l’option liveview
Pour créer une application phœnix avec les dossiers préparés pour liveview, nous n’avons plus à utiliser l’option –live puisque c’est l’option par defaut. L’option –no-live est utilisé lorsqu’on ne veut pas utiliser liveview.
- mix phx.new app_name
Nous avons :
- phx.new pour créer les application phœnix (phx est le diminutif pour phœnix)
- app_name le nom de l’application. L’ensemble du projet sera créé dans le répertoire dans lequel nous nous trouvons au moment du lancement de la commande mix.
- –live signifie que nous souhaitons créer les écrans au format liveview.
Nous pouvons voir la liste de toutes les options proposées par phx.new :
Installation de l’environnement de développement
Avant de commencer, vérifions et mettons à jour notre poste de travail si besoin.
Nous vérifions toutes les versions installées :
C:\Users\broussel>elixir -v Erlang/OTP 26 [erts-14.0.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [jit:ns] Elixir 1.15.5 (compiled with Erlang/OTP 26) C:\Users\broussel>mix local.hex Found existing entry: c:/Users/broussel/.mix/archives/hex-2.0.6 Are you sure you want to replace it with "https://builds.hex.pm/installs/1.14.0/hex-2.0.6.ez"? [Yn] n C:\Users\broussel>mix phx.new --version Phoenix installer v1.7.7 C:\Users\broussel>psql --version psql (PostgreSQL) 15.4 C:\Users\broussel>node --version v18.17.1 C:\Users\broussel>npm --version 9.6.7 C:\Users\broussel>
Toutes les versions sont à jour.
Présentation du projet minuteur avec liveview
Nous allons créer un projet très simple : un minuteur pour les œufs à la coque. Le principe, nous décomptons toutes les secondes en partant de 3mn, jusqu’à zéro. Lorsque nous arrivons à zéro, l’alarme se déclenche sous la forme d’un message. Nous affichons le temps qui passe afin de pouvoir suivre la cuissons de nos œufs.
Nous aurons :
- un affichage du compteur
- un bouton start pour lancer le minuteur
- un bouton stop pour arrêter l’alarme
Dans une application avec controller, la description de l’application se fait dans
- lib/my_app_web/controllers/
Avec liveview, nous avons les pages dans :
- lib/my_app_web/live/
Génération du projet Phoenix minuteur avec l’option liveview
Nous nous plaçons au préalable dans le répertoire Projets :
C:\Users\broussel>cd C:\CarbonX1\Phoenix\Projets C:\CarbonX1\Phoenix\Projets>mix phx.new minuteur --live * creating minuteur/config/config.exs * creating minuteur/config/dev.exs * creating minuteur/config/prod.exs * creating minuteur/config/runtime.exs * creating minuteur/config/test.exs * creating minuteur/lib/minuteur/application.ex * creating minuteur/lib/minuteur.ex * creating minuteur/lib/minuteur_web/controllers/error_json.ex * creating minuteur/lib/minuteur_web/endpoint.ex * creating minuteur/lib/minuteur_web/router.ex * creating minuteur/lib/minuteur_web/telemetry.ex * creating minuteur/lib/minuteur_web.ex * creating minuteur/mix.exs * creating minuteur/README.md * creating minuteur/.formatter.exs * creating minuteur/.gitignore * creating minuteur/test/support/conn_case.ex * creating minuteur/test/test_helper.exs * creating minuteur/test/minuteur_web/controllers/error_json_test.exs * creating minuteur/lib/minuteur/repo.ex * creating minuteur/priv/repo/migrations/.formatter.exs * creating minuteur/priv/repo/seeds.exs * creating minuteur/test/support/data_case.ex * creating minuteur/lib/minuteur_web/controllers/error_html.ex * creating minuteur/test/minuteur_web/controllers/error_html_test.exs * creating minuteur/lib/minuteur_web/components/core_components.ex * creating minuteur/lib/minuteur_web/controllers/page_controller.ex * creating minuteur/lib/minuteur_web/controllers/page_html.ex * creating minuteur/lib/minuteur_web/controllers/page_html/home.html.heex * creating minuteur/test/minuteur_web/controllers/page_controller_test.exs * creating minuteur/lib/minuteur_web/components/layouts/root.html.heex * creating minuteur/lib/minuteur_web/components/layouts/app.html.heex * creating minuteur/lib/minuteur_web/components/layouts.ex * creating minuteur/priv/static/images/logo.svg * creating minuteur/lib/minuteur/mailer.ex * creating minuteur/lib/minuteur_web/gettext.ex * creating minuteur/priv/gettext/en/LC_MESSAGES/errors.po * creating minuteur/priv/gettext/errors.pot * creating minuteur/priv/static/robots.txt * creating minuteur/priv/static/favicon.ico * creating minuteur/assets/js/app.js * creating minuteur/assets/vendor/topbar.js * creating minuteur/assets/css/app.css * creating minuteur/assets/tailwind.config.js * creating minuteur/assets/vendor/heroicons/LICENSE.md * creating minuteur/assets/vendor/heroicons/UPGRADE.md * extracting minuteur/assets/vendor/heroicons/optimized Fetch and install dependencies? [Yn] Y * running mix deps.get * running mix assets.setup * running mix deps.compile We are almost there! The following steps are missing: $ cd minuteur Then configure your database in config/dev.exs and run: $ mix ecto.create Start your Phoenix app with: $ mix phx.server You can also run your app inside IEx (Interactive Elixir) as: $ iex -S mix phx.server C:\CarbonX1\Phoenix\Projets>
Nous nous plaçons dans le répertoire du projet minuteur, puis nous ouvrons le projet dans VS Code par la commande [code .] afin de vérifier les paramètres de la base de données.
C:\CarbonX1\Phoenix\Projets>cd minuteur C:\CarbonX1\Phoenix\Projets\minuteur>code . C:\CarbonX1\Phoenix\Projets\minuteur>
La configuration de la base de données pour le projet se trouve dans le fichier MINUTEUR/config/dev.exs :
# Configure your database config :minuteur, Minuteur.Repo, username: "postgres", password: "postgres", hostname: "localhost", database: "minuteur_dev", stacktrace: true, show_sensitive_data_on_connection_error: true, pool_size: 10
Nous avons bien postgres/postgres.
Nous exécutons la création de la base de données :
C:\CarbonX1\Phoenix\Projets\minuteur>mix ecto.create Compiling 15 files (.ex) Generated minuteur app The database for Minuteur.Repo has been created C:\CarbonX1\Phoenix\Projets\minuteur>
La génération du dossier générique pour le projet Minuteur est maintenant terminée.
Création de la page minuteur
Pour créer la page minuteur, nous devons :
- rendre la page accessible avec router.ex
- créer la page /live/minuteur_live.ex
Déclaration de la page dans le routeur
Pour que la page minuteur soit accessible depuis le navigateur, nous devons déclarer dans router.ex le chemin URL vers /minuteur.
on déclare un lien pour L’URL /minuteur :
lib/minuteur_web/router.ex :
scope "/" MyAppWeb do pipe_through :browser get "/", PageController, :home live "/minuteur", MinuteurLive end
Remarquez que nous n’ajoutons pas une action comme sur la ligne précédente qui contient l’action :home
Création de la page minuteur_live
Nous créons un dossier live dans minuteur_web. Ce dossier contiendra notre page minuteur_live.ex :
- lib/minuteur_web/live/minuteur_live.ex
La page minuteur_live.ex contient 2 fonctions :
- une fonction d’initialisation de la page : mount
- une fonction d’affichage de la page : render
lib/minuteur_web/live/minuteur_live.ex :
defmodule MinuteurWeb.MinuteurLive do use MinuteurWeb, :live_view def mount(_param, _session, socket) do {:ok, assign(socket, hello: :world)} end def render(assigns) do ~H""" <h1>bonjour <%= @hello %></h1> <h2>on placera le minuteur ici</h2> """ end end
La fonction render utilise le paramètre assigns qui est une Map située dans socket. Assigns est utilisé pour ranger sous la form key/value les données spécifiques à la session pour l’application. Dans la fonction mount., nous avons mis le couple key/value [:hello,:world] dans socket.assigns.
Avec assign(socket, hello: :world) nous plaçons dans socket le couple key [:hello], value [:world].
La fonction render défini la page à afficher dans le navigateur. C’est du HTML, parsé avec ~H qui permet d’évaluer puis remplacer le code Elixir inscrit dans les zones délimitées par <%= @variable %>.
~H se lit « sigil H« .
Nous pouvons afficher le message et voir le résultat dans notre navigateur avec :
- cd dossier de l’application
- mix phx.server : pour lancer le serveur, et on peut le laisser actif.
dans le navigateur
- http://localhost:4000/minuteur
Affichage de la page minuteur
Nous avons la page qui s’affiche bien dans notre navigateur :
Nous pouvons passer à l’étape suivante.
Ajout du compteur dans la page
Le compteur est effectué à partir de timer, qui dispose d’une fonction pour déclencher un évènement à intervalle régulier :
- :timer.send_interval/3 (delais, pid, messageName)
defmodule MinuteurWeb.MinuteurLive do # use Phoenix.LiveView use MinuteurWeb, :live_view def mount(_param, _session, socket) do :timer.send_interval(1000,self(),:tick) {:ok, assign(socket, hello: :world, count: 180)} end def render(assigns) do ~H""" <h1>bonjour <%= @hello %></h1> <h2>vos oeufs sont prêt dans :</h2> <p>décompte : <%= @count %> secondes</p> """ end def handle_info(:tick, socket) do {:noreply, count(socket)} end defp count(socket) do assign(socket, count: socket.assigns.count - 1) end end
A l’initialisation de la page donc dans mount(), nous ajoutons une variable count qui est notre compteur initialisé à 3mn soit 180 secondes. L’initialisation se fait dans la fonction mount.
La fonction timer.send_interval crée un évènement ici appelé :tick, qui est reçu par la fonction handle_info.
Nous créons une fonction privée count() pour recalculer la valeur de count à chaque évènement :tick. La valeur de count est récupérée dans socket.assigns qui contient les variables de l’application.
Nous voyons ici les 3 étapes :
- préparer le travail : mount
- faire le travail : handle_info
- montrer le travail : render
Affichage de la page avec le compteur
Nous activons l’application :
- cd dossier de l’application
- mix phx.server : pour lancer le serveur
dans le navigateur
- http://localhost:4000/minuteur
La page dans le navigateur avec le compteur :
Amélioration de notre minuteur qui doit s’arrêter en arrivant à zéro
Notre compteur ne s’arrête jamais. Regardons dans la documentation si :timer propose des actions pour arrêter le compteur. L’aide en ligne de iex permet d’afficher la documentation avec la fonction help ‘h module_elixir ‘. Nous quitterons iex avec System.halt.
C:\Users\broussel>iex Erlang/OTP 26 [erts-14.0.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [jit:ns] Interactive Elixir (1.15.5) - press Ctrl+C to exit (type h() ENTER for help) iex(1)> h :timer :timer This module provides useful functions related to time. Unless otherwise stated, time is always measured in milliseconds. All timer functions return immediately, regardless of work done by another process. Successful evaluations of the timer functions give return values containing a timer reference, denoted TRef. By using cancel/1 (stdlib:timer#cancel/1), the returned reference can be used to cancel any requested action. A TRef is an Erlang term, which contents must not be changed. The time-outs are not exact, but are at least as long as requested. Creating timers using erlang:send_after/3 (erts:erlang#send_after/3) and erlang:start_timer/3 (erts:erlang#start_timer/3) is more efficient than using the timers provided by this module. However, the timer module has been improved in OTP 25, making it more efficient and less susceptible to being overloaded. See the Timer Module section in the Efficiency Guide (system/efficiency_guide:commoncaveats#timer-module). ## Examples Example 1 The following example shows how to print "Hello World!" in 5 seconds: 1> timer:apply_after(5000, io, format, ["~nHello World!~n", []]). {ok,TRef} Hello World! Example 2 The following example shows a process performing a certain action, and if this action is not completed within a certain limit, the process is killed: Pid = spawn(mod, fun, [foo, bar]), %% If pid is not finished in 10 seconds, kill him {ok, R} = timer:kill_after(timer:seconds(10), Pid), ... %% We change our mind... timer:cancel(R), ... ## Notes A timer can always be removed by calling cancel/1 (stdlib:timer#cancel/1). An interval timer, that is, a timer created by evaluating any of the functions apply_interval/4 (stdlib:timer#apply_interval/4), send_interval/3 (stdlib:timer#send_interval/3), and send_interval/2 (stdlib:timer#send_interval/2) is linked to the process to which the timer performs its task. A one-shot timer, that is, a timer created by evaluating any of the functions apply_after/4 (stdlib:timer#apply_after/4), send_after/3 (stdlib:timer#send_after/3), send_after/2 (stdlib:timer#send_after/2), exit_after/3 (stdlib:timer#exit_after/3), exit_after/2 (stdlib:timer#exit_after/2), kill_after/2 (stdlib:timer#kill_after/2), and kill_after/1 (stdlib:timer#kill_after/1) is not linked to any process. Hence, such a timer is removed only when it reaches its time-out, or if it is explicitly removed by a call to cancel/1 (stdlib:timer#cancel/1). iex(2)> System.halt C:\Users\broussel>
Pour arrêter notre compteur nous utilisons la fonction :timer.kill_after(0). Nous modifions notre code pour arrêter le comptage à 0. Cette solution ne fonctionnera pas car le superviseur relance l’application une fois le message killl reçu. Nous proposerons une deuxième version avec :timer.cancel(refTimer) qui elle fonctionnera parfaitement.
Utilisation du patern-matching pour isoler l’arrivée du compteur à zéro
Le pattern-matching sur count(socket) permet de filtrer et arrêter le compteur lorsque celui-ci est à zéro. La documentation Phoenix donne la structure de données de Phoenix.Socket.
Un article présente un guide pour l’utilisation d’assigns dans Liveview. Nous voyons en particulier comment créer le pattern-matching avec les données d’assigns :
- socket=%{assigns: %{count: 0}}
lib/minuteur_web/live/minuteur_live.ex (version relancé par le superviseur après arrêt du timer) :
#si on a le assigns.count à 0 defp count(socket=%{assigns: %{count: 0}}) do :timer.kill_after(0) #on arrête le timer immédiatement socket #pas de modification de socket end # dans les autres cas defp count(socket) do assign(socket, count: socket.assigns.count - 1) end
La version ci-dessus ne fonctionne pas car le superviseur relance l’application après réception du message kill. Nous allons changer de méthode. Nous devons garder la référence à notre timer ligne (5) que nous passons dans notre socket.assigns, ligne (6) ; lorsque nous souhaitons stopper le timer, nous récupérons la référence au timer, ligne (22), et nous pouvons appeler :timer.cancel, ligne (23).
lib/minuteur_web/live/minuteur_live.ex (version qui fonctionne ) :
defmodule MinuteurWeb.MinuteurLive do use MinuteurWeb, :live_view def mount(_param, _session, socket) do {:ok, my_timer} = :timer.send_interval(1000,self(),:tick) {:ok, assign(socket, hello: :world, count: 180, timer: my_timer)} end def render(assigns) do ~H""" <h1>bonjour <%= @hello %></h1> <h2>vos oeufs sont prêt dans :</h2> <p>décompte : <%= @count %> secondes</p> """ end def handle_info(:tick, socket) do {:noreply, count(socket)} end #si on a le assigns.count à 0 defp count(socket=%{assigns: %{count: 0, timer: my_timer}}) do :timer.cancel(my_timer) #on arrete le timer socket #pas de modification de socket end # dans les autres cas defp count(socket) do assign(socket, count: socket.assigns.count - 1) end end
Le serveur est activé par :
- cd C:\CarbonX1\Phoenix\Projets\minuteur
- mix phx.server : pour lancer le serveur
l’application est accessible par : http://localhost:4000/minuteur
Dans la suite de cet article, nous proposons d’ajouter des actions utilisateur pour lancer le minuteur, et remettre le minuteur à zéro. Nous allons aussi améliorer l’affichage de notre minuteur grâce au pattern-matching sur les différents états du timer.
Gestion des actions utilisateurs dans la page
Ajouter les actions utilisateurs avec des boutons
Les boutons sont des actions utilisateurs. Liveview intègre les actions des utilisateurs avec des fonctions placées dans le code serveur écrit en Elixir/Phoenix.
Trois boutons sont utilisés pour faire fonctionner notre minuteur :
- start : pour lancer le minuteur
- reset : pour réinitialiser le compteur à 3mn
- stop : pour arrêter le compteur
Les états correspondant à nos 3 boutons :
- ready : lorsque le timer est prêt à être lancé
- running : lorsque le timer est démarré
- finished : lorsque le compteur est arrivé à zéro
L’affichage est spécialisé pour chacun des états avec des messages spécifiques :
- ready : « mettez vos œufs dans l’eau bouillante et appuyez sur ‘start' »
- running : « vos œufs sont prêt dans x secondes »
- finished : « vos œufs sont prêts, retirez les de l’eau bouillante »
- stopped : « le compteur est arrêté »
Pour afficher dans render(assigns) les bons messages, nous créons une fonction afficher(), qui filtre selon les timer_status par pattern_matching.
Le lancement du timer se fait dans start. L’initialisation est dans l’état :ready.
Code de la page du minuteur avec les actions utilisateurs
Nous avons donc :
defmodule MinuteurWeb.MinuteurLive do # use Phoenix.LiveView use MyAppWeb, :live_view def mount(_param, _session, socket) do {:ok, assign(socket, hello: :world, count: 180, timer_status: :ready )} end def render(assigns) do afficher(assigns) end # ready def afficher(assigns=%{timer_status: :ready}) do ~H""" <h1>bonjour <%= @hello %></h1> <h2>Mettez vos oeufs dans l'eau bouillante et appuyez sur 'start'</h2> <button phx-click="start">start</button> """ end # running def afficher(assigns=%{timer_status: :running}) do ~H""" <h1>bonjour <%= @hello %></h1> <h2>Minuteur status : <%= @timer_status %></h2> <h2>vos oeufs sont prêt dans :</h2> <p>décompte : <%= @count %> secondes</p> <button phx-click="stop">stop</button> """ end # stopped def afficher(assigns=%{timer_status: :stopped}) do ~H""" <h1>bonjour <%= @hello %></h1> <p>décompte : <%= @count %> secondes</p> <h2>Le Minuteur a été arrété</h2> <h2>Réinitialisez le minuteur avec 'reset'</h2> <button phx-click="reset">reset</button> """ end # finished def afficher(assigns=%{timer_status: :finished}) do ~H""" <h1>bonjour <%= @hello %></h1> <p>décompte : <%= @count %> secondes</p> <h2>Vos oeufs sont prêts : retirez-les de l'eau !</h2> <h2>Bonne dégustation</h2> <button phx-click="reset">reset</button> """ end # affichage du cas général lorsque le status ne correspond à aucun cas ci-dessus def afficher(assigns) do ~H""" <h1>bonjour <%= @hello %></h1> <h2>status du minuteur : <%= @timer_status %></h2> <button phx-click="reset">reset</button> """ end # bouton reset : réinitialisation à ready def handle_event("reset", _metadata, socket) do {:ok, assign(socket, count: 180, timer_status: :ready )} end # bouton start ! lancement du compteur def handle_event("start", _metadata, socket) do {:ok, my_timer} = :timer.send_interval(1000,self(),:tick) # IO.inspect(my_timer, label: "my_timer") {:ok, assign(socket, count: 180, timer_status: :running, timer: my_timer )} end # bouton stop : arrêt du compteur def handle_event("stop", _metadata, socket=%{assigns: %{timer: my_timer}}) do :timer.cancel(my_timer) #on arrete le timer {:ok, assign(socket, timer_status: :stopped )} end def handle_info(:tick, socket) do {:noreply, count(socket)} end #si on a le assigns.count à 0 defp count(socket=%{assigns: %{count: 0, timer: my_timer}}) do :timer.cancel(my_timer) #on arrete le timer assign(socket, timer_status: :finished) #nous passons le status à :finished end defp count(socket) do assign(socket, count: socket.assigns.count - 1) end end
Nous ajoutons dans afficher(assigns) pour chacun des états du minuteur (ligne 37 et 48), le compteur afin de bien vérifier que le minuteur est arrêté lorsque cela doit être le cas :
- <p>décompte : <%= @count %> secondes</p>
Nous avons ajouté ligne (72) un moyen de vérifier le retour de la création du timer :
- IO.inspect(my_timer, label: « my_timer »)
Le bouton contient phx-click= »my_event » qui déclenche un évènement phx-click qu’on retrouve dans la fonction handle_event/3 de notre module, avec « my_event » comme premier argument.
pour lancer le serveur :
- cd C:\CarbonX1\Phoenix\Projets\minuteur
- mix phx.server : pour activer le serveur
Autre option, remplacer :timer par Process.send_after
L’application développer ci-dessus fonctionne. Une autre façon de faire aurait été de remplacer :
- timer.send_interval (1000, self(),:tick) par
- Process.send_after (self(), :tick, 1000)
Et nous ajoutons une variable timer_status permettant de connaitre l’état de notre minuteur :
- timer_status : running #minuteur en cours de comptage
- timer_status : stopped : #minuteur à l’arrêt
Pour comprendre Process, nous devons voir qu’il s’agit de l’équivalent d’un thread en java, avec possibilité d’y accéder par message. On peut aussi demander à Process d’envoyer un message différé dans le temps, pour jouer le rôle de notre Timer : Process.send_after/4 :
- send_after(dest, msg, time, opts \ [])
Nous pourrions donc utiliser Process.send_after pour remplacer notre Timer.
A l’initialisation, nous indiquons que timer_status est à stopped. le boutons start passe le timer_status à running et déclenche un évènement send_after (:tick). l’évenement tick est géré par handle_info comme précédemment avec count(socket) pour faire avancer le minuteur en relançant un Process.send_after (:tick). Lorsque le minuteur arrive à 0, on arrête le compteur avec timer_status : stopped.
Vous pouvez lire l’article Phoenix LiveView Stopwatch, qui propose cette solution. En outre cet article montre comment créer une horloge partagée entre plusieurs navigateurs.
Nous proposons ici le code de la version avec Process.send_after.
defmodule MinuteurWeb.MinuteurLive do # use Phoenix.LiveView use MyAppWeb, :live_view def mount(_param, _session, socket) do # :timer.send_interval(1000,self(),:tick) {:ok, assign(socket, hello: :world, count: 180, timer_status: :stopped )} end def render(assigns) do ~H""" <h1>bonjour <%= @hello %></h1> <h2>Minuteur status : <%= @timer_status %></h2> <h2>vos oeufs sont prêt dans :</h2> <p>décompte : <%= @count %> secondes</p> <button phx-click{:start}>start</button> <button phx-click{:stop}>stop</button> """ end def handle_info(:tick, socket) do if socket.assigns.timer_status == :running do Process.send_after(self(), :tick, 1000) {:noreply, count(socket)} else {:noreply, socket} end end def handle_event("start", _metadata, socket) do Process.send_after(self(), :tick, 1000) {:ok, assign(socket, hello: :world, count: 180, timer_status: :running )} end def handle_event("stop", _metadata, socket) do {:ok, assign(socket, hello: :world, timer_status: :stopped )} end defp count(socket) do assign(socket, count: socket.assigns.count - 1) if socket.assigns.count <= 0 do assign(socket, timer_status: :stopped ) end end end
Conclusion
Nous avons vu que liveview permet de créer une application web en travaillant exclusivement sur la partie Serveur en Phoenix/Elixir.
Pour gérer les évènements générés côté serveur,
- nous utilisons la fonction handle_info.
Pöur gérer les évènements utilisateurs depuis le navigateur, nous :
- déclarons l’évènement avec phx-click= »event« dans les boutons par exemple,
- et nous traitons l’évènement dans la partie serveur avec handle_event.
Nous avons terminé notre application Phoenix avec liveview. Dans un prochain article nous regarderons comment améliorer le design de l’application en utilisant des composants