Subir archivos multimedia con Trix Editor y Phoenix + Waffle

· Iván Hernández Cazorla - Blog

Trix, este es un editor de texto enriquecido sencillo y fácil de integrar en Phoenix. ¡Solo nos falta aprender a integrar la subida de archivos!
#phoenix, #elixir, #javascript

Nota (2023-11-20): tengo pendiente hacerle una actualización a este artículo tras la revisión que le hizo un gran alquimista, y mejor amigo. Cualquier duda, ¡no duden en preguntar! En el pie del artículo indico dónde.

Hace unas semanas publiqué la entrada Integrar Trix Editor con Phoenix, con la que quise enseñarles qué es Trix y cómo integrarlo en una instancia de Phoenix. Si quieren saber más sobre Trix o cómo integrar Trix en Phoenix, les recomiendo la entrada. En esta ocasión vamos a ver cómo podemos integrar la subida de archivos con Trix.

Integrar Trix con Phoenix no conlleva muchas líneas de código, ya que principalmente hay que crear un hook que facilite la comunicación de los eventos de Trix con Phoenix y crear el campo para la entrada de texto por medio de un hidden_input/4 y la etiqueta <trixeditor>.

# Por qué no funciona la subida de archivos multimedia con Trix por defecto

Antes de empezar con la parte práctica, vamos a entender por qué no podemos adjuntar archivos multimedia, más concretamente imágenes, con Trix a pesar de estar integrado.

Por una parte, Trix no está programado para subir el archivo por sí mismo, sea con Phoenix o cualquier otro framework, quien integre Trix es quien se encarga de programar la subida y gestión de los archivos. Lo que sí proporciona Trix es una serie de eventos que podemos escuchar para gestionar la subida de archivos:

En esta ocasión solo vamos a utilizar trix-attachment-add.

# Subida de archivos a Phoenix con Waffle

Se podría gestionar la propia subida de archivos por medio de JavaScript, pero como estamos en una instancia de Phoenix, nuestro objetivo es intentar que todo quede lo mejor integrado posible. Para ello vamos a utilizar Waffle, un paquete de Elixir que facilita la subida de archivos, el almacenamiento de la imagen en la base de datos con Waffle.Ecto e incluso la integración con Amazon S3, aunque esto último no lo veremos en esta entrada.

Lo primero sería configurar waffle, por lo que debemos introducir las dependencias en nuestro mix.exs:

1defp deps do
2  [
3    ...
4    {:waffle, "~> 1.1.6"},
5    {:waffle_ecto, "~> 0.0.11"}
6  ]
7end

