Statamic Peak

Article

L'upload de fichier avec Elixir et Phoenix LiveView

On va voir ensemble comment gérer l'upload d'un fichier avec Elixir, Phoenix LiveView et TailwindCSS pour avoir une expérience utilisateur aux petits oignons.

L'upload de fichier est toujours un incontournable dans les projets web et c'est souvent un moment compliqué quand on débute sur un framework : la documentation est trop technique, les exemples sur internet sont des copier/coller de la documentation et ne sont pas à jour, etc.

Avec Phoenix LiveView, on a un peu de chance, la documentation officielle est bien fournie mais pour quelqu'un qui découvre Phoenix, elle n'est pas toujours évidente à comprendre.

Je vais partir d'un exemple réel réalisé pour un side projet pour vous montrer comment faire !

Avant de commencer, je vous laisse préparer une LiveView, avec sa route, etc.

Configurer le socket pour autoriser l'upload

La première étape est d'autoriser l'upload de fichier pour notre LiveView. Pour cela, on va simplement modifier notre fonction `mount` en lui indiquant :

  • le nom du paramètre qui va recevoir l'image uploadée (téléversée si on veut éviter les anglicismes)

  • les extensions acceptées

  • et plein d'autres options

@impl true
  def mount(_params, _session, socket) do
    {
      :ok,
      socket
      |> allow_upload(:image, accept: ~w(.jpg .jpeg .png))
    }
  end

Intégrer notre champ d'upload

On va ajouter un champ <.live_file_input> dans notre fichier .html.heex ou notre fonction render/1. Il s'agit d'un composant créé pour nous par Phoenix LiveView et qui va s'occuper de faire le lien entre un input HTML classique et le socket de notre LiveView.

Je vais le placer dans un <form> parce que c'est le fonctionnement le plus courant mais vous pouvez même vous en passer notamment avec l'option :auto_upload de la fonction allow_upload.

Pour que ce soit un peu plus sympa visuellement, je vous propose un exemple qui utilise un élément gratuit de TailwindUI mais ça fonctionne évidemment sans.

<form
    id="upload-form"
    phx-change="validate-upload"
    phx-submit="upload"
  >
    <div class="mb-2">
      <label class="block text-sm font-medium text-gray-700">Image</label>
      <div class="mt-1 flex justify-center rounded-md border-2 border-dashed border-gray-300 px-6 pt-5 pb-6" phx-drop-target={@uploads.image.ref}>
        <div class="space-y-1 text-center">
          <svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48" aria-hidden="true">
            <path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
          </svg>
          <div class="flex text-sm text-gray-600">
            <label class="relative cursor-pointer rounded-md bg-white font-medium text-primary-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-primary-500 focus-within:ring-offset-2 hover:text-primary-500">
              <span>Upload a file</span>
              <.live_file_input upload={@uploads.image} class="sr-only" /> 
            </label>
            <p class="pl-1">or drag and drop</p>
          </div>
          <p class="text-xs text-gray-500">PNG or JPG</p>
        </div>
      </div>
    </div>
    <button phx_disable_with={"Saving..."}>Save</button>
</form>

Je vous passe le détail des classes TailwindCSS et du HTML. On va seulement s'intéresser à ce qu'on a modifié pour avoir une intégration avec Phoenix LiveView.

Tout d'abord, on a ajouté à notre formulaire les attributs phx-change="validate-upload" et phx-submit="upload". Ce sont les attributs classiques d'un formulaire avec Phoenix LiveView pour permettre la validation des données du formulaire et le traitement lors de l'envoi. Ils font référence à des fonctions handle_event de notre LiveView qu'on va détailler dans la prochaine partie.

Ensuite, on a un attribut phx-drop-target={@uploads.image.ref}. @uploads est injecté automatiquement par Phoenix pour nous et contient l'ensemble des uploads autorisés. Pour rappel, on l'a appelé image dans notre exemple mais on aurait pu la nommer avatar ou n'importe quoi d'autre. Cet attribut gère automatiquement pour nous le drag and drop d'une image dans ce <div>, plutôt cool !

Reste ensuite l'input en lui même qui est simplement <.live_file_input upload={@uploads.image} class="sr-only" />.

Traiter la validation formulaire

Côté Elixir, on va avoir une fonction à ajouter pour gérer l'évènement phx-change. On va simplement faire une validation du fichier (taille, type de fichier, etc.) mais si vous avez plusieurs champs de formulaire, c'est là également que vous allez valider votre changeset.

Pour la validation de l'upload, on a en réalité rien à faire de particulier, elle sera faite automatiquement à partir des paramètres fournis à la fonction allow_upload. On doit juste avoir une fonction qui gère l'évènement et renvoie le socket.

