File uploading in phoenix can be straight forward and I would recommend to anyone to use their docs to do it. However, I had a recent instance where I used a JavaScript driven version. More than anything, it was complicated because of all the files that needed to be changed and the JS involved.
This post is more step-by-step than narrative. Each section corresponds to a file change with the file path and info on what goes in it. It's more of a bookmark for myself for the future. Just a quick reference to what files were implicated and how.
It also serves as an example of an alternative approach to uploading and how JS is incorporated in work with Phoenix.
The sections are as follows:
FILE | CHANGES MADE |
---|---|
Migration | new table to store the file info |
Schema | schema definition and changesets |
Context Module | entirely new context queries to fetch reports DB transactions for reports |
API Controller | create and delete actions that employ corresponding context module functions |
API View | view render functions for show and delete json |
Controller Route | if creating a new controller, you will need to create a route to serve up the action for it to render somewhere |
Controller | Create or update an existing controller with an index action. This will correspond to the designated route and template |
Index template | links to view reports for the last 6 months select file, add custom name (optional) and upload display new upload with delete icon and capability |
JavaScript | checking for a file on button click uploading: posting the file via ajax http request handling success/failure show file in UI on success handle deleting file clearing errors |
API Route | the route to hit when making api requests |
Tests | testing of the context module functions |
Stein Storage | some context for methods used in the upload process from a homegrown library |
Migration
File path: priv/repo/migrations/20211021161736_create_reports.exs
What I did:
added a table with the following columns:
filename
key
extension
timestamps()
> could have included a separate 'name' column
defmodule MyApp.Repo.Migrations.CreateReports do
use Ecto.Migration
def change do
create table(:reports) do
add(:filename, :string, null: false)
add(:key, :uuid, null: false)
add(:extension, :string, null: false)
timestamps()
end
end
end
Schema
File path: lib/my_app/reports/report.ex
What I did:
Define Schema with table columns (fields); create and delete changesets
defmodule MyApp.Reports.Report do
@moduledoc """
Report schema
"""
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{}
schema "reports" do
field(:filename, :string)
field(:key, Ecto.UUID)
field(:extension, :string)
timestamps(type: :utc_datetime_usec)
end
def create_changeset(struct, file, key, custom_name \\ "") do
name = Web.DocumentView.name(file, custom_name)
struct
|> change()
|> put_change(:filename, name)
|> put_change(:key, key)
|> put_change(:extension, file.extension)
end
end
Context module
File path: lib/my_app/reports.ex
What I did:
Using Ecto.Query
• get a single report
• get reports for the last 6 months
Using Stein Storage
• upload a report
• delete a report
• download a report
defmodule MyApp.Reports.Reports do
@moduledoc """
Context for creating a report
"""
import Ecto.Query
alias MyApp.Repo
alias MyApp.Reports.Report
alias MyApp.Security
alias MyApp.SecurityLogs
alias Stein.Storage
@doc """
Get a report
"""
def get_report(id) do
case Repo.get(Report, id) do
nil ->
{:error, :not_found}
document ->
{:ok, document}
end
end
def all_last_six_months() do
last_six_months = Timex.shift(DateTime.utc_now(), months: -6)
Report
|> where([r], r.inserted_at > ^last_six_months)
|> order_by([r], desc: r.inserted_at, desc: r.id)
|> Repo.all()
end
@doc """
Upload a new report
"""
def upload_report(conn, user, %{"file" => file, "name" => name}) do
file = Storage.prep_file(file)
key = UUID.uuid4()
path = report_path(key, file.extension)
meta = [
{:content_disposition, ~s{attachment; filename="#{file.filename}"}}
]
allowed_extensions = [".pdf", ".txt", ".csv", ".jpg", ".png", ".tiff"]
case Storage.upload(file, path, meta: meta, extensions: allowed_extensions) do
:ok ->
upload(conn, user, file, key, name)
{:error, _reason} ->
user
|> Ecto.Changeset.change()
|> Ecto.Changeset.add_error(:file, "had an issue uploading")
|> Ecto.Changeset.apply_action(:insert)
end
end
def upload(conn, user, file, key, name) do
changeset =
%Report{}
|> Report.create_changeset(file, key, name)
remote_ip = Security.extract_remote_ip(conn)
result =
Ecto.Multi.new()
|> Ecto.Multi.insert(:report, changeset)
|> Ecto.Multi.run(:security_log, fn _repo, _changes ->
SecurityLogs.track(%{
originator_id: user.id,
originator_role: user.role,
originator_identifier: user.email,
originator_remote_ip: remote_ip,
action: "create",
details: %{upload: "site analytics report"}
})
end)
|> Repo.transaction()
case result do
{:ok, %{report: report, security_log: _security_log}} ->
{:ok, report}
{:error, _type, changeset, _changes} ->
{:error, changeset}
end
end
@doc """
Delete a report
Also removes the file from remote storage
"""
def delete_report(file) do
case Storage.delete(report_path(file)) do
:ok ->
Repo.delete(file)
{:error, error} ->
{:error, error}
end
end
@doc """
Get a signed URL to view the report
"""
def download_report_url(file) do
Storage.url(report_path(file.key, file.extension), signed: [expires_in: 3600])
end
@doc """
Get the storage path for a report
"""
def report_path(key, extension), do: "/reports/#{key}#{extension}"
def report_path(file = %Report{}) do
report_path(file.key, file.extension)
end
end
API controller
File path: lib/web/controllers/api/report_controller.ex
What I did:
ensure role plug to assume role access to admin only
execute context function, handle result
render response json (via the view)
defmodule Web.Api.ReportController do
use Web, :controller
alias MyApp.Reports.Reports
alias Web.ErrorView
action_fallback(Web.FallbackController)
plug(Web.Plugs.EnsureRole, [:admin, :super_admin])
def create(conn, %{"document" => params}) do
%{current_user: user} = conn.assigns
case Reports.upload_report(conn, user, params) do
{:ok, report} ->
conn
|> assign(:report, report)
|> put_status(:created)
|> render("show.json")
{:error, changeset} ->
conn
|> assign(:changeset, changeset)
|> put_status(:unprocessable_entity)
|> put_view(ErrorView)
|> render("errors.json")
end
end
def delete(conn, %{"id" => id}) do
with {:ok, file} <- Reports.get__report(id),
{:ok, _file} <- Reports.delete_report(file) do
conn
|> put_status(:ok)
|> render("delete.json")
end
end
end
API view
File path: lib/web/views/api/report_view.ex
What I did:
take in params and return desired keys in response map
defmodule Web.Api.ReportView do
use Web, :view
alias MyApp.Reports.Reports
def render("show.json", %{report: report}) do
%{
id: report.id,
filename: report.filename,
url: Reports.download_report_url(report)
}
end
def render("delete.json", _) do
%{}
end
end
Controller Route
File path: lib/web/router.ex
What did you do:get("/reports", ReportsController, :index)
Controller
File Path: lib/web/controllers/reports_controller.ex
What did you do:
def index(conn, _params) do
reports = Reports.all_last_six_months()
conn
|> assign(:reports, reports)
|> render("index.html")
end
Index template
File path: lib/web/templates/reports/index.html.eex
What did you do:
View reports
• query in the controller → instance var
reports = Reports.all_last_six_months()
conn
|> assign(:reports, reports)
|> render("index.html")
• map over reports provided in the template
<%= Enum.map(@reports, fn report -> %>
<div class="row">
<div class="col text-center">
<a href="<%= Reports.download_report_url(report) %>" target="_blank"><%= report.filename %></a>
</div>
</div>
<% end) %>
• display each as a link using Stein Storage.url
# function in the context - lib/my_app/reports.ex
def download_report_url(file) do
Storage.url(report_path(file.key, file.extension), signed: [expires_in: 3600])
end
# in the template - lib/web/templates/reports/index.html.eex
<a href="<%= Reports.download_report_url(report) %>" target="_blank"><%= report.filename %></a>
• uses the path /reports/#{key}#{extension}
and a signed cert
# lib/my_app/reports.ex
def report_path(key, extension), do: "/reports/#{key}#{extension}"
Upload/delete reportsinput class="report_file form-file-control" type="file">
JS
File path: assets/js/app/_report_file_upload.js
What did you do:
set csrf token right away
csrf_token = $("input[name='_csrf_token']").val()
$.ajaxPrefilter(function (options, originalOptions, jqXHR) {
jqXHR.setRequestHeader('X-CSRF-Token', csrf_token);
});
any changes made to file uploading clears any previous errors
• file-upload
class on encapsulating div
# Phoenix template: lib/web/templates/reports/index.html.eex
<div class="file-upload content-sub-1">
// JS
$(".file-upload").on("change", ".report_file", function() {
$(this).removeClass("is-invalid")
})
HTML being acted on
setup main function and variables needed
$(".file-upload").on("click", ".report_upload", function() {
parentComponent = $(this).parents(".file-upload")
nameInput = $(this).siblings(".file_name")
name = nameInput.val()
fileInput = $(this).siblings(".report_file")
file = fileInput.prop("files")[0]
uploadedReport = parentComponent.find(".uploaded_report")
fd = new FormData()
fd.append("document[file]", file)
fd.append("document[name]", name)
...
after selecting file and clicking upload button
• check file input for “file” props
• if there is a file selected, ajax post request
• set loading feedback
• execute ajax post request
if (file) {
$(".report_loading").append(`<img src="/images/loading-buffering.gif" class="loading-feedback w-25"/>`)
$.ajax({
url: "/api/reports",
type: "post",
processData: false,
contentType: false,
data: fd,
success: function(document) {
$(nameInput).val("")
$(fileInput).val("")
setTimeout(() => {
$(".loading-feedback").remove()
uploadedReport.append(`
<div>
<i class="fa fa-paperclip mr-1"></i>
<a href=${document.url} target="_blank">${document.filename}</a>
<a href="" data-document-id=${document.id} class="uploaded_report_delete">
<i class="fa fa-trash"></i>
</a>
</div>
`)
}, 1000);
},
error: function(err) {
console.log("Something went wrong")
setTimeout(() => {
$(".loading-feedback").remove()
$(fileInput).addClass("is-invalid")
}, 1000);
}
})
}
- if successful
• reset file name and upload field
• wait a second and remove loading feedback and append file to bottom of wrapper div
• add html to UI which includes the file link and a delete class and (trash) icon
timeout allows the loading UI to never just flash if uploading is quick - if unsuccessful
• remove the loading feedback after a second and add error class to file selector
If the trash icon is clicked
• get the file id that has been set in the html
• ajax delete request
• remove appended file html id successful
$(".uploaded_report").on("click", ".uploaded_report_delete", function(e) {
e.preventDefault()
document_id = $(this).data("document-id")
parent_element = $(this).parent()
$.ajax({
url: `/api/reports/${document_id}`,
type: "delete",
processData: false,
contentType: false,
success: function(res) {
parent_element.remove()
},
error: function(err) {
console.log("Something went wrong")
}
})
})
API Route
File path: lib/web/router.ex
What I did:
create and delete actions for this routeresources("/reports", ReportController, only: [:create, :delete])
corresponds to this call:
// JS - assets/js/app/_report_file_upload.js
$.ajax({
url: `/api/reports/${document_id}`,
type: "delete",
processData: false,
contentType: false,
...
}
Test
File path: test/my_app/reports/reports_test.exs
What I did:
defmodule MyApp.ReportsTest do
use Web.ConnCase
alias MyApp.TestHelpers.AccountHelpers
alias MyApp.Reports.Reports
describe "uploading reports" do
test "successfully", %{conn: conn} do
user = AccountHelpers.create_user(%{role: "admin"})
{:ok, report} =
Reports.upload_report(conn, user, %{
"file" => %{path: "test/fixtures/test.pdf"},
"name" => "Test File Name"
})
assert report.extension == ".pdf"
assert report.key
assert report.filename === "Test File Name.pdf"
end
end
describe "deleting a report" do
test "successfully", %{conn: conn} do
user = AccountHelpers.create_user(%{role: "admin"})
{:ok, report} =
Reports.upload_report(conn, user, %{
"file" => %{path: "test/fixtures/test.pdf"},
"name" => "Test File Name"
})
{:ok, report} = Reports.delete_report(report)
report = Reports.get_report(report.id)
assert report == {:error, :not_found}
end
end
end
Stein Storage
Methods used
.url()def url(key, opts \\ [])
Get the remote url for viewing an uploaded file
.upload()upload(file, key, opts)
Upload files to the remote storage (eg S3)
Stein.Storage.upload(file, key, extensions: [".jpg", ".png"])
returns either::ok
{:error, :invalid_extension} | {:error, :uploading}
.delete()
Delete files from remote storagedelete(key)
example
path = "/reports/#{file.key}#{file.extension}"
Storage.delete(path)
returns:ok
{:error, reason}
still might need to be deleted in the DBRepo.delete(file)
.prep_file()
Prepare a file for upload to the backendprep_file(upload)
Must be a Stein.Storage.FileUpload
, Plug.Upload
, or a map that has the :path
key.
original
file =
%Plug.Upload{
content_type: "text/csv",
filename: "sample.csv",
path: "/var/folders/s5/nq3wd1712hz45s5b52ntszkw0000gn/T//plug-1639/multipart-1639068655-854291197580633-6"
}
into...
Storage.prep_file(file)
file =
%Stein.Storage.FileUpload{
extension: ".csv",
filename: "sample.csv",
path: "/var/folders/s5/nq3wd1712hz45s5b52ntszkw0000gn/T//plug-1639/multipart-1639068547-750562336275738-2"
}