Hacemos mix deps.get, ¡y ya tenemos Waffle instalado. Ahora debemos especificar en nuestros archivos de configuración (config/*.exs) cómo vamos a almacenar los archivos según el entorno en el que despleguemos nuestra aplicación (por ej., podría guardarse en S3 si la aplicación está desplegada en producción y en local si estamos en el entorno de desarrollo. En nuestro caso solo veremos como guardarlos nosotros mismos en un directorio local:

1config :waffle,
2  storage: Waffle.Storage.Local

# Contexto

Si no tenemos ya un contexto con el que gestionar las imágenes, debemos crear uno:

mix phx.gen.html Gallery Image images image:string

Ahora tenemos en nuestra aplicación lo siguiente:

Introducimos la ruta para este controlador en router.ex:

1defmodule AppWeb.Router do
2  scope "/", AppWeb do
3    pipe_through :browser
4    ...
5    resources "/images", ImageController
6  end
7end

Y finalmente realizamos la migración con mix ecto.migrate.

# Gestor de subida de archivos

Ahora tenemos que generar el gestor de subida con la tarea mix waffle.g file_image, que generará un archivo en lib/app_web/uploaders/file_image.ex. El contenido de ese archivo está comentado en su mayoría, por lo que debemos entrar en él y ajustarlo según nuestras necesidades. En este caso inyectamos Waffle.Definition y Waffle.Ecto.Definition y descomentamos la función validate/1, con la que podremos validar si la imagen tiene una extensión que admitimos. El conjunto quedaría de la siguiente manera:

 1defmodule AppWeb.FileImage do
 2  use Waffle.Definition
 3  use Waffle.Ecto.Definition
 4
 5  @versions [:original]
 6
 7  ...
 8
 9  # Whitelist file extensions:
10  def validate({file, _}) do
11    file_extensions = file.file_name |> Path.extname() |> String.downcase()
12
13    case Enum.member?(~w(.jpg .jpeg .gif .png), file_extension) do
14      true -> :ok
15      false -> {:error, "tipo de archivo inválido"}
16    end
17  end
18
19  ...
20
21end

Con respecto al resto de funciones comentadas, tenemos la opción de dejarlas ahí por si nos fuese de utilidad en el futuro u optar por eliminarlas y dejar el archivo más pequeño y limpio. Aunque, en este paso es importante destacar que los archivos se guardan por defecto en la carpeta /uploads. Si quisiésemos otra ubicación, tendríamos que descomentar las líneas de la función storage_dir/2 y ajustarla a nuestras necesidades.

Ahora debemos modificar el esquema que creamos previamente para inyectar Waffle.Ecto.Schema y corregir el tipo del campo image.

 1defmodule App.Gallery.Image do
 2  use Ecto.Schema
 3  use Waffle.Ecto.Schema
 4
 5  import Ecto.Changeset
 6
 7  schema "images" do
 8    field :image, App.FileImage.Type
 9  end
10
11  ...
12end

Finalmente, para acabar con el gestor de subida de imágenes, tendríamos que añadir a nuestro endpoint.ex otro plug para la gestión de los archivos estáticos (digo otro, porque ya está el definido por Phoenix).

 1defmodule AppWeb.Endpoint do
 2  ...
 3
 4  plug Plug.Static,
 5    at: "/uploads",
 6    from: Path.expand("./uploads"),
 7    gzip: false
 8
 9  ...
10end

En el caso de que hayan definido otro directorio por medio de storage_dir/2 en el módulo App.FileImage, tendrán que ajustar esa misma ruta en el plug.

# Ajustar las plantillas

Cuando generamos las plantillas para el controlador de las imágenes, estas se generan con la estructura por defecto. En nuestro caso, en lib/app_web/templates/image tenemos index.html.heex y show.html.heex, ambas con <%= @image.image %>, que en este caso nos proporcionaría un mapa que no nos sirve. Para poder obtener la url de la imagen debemos utilizar App.FileImage.url/1 y combinarla con la etiqueta HTML que necesitásemos o con alguna de las funciones definidas por Phoenix.HTML, como por ejemplo link/2 o img_tag/2. Veamos algunos ejemplos, uno con cada una de ellas:

1link "texto del enlace", to: App.FileImage.url({image.image, image})
1link image.image.file_name, to: App.FileImage.url({image.image, image})
1img_tag App.FileImage.url({image.image, image}), signed: true

# Integración con Trix

Llegado a este punto solo nos queda integrar este sistema de subida de imágenes con el editor. Para ello primero vamos a proporcionar a crear una pequeña API que nos permita trabajar con esas imágenes.

# API

Para simplificar esta tarea podemos tomar como punto de partida el AppWeb.ImageController que generamos previamente: creamos el directorio api dentro del directorio de controladores de Phoenix. Al archivo copiado le cambiamos el nombre a AppWeb.Api.ImageController y cambiamos la extensión html por json en todas las funciones render/3. Llegados a este punto podemos entretenernos o no en redefinirlo más adecuadamente según el uso que le vamos a dar, pero esta entrada ya se está alargando mucho, por lo que eso queda como decisión de cada uno.

Eso sí, la función create/2 debemos ajustarla para que en lugar de que nos redireccione a la imagen una vez se cree, nos devuelva la URL de la imagen:

 1  def create(conn, %{"image" => image_params}) do
 2    case MediaResources.create_image(image_params) do
 3      {:ok, image} ->
 4        url = AppWeb.Router.Helpers.image_path(conn, :show, image.id)
 5        image = Map.merge(image, %{url: url})
 6        conn
 7        |> render("show.json", image: image)
 8      {:error, %Ecto.Changeset{} = changeset} ->
 9        render(conn, "new.json", changeset: changeset)
10    end
11  end

Lo que hacemos en este caso es obtener la ruta de la imagen en la variable url a partir de su image.id y luego introducimos esa variable en la clave url del mapa image.

# Ajustar el hook

En la entrada Integrar Trix Editor con Phoenix creamos un hook muy sencillito para añadir Trix a nuestro proyecto Phoenix. El hook resultante es el siguiente:

 1const TrixHook = {
 2  initListener(trix, event) {
 3    trix.addEventListener(event, function() {
 4      console.log(`event ${event} fired!`)
 5    })
 6  },
 7  bindTrixEditor() {
 8    /*
 9    this.el = elemento en el que está el hook
10    el siguiente elemento sería el contenedor "page-form_body-editor"
11    el hijo "1" se refiere a la etiqueta <trix-editor>, el hijo "0" es la barra
12    de tareas del editor
13    */
14    let trix = this.el.nextElementSibling.children[1]
15    this.initListener(trix, "trix-change")
16  },
17  mounted() {
18    console.log("Hello, Trix!");
19    this.bindTrixEditor()
20  }
21}
22
23export default TrixHook;

