Tutorial

CSV Import file upload with preview in LiveView

This post was updated 27 Mar

Phoenix 1.7 fileupload CSV liveview

A very common scenario in web applications is the need to import a larger set of data. This could be done by importing it from an API but that is not always, or even not often, possible. A solution that is almost always possible is to import records through a CSV file that has been exported from Excel or any other spreadsheet software.

In this tutorial, I will go through how to upload and import a CSV file with Phoenix LiveView and show how easy it is to preview the imports before actually storing them in the database.

Setup

Before starting, I need to install the CSV package. So, first I can open up the mix file and install

# mix.exs
{:csv, "~> 3.0"},

Step 1: Create an Importer Module

We’ll start by creating an Importer module to handle our CSV data. This module will include a function empty_csv_data which provides a template CSV file with headers but no data. This will serve as a sample file for users to download and populate with their data for import.

# lib/my_app/customers/importer.ex
defmodule MyApp.Customers.Importer do
def empty_csv_data do
[["First Name", "Last Name", "Email", "Phone"]]
|> CSV.encode()
|> Enum.to_list()
end
end

Next, we’ll set up a route in our router file that will handle requests to download the sample CSV file:

# lib/my_app_web/router.ex
scope "/", MyAppWeb do
pipe_through :browser
get "/sample-csv", CsvController, :index
end

The /sample-csv route will trigger the index action in our CsvController. In the index action, we generate the sample CSV file and send it for download:

defmodule MyAppWeb.CsvController do
use MyAppWeb, :controller
alias MyApp.Customers.Importer
def index(conn, _params) do
csv_data = Importer.empty_csv_data()
send_download(
conn,
{:binary, csv_data},
content_type: "application/csv",
filename: "sample-import.csv"
)
end
end

Now if I visit https://localhost:4000/sample-csv I can download the sample CSV.

Step 2: Implement the LiveView

Now it’s time to turn our attention to the Import LiveView. I will start by creating a new live route, /customers/import, that will handle the process of importing customer data:

live "/customers/import", CustomerLive.Import, :import

This route corresponds to the MyAppWeb.CustomerLive.Import LiveView module. Here, we allow file uploads for :sample_csv in the mount function and set up empty assigns for storing parsed rows, imported customers, and uploaded files:

# lib/my_app_web/live/customer_live/import.ex
defmodule MyAppWeb.CustomerLive.Import do
use MyAppWeb, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:parsed_rows, [])
|> assign(:imported_customers, [])
|> assign(:sample_customers, [])
|> assign(:uploaded_files, [])
|> allow_upload(:sample_csv, accept: ~w(.csv), max_entries: 1)}
end
@impl true
def handle_event("reset", _, socket) do
{:noreply,
socket
|> assign(:parsed_rows, [])
|> assign(:imported_customers, [])
|> assign(:sample_customers, [])
|> assign(:uploaded_files, [])}
end
end

In our LiveView template, we provide a form that allows users to download the sample CSV file and upload their own CSV file for parsing:

<!-- lib/my_app_web/live/customer_live/import.html.heex -->
<.header>
Import Customers
<:subtitle>
Open up the file below in Excel, Google Sheets or a similar
</:subtitle>
<:actions>
<.link href={~p"/sample-csv"} target="_blank" class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700">
Download Sample CSV
</.link>
</:actions>
</.header>
<div class="my-12">
<form :if={length(@parsed_rows) == 0} id="upload-form" phx-submit="parse" phx-change="validate" class="space-y-2">
<div class="p-4 border border-zinc-200 rounded" phx-drop-target={@uploads.sample_csv.ref}>
<.live_file_input upload={@uploads.sample_csv} />
</div>
<.button type="submit">Upload</.button>
</form>
<div :if={length(@parsed_rows) > 0} class="flex items-center space-x-2">
<h3 class="text-base font-semibold">Upload a new CSV file</h3>
<.button type="button" phx-click="reset">Reset</.button>
</div>
</div>

