Phoenix File Uploading via JavaScript

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 reports
input 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

<div class="file-upload content-sub-1">3
  
	<h4 class="box-title mb-5">Upload site analytics reports</h4>
  
	<label for="file_name" class="control-label">File name (optional)		</label>
  
	<input class="file_name form-control" type="text">

    <br/>
        
 	<input class="report_file form-file-control" type="file">

	<div class="report_upload btn btn-primary">Upload file</div>
  
 	<div class="report_upload_error invalid-feedback">File must be a .pdf, .txt, .csv, .jpg, .png, or .tiff</div>
  
	<small class="form-text text-muted font-italic">Allowed file types: .pdf, .txt, .csv, .jpg, .png, or .tiff</small>

	<div class="report_loading"></div>
  
	<div class="uploaded_report">
    	View all reports currently on display <%= link("here", to: 		Routes.reports_path(@conn, :index))%>
	</div>

</div>
corresponding html

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 route
resources("/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 storage
delete(key)

example

path = "/reports/#{file.key}#{file.extension}"

Storage.delete(path)

returns
:ok
{:error, reason}

still might need to be deleted in the DB
Repo.delete(file)

.prep_file()
Prepare a file for upload to the backend
prep_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"
    }