Vamos a empezar por añadir un nuevo listener a nuestro bindTrixEditor(self):

1    self.initListener(self, trix, "trix-attachment-add")

Esto lo añadimos por debajo del que ya tenemos (trix-change) y, en initListener(self, trix, event) vamos a pedirle al sistema que cuando se produzca ese evento, se suba el archivo con una función que desarrollaremos en el siguiente paso:

1  initListener(self, trix, event) {
2    trix.addEventListener(event, function(e) {
3      if (event === "trix-attachment-add" && e.attachment.file) {
4        self.uploadFileAttachment(self, e.attachment)
5      }
6    })
7  },

e.attachment.file hace referencia al archivo que ha seleccionado el usuario en el diálogo de selección de archivos. Esto lo pasamos a la función uploadFileAttachment(self, attachment), que definimos de la siguiente manera:

 1  uploadFileAttachment(self, attachment) {
 2    let file = attachment.file
 3    let form = new FormData
 4    form.append("Content-Type", file.type)
 5    form.append("image[image]", file)
 6
 7    let csrf_token = document.querySelector("meta[name='csrf-token']").getAttribute("content")
 8
 9    let xhr = new XMLHttpRequest
10    xhr.open("POST", "/api/images", true)
11    xhr.setRequestHeader("X-CSRF-Token", csrf_token)
12
13    xhr.upload.onprogress = function(event) {
14      let progress = event.loaded / event.total * 100;
15
16      attachment.setUploadProgress(progress);
17    }
18
19    xhr.onload = function() {
20      if (xhr.status == 200 || xhr.status == 201) {
21        let response = JSON.parse(xhr.responseText);
22
23        return attachment.setAttributes({
24          url: response.data.image_url,
25          href: response.data.image_url
26        })
27      }
28    }
29
30    return xhr.send(form);
31  },

En esta función creamos un objeto FormData y le añadimos el tipo de archivo y el archivo en sí mismo.

1    let file = attachment.file
2    let form = new FormData
3    form.append("Content-Type", file.type)
4    form.append("image[image]", file)

csrf_token nos devuelve el token CSRF del formulario a partir de la selección del valor existente en la etiqueta <meta name="csrf-token">. Luego creamos un nuevo objeto XMLHttpRequest, inicializamos la petición a nuestra API y establecemos el token CSRF.

