How to handle file upload using Trix editor in a Phoenix application
In my quest of searching for a WYSIWYG editor, I came across Trix. An open-source rich text editor, easy to setup. Thanks to multiple ressources, I was able to set it up pretty quickly, however I didn't find anything regarding file uploads. In this article, I will focus on how to upload file locally or on a cloud storage.
Setup
Just in case you are starting from scratch reading this article, I will quickly go over how to setup Trix.
First, download the package using your favorite package manager :
Edit your app.css
:
If you don't want to install it, you can also use CDN by including the following code in your root.html.heex
file :
After that, create the Trix.js
hook.
$ touch assets/js/Trix.js
Then write the following in it :
In your app.js
:
Finally, in your form_component.ex
(or wherever you need to use the rich text editor)
Make sure that the input's id
matches the input
attribute of the <trix-editor />
tag.
At this point, you should see the editor displayed. Make sure to check your logs on the validate
event whenever you type something to see if it works as intended.
File Upload
Now that you are set up, let's get into the file upload part. If you take a closer look at the Trix's Github repository, they provide a sample code to handle file attachment. We will take inspiration from it and make adjustments to suit our needs.
First, let's create a dedicated route to handle file uploads. Add the following to your router.ex
under your "/" scope :
post "/trix-uploads", TrixUploadsController, :create
Then, create a trix_uploads_controller.ex
with the following :
I will go over the impl/0
in a bit. We'll start with the local upload.
Local upload
As you can see in the previous controller sample code, we are using an uploader adapter that will change depending on the current environment. Let's set up our local upload adapter.
In the dev.exs
, add the following line :
config :my_app, :uploader, adapter: Clients.Storage.Local
Then, create the lib/clients/storage/local.ex
with the following lines :
This module directly answers to the client-side XHR that we are going to setup right now.
Let's go back to the Trix.js
hook file and add the request :
We keep the initial implementation of the uploadFileAttachment
from the provided sample code.
function uploadFileAttachment(attachment) {
uploadFile(attachment.file, setProgress, setAttributes)
function setProgress(progress) {
attachment.setUploadProgress(progress)
}
function setAttributes(attributes) {
attachment.setAttributes(attributes)
}
}
Our uploadFile
function will be a little different :
Voilà. With this, you should be able to add attachments to your content and see them uploaded to the /uploads
folder.
Now, what if you had a change of heart and wanted to delete the newly added attachment ? Let's see how we can remove an attachment from our content.
If you take a look at our Trix.js
, we already covered the event (trix-attachment-remove
) and are invoking the function, we just need to implement it :
This piece of code simply sends another XHR to our back-end with the file url under the key
param. Let's go back to our trix_uploads_controller.ex
and add a function to respond to that request.
First, let's add another line to our router.ex
:
delete "/trix-uploads", TrixUploadsController, :delete
Then add the delete
function :
# trix_uploads_controller.ex
...
def delete(conn, %{"key" => key}) do
case impl().delete_file(key) do
:ok -> send_resp(conn, 204, "File successfully deleted")
{:error, _reason} -> send_resp(conn, 400, "Unable to delete file, please try again later.")
end
end
As you can see, it ressembles our previous create
function. Let's add the delete_file/1
function to our adapter.
# local.ex
...
def delete_file(file_url) do
"priv/static"
|> Path.join(file_url)
|> File.rm()
|> case do
:ok -> :ok
error -> error
end
end
Our keys will always be prefixed with /uploads
, so we are joining priv/static
to recreate the path to our file and delete it from the file system. Now, when clicking on the ❌ logo above your attachment, you should see it deleted from the /uploads
folder.
Now that we covered local upload, let's see how we can do the same thing for an external provider.
External upload
To give a little bit of context, I am using Backblaze B2 as a cloud storage. It comes with an S3 compatible API so the following code should work whether you are using AWS or a provider with a compatible API. At a particular point, I will add a line that is specific to Backblaze (I will stress it furthermore once we reach the relevant line of code), so make sure to delete it if you are using something else.
I will use the aws
package for this example. Add the following lines to your mix.exs
defp deps do
...
{:aws, "~> 0.13.0"},
{:hackney, "~> 1.18"},
...
end
Let's create the S3 upload adapter.
defmodule Clients.Storage.S3 do
@moduledoc false
@b2_region Application.compile_env!(:my_app, :b2_region)
@b2_access_key_id Application.compile_env!(:my_app, :b2_access_key_id)
@b2_secret_key_access Application.compile_env!(:my_app, :b2_secret_key_access)
@b2_bucket_name Application.compile_env!(:my_app, :b2_bucket_name)
def upload(%{"Content-Type" => content_type, "file" => %Plug.Upload{path: tmp_path}}) do
file_path = "public/#{Ecto.UUID.generate()}.#{ext(content_type)}"
file = File.read!(tmp_path)
md5 = :md5 |> :crypto.hash(file) |> Base.encode64()
get_client()
|> AWS.S3.put_object(@b2_bucket_name, file_path, %{
"Body" => file,
"ContentMD5" => md5,
"Content-Type" => content_type
})
|> case do
{:ok, _, %{status_code: 200}} ->
{:ok, "#{endpoint()}/#{@b2_bucket_name}/#{file_path}"}
_ = response ->
{:error, "Unable to upload file, please try again later."}
end
end
defp get_client do
@b2_application_key_id
|> AWS.Client.create(@b2_application_key, @b2_region)
# This line might be irrelevant for you if you are not using backblaze
|> AWS.Client.put_endpoint("s3.#{@b2_region}.backblazeb2.com")
end
defp endpoint do
"https://s3.#{@b2_region}.backblazeb2.com"
end
end
For testing purposes, you can modify your adapter in the dev.exs
from Clients.Storage.Local
to Clients.Storage.S3
.
Now let's have a look at the delete_file/1
function :
If you remember correctly from the Local implementation, we send the full url. In order to get the key, we need to split the url after the bucket name.
e.g : https://s3.eu-central-003.backblazeb2.com/my-bucket/public/3c528724-7b44-4d94-a3ef-06aa2e3ee5989.png
will become my-bucket/public/3c528724-7b44-4d94-a3ef-06aa2e3ee5989.png
def delete_file(file_url) do
key = file_url |> String.split("#{@b2_bucket_name}/") |> List.last()
case AWS.S3.delete_object(get_client(), @b2_bucket_name, key, %{}) do
{:ok, _body, %{status_code: 204}} -> :ok
{:error, _reason} = error -> error
end
end
That's it - Attachments are now handled regardless of the environment you are firing the request from.
Happy coding !