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:
trix-attachment-add
, que se dispara cuando se adjunta un archivo al documento.trix-attachment-remove
, que se dispara cuando se elimina un archivo del documento.trix-file-accept
, que se dispara cuando se arrastra o inserta un archivo en el editor.
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:
lib/app/gallery.ex
: el módulo del contexto.lib/app/gallery/images.ex
: el esquema para la gestión de la tablaimages
.lib/app_web/views/image_view.ex
, la vista, ylib/app_web/controllers/image_controller.ex
, el controlador.- Y las correspondientes plantillas CRUD en
lib/app_web/templates/image
.
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:
- Generar un enlace que dirija a la imagen.
1link "texto del enlace", to: App.FileImage.url({image.image, image})
- Generar un enlace con el nombre del archivo que dirija a la imagen.
1link image.image.file_name, to: App.FileImage.url({image.image, image})
- Mostrar la imagen.
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.
- Mateusz Tatarski, Curiosum: How to upload a file in Elixir with Waffle.
- Chris Oliver, Go Rails: Using the Trix Editor plus File Upload Attachments
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.