1    let xhr = new XMLHttpRequest
2    xhr.open("POST", "/api/images", true)
3    xhr.setRequestHeader("X-CSRF-Token", csrf_token)

Hacemos uso de xhr.upload.onprogress para indicar el progreso de la subida del archivo.

1    xhr.upload.onprogress = function(event) {
2      let progress = event.loaded / event.total * 100;
3
4      attachment.setUploadProgress(progress);
5    }

Antes de enviar la petición le indicamos que compruebe si devuelve un estado 200 (OK) o 201 (CREATED). Teóricamente debería bastar con el 200, pero en mi caso a veces me fallaba sin el 201. Obtenemos la respuesta a la petición con JSON.parse(xhr.response.Text) y devolvemos el attachment con dos nuevos atributos: url y href, que en nuestro caso lo asociamos con el mismo valor, image_url, porque queremos que nos abra la imagen a parte al hacerle clic.

 1    xhr.onload = function() {
 2      if (xhr.status == 200 || xhr.status == 201) {
 3        let response = JSON.parse(xhr.responseText);
 4
 5        return attachment.setAttributes({
 6          url: response.data.image_url,
 7          href: response.data.image_url
 8        })
 9      }
10    }

Y, finalmente, enviamos la petición devolviendo xhr.send(form).

# Conclusiones y agradecimientos

Con esto tendríamos Trix y la opción de subida de archivos integrada en nuestro proyecto Phoenix. Si juntamos los archivos, ¡no es tanto el proceso! Solo que yo he querido detallar cada parte porque cuando lo quise montar me sentí un poco perdido al no encontrar muchos recursos sobre este tema.

La entrada empecé a escribirla poco después de la primera sobre Trix, ¡en noviembre! Desde entonces ha habido novedades muy interesantes en Elixir y Phoenix. Incluso los compañeros de ElixirCasts publicaron un episodio dedicado a integrar Trix Editor en Phoenix, el cual les recomiendo ver.

Como dije, la entrada se me ha demorado muchísimo, porque me fui liando con quehaceres laborales y de la vida. Ahora mismo le haría algunos cambios a este enfoque, aunque la base seguiría siendo la misma, sobre todo lo que se refiere al hook.

En cuanto pueda intentaré hacer esto mismo siguiendo las nuevas convenciones recomendadas para crear APIs con Phoenix 1.7 e intentaré montar un repositorio con una aplicación de ejemplo.

Y ya para terminar, dar las gracias a Mateusz Tatarski por su entrada sobre Elixir y Waffle, y a Chris Oliver por su screencast sobre cómo integrar Trix Editor y la subidad de archivos en un proyecto Ruby on Rails.


Cualquier comentario, duda o sugerencia me la pueden hacer llegar por Telegram, ya sea por privado o mencionarme (@ivanhercaz) en el grupo elixirES.

Y si encuentran algún error o no funciona, díganmelo por favor, porque la entrada la hice en dos momentos muy distanciados entre sí y puede que se me haya escapado algún paso.


Formo parte de Molécula, un grupo de tres cowboys del espacio y alquimistas que programan mucho e intentan contribuir a la comunidad del software libre. Si te gusta lo que escribo o lo que contribuyo, puedes invitarnos a un café, té o maté. ¡Estamos en proceso de liberar varios proyectos!

Todas las entradas de este blog están bajo la licencia CC BY-SA 4.0. Las imágenes y otros recursos que no son de mi autoría tienen especificada su respectiva licencia. En caso de no tenerla, no dudes en reportármelo.

We are a team of three space cowboys and alchemists who develop software and try to contribute to the free software community. If you like what I write or what I contribute, you can invite us for a coffee, tea or maté. I still have have to finish developing several projects and, of course, release them!

All blog posts are licensed under CC BY-SA 4.0. The images or other resources that are not my autorship have their licenses specified. In case there is something it hasn't, don't hesitate to report it to me.