Now there is functionality for both downloading the sample file and uploading it back.

Step 3: Extend the Importer Module

In the next step, we’ll extend our Importer module with preview and import functions. The preview function reads the first five rows from the CSV, transforms their keys to snake case, and prepares them for import. The import function does the same for all rows in the CSV.

# lib/my_app/customers/importer.ex
defmodule MyApp.Customers.Importer do
alias MyApp.Customers
alias MyApp.Customers.Customer
# ...
def preview(rows) do
rows
|> Enum.take(5)
|> transform_keys()
|> Enum.map(fn attrs ->
Customers.change_customer(%Customer{}, attrs)
|> Ecto.Changeset.apply_changes()
end)
end
def import(rows) do
rows
|> transform_keys()
|> Enum.map(fn attrs ->
Customers.create_customer(attrs)
end)
end
# "First Name" => "first_name"
defp transform_keys(rows) do
rows
|> Enum.map(fn row ->
Enum.reduce(row, %{}, fn {key, val}, map ->
Map.put(map, underscore_key(key), val)
end)
end)
end
defp underscore_key(key) do
key
|> String.replace(" ", "")
|> Phoenix.Naming.underscore()
end
end

Step 4: Handle File Upload Events

Next, we’ll add handlers for “validate”, “parse”, and “import” events in our LiveView module.

In the “validate” event handler, we do nothing for now. We could add validation logic here in the future.

In the “parse” event handler, we read the uploaded CSV file, decode its contents, and assign the resulting rows to :parsed_rows. We also generate a preview of the first five rows and assign it to :sample_customers.

Finally, the “import” event handler imports all parsed rows and assigns the resulting Customer structs to :imported_customers.

# lib/my_app_web/live/customer_live/import.ex
def handle_event("validate", _params, socket) do
{:noreply, socket}
end
def handle_event("parse", _, socket) do
parsed_rows = parse_csv(socket)
{:noreply,
socket
|> assign(:parsed_rows, parsed_rows)
|> assign(:sample_customers, Importer.preview(parsed_rows))
|> assign(:uploaded_files, [])}
end
def handle_event("import", _, socket) do
imported_customers = Importer.import(socket.assigns.parsed_rows)
{:noreply,
socket
|> assign(:sample_customers, [])
|> assign(:imported_customers, imported_customers)}
end
defp parse_csv(socket) do
consume_uploaded_entries(socket, :sample_csv, fn %{path: path}, _entry ->
rows =
path
|> File.stream!()
|> CSV.decode!(headers: true)
|> Enum.to_list()
{:ok, rows}
end)
|> List.flatten()
end

Finally, update the template with the preview and import button:

<div :if={@sample_customers != []} class="space-y-2">
<h3 class="text-xl font-bold">Preview</h3>
<.table id="customers" rows={@sample_customers}>
<:col :let={customer} label="First name">{customer.first_name}</:col>
<:col :let={customer} label="Last name">{customer.last_name}</:col>
<:col :let={customer} label="Email">{customer.email}</:col>
<:col :let={customer} label="Phone">{customer.phone}</:col>
</.table>
<p>This is a sample of the data to import</p>
<.button type="button" phx-click="import">Import Now</.button>
</div>
<div :if={@imported_customers != []} class="space-y-2">
<h3 class="text-xl font-bold">Import Result</h3>
<p>Success: {length(for {:ok, _} = row <- @imported_customers, do: row)} imports</p>
<p>Failed: {length(for {:error, _} = row <- @imported_customers, do: row)} imports</p>
</div>

Final result. File upload with preview and import with validation:

Related Tutorials

NEW
Published 27 Mar

Adding Modals to Phoenix 1.8 with DaisyUI

In Phoenix 1.8, the built-in modal component was removed. Instead, Phoenix now encourages developers to use separate LiveView pages for new and edit..

Published 03 Apr - 2024

Set session values from LiveView

Working with session data can significantly improve the feel of web applications, making interactions feel more connected and dynamic. However, Phoe..