@impl true
  def handle_event("validate-upload", _params, socket) do
    {:noreply, socket}
  end

Une bonne chose de faite ! Mais que fait-on en cas d'erreur ? On va pouvoir rajouter entre notre upload et le bouton de submit, les erreurs et même la progression de l'upload et une preview tant qu'on y est.

<%= for entry <- @uploads.image.entries do %>
    <figure>
        <.live_img_preview entry={entry} />
        <figcaption><%= entry.client_name %></figcaption>
    </figure>
    <p>
        <progress value={entry.progress} max="100"> <%= entry.progress %>% </progress>
    </p>
    <%= for err <- upload_errors(@uploads.image, entry) do %>
        <p><%= error_to_string(err) %></p>
    <% end %>
<% end %>

Le fonctionnement de LiveView est le même si on autorise une seule image ou plusieurs, donc on doit faire le traitement dans une boucle. Je mettrais à jour l'exemple quand j'aurais passé un petit coup de CSS dessus, pour l'instant il est un peu brut.

Avant d'aller plus loin, il manque juste le code pour error_to_string/1. Quand il y a une erreur lors de l'upload, Phoenix nous donne l'information sous la forme d'un atom, on va donc faire des fonctions très simple pour récupérer un texte à la place.

def error_to_string(:too_large), do: "Image too large"
def error_to_string(:too_many_files), do: "Too many files"
def error_to_string(:not_accepted), do: "Unacceptable file type"

Sauvegarder le fichier

Pour terminer notre tutoriel, il ne nous reste plus qu'à enregistrer l'image uploadée côté serveur.

@impl true
  def handle_event("upload", _params, socket) do
    file_path =
      consume_uploaded_entries(socket, :image, fn %{path: path}, _entry ->
        directory = Path.join([:code.priv_dir(:my_app), "static", "uploads"])
        File.mkdir(directory)
        dest = Path.join([directory, Path.basename(path)])
        File.cp!(path, dest)
        {:ok, Routes.static_path(socket, "/uploads/#{Path.basename(dest)}")}
      end)
    {:noreply, push_event(socket, "new_image", %{image: file_path})}
  end

Si on part du plus général, on définit une fonction pour réagir à l'évènement upload, qui se déclenche à la soumission du formulaire. Cette fonction fait 2 choses, elle récupère un chemin et l'envoi à mon JS via un évènement. En réalité, vous faites ce que vous voulez de ce chemin, dans la documentation officielle, ils stockent les images dans l'assign du socket mais là c'est vraiment en fonction de vous.

Ce qui va nous intéresser le plus, c'est la fonction consume_uploaded_entries et le callback qu'on lui passe. Cette fonction va gérer l'enregistrement de tous les fichiers uploadés. Dans notre cas, on va les mettre dans le dossier /priv/static/uploads et le créer s'il n'existe pas déjà. La dernière ligne indique qu'on veut que le traitement de l'image ait lieu tout de suite et on construit une URL qui pointe vers le nouveau fichier stocké.

Si vous testez votre formulaire, tout va bien marché mais vous ne pourrez pas forcément afficher dans une <img> le fichier que vous venez d'enregistrer.

Vérifier la configuration de Plug.Static

Il est probable que l'URL que vous avez récupéré précédemment ne permette pas de visualiser l'image depuis votre navigateur. Cela vient du fait que vous définissez dans votre endpoint.ex la liste des répertoires que Plug.Static doit exposer.

Dans mon cas, il ressemble à ça, avec le dossier uploads que j'ai dû rajouter.

  plug Plug.Static,
    at: "/",
    from: :my_app,
    gzip: false,
    only: ~w(assets fonts images uploads favicon.ico robots.txt)

Et maintenant tout marche 🥳

Le dossier priv et les releases Mix

Le dossier priv est géré de manière un peu particulière. En effet, la fonction :code.priv_dir(:my_app) correspond au dossier priv de l'application compilée soit ./_build/dev/lib/my_app/priv en développement, qui est un lien symbolique vers ./priv.

En production avec une release Mix, priv est un dossier bien séparé qui est correspond au chemin ./_build/prod/rel/petal_pro/lib/myapp-{version}/priv. Dans le cas de l'utilisation de Docker, cela signifie que vous n'aurez pas un lien fixe pour créer un volume dans un docker compose. Il ne faudra pas l'utiliser pour des fichiers qui sont amenés à être conservés entre 2 releases.

À la place, on pourra simplement choisir une destination de dossier arbitraire pour sauvegarder nos images et ajouter une configuration Plug.Static pour gérer ce cas de figure.

plug Plug.Static, at: "/uploads", from: "/some/other/path"

Dans un prochain article, nous verrons comment utiliser S3 pour stocker nos fichiers.