Tutorial
CSV Export with Phoenix and LiveView
A common need in web apps is to export data to different file formats. One common, or even maybe the most common format, is exporting CSV files. CSV stands for comma separated values and its basically just a text file where all the values are separated by comma or semi colon. A CSV file is in turn easy to import in other programs or can easily be parsed with a simple script.
In this tutorial I want to show how to export a CSV from an Elixir Phoenix application. And I want to export the CSV from a Phoenix LiveView interface where I can pick what fields to export.
I already have a page that consists of a table with customers. The headers are name, address, zip, city, phone, longitude and latitude. The goal is to add an export-CSV functionality that can optionally pick and choose the attributes.
I will implement the export CSV functionality in these steps
- Add CSV library to mix file
- Create a controller for the export
- Add export CSV route to the routes file
- Add the Export button and test the solution
Add CSV library to mix file
The are a few CSV libraries that can to this but I went with https://hexdocs.pm/csv/CSV.html. So, I will add it do the deps and install the dependencies.
# mix.exs
defp deps do
[
# other deps
{:csv, "~> 2.4"},
]
end
And run:
mix deps.get
Now that the CSV library is installed, I can move on to building out the logic to export the CSV.
Create a controller for the export
Since the goal is to generate a file that needs to be downloaded and not displayed in the browser I need to customise the conn pipeline and specify the content type as text/csv
. I will also need to specify the response header: "content-disposition", "attachment; filename=\"export.csv\""
.
Add export CSV route to the routes file Not that the controller is in place, I can add a port route to it:
<!-- lib/tutorial_web/live/customer_live/index.html.heex --><div class="flex justify-end mb-2">
<%= link to: Routes.export_path(@socket, :create), class: "btn btn-secondary", method: :post do %>
Export
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="w-6 h-6 ml-1" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M14 4.5V14a2 2 0 0 1-2 2h-1v-1h1a1 1 0 0 0 1-1V4.5h-2A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v9H2V2a2 2 0 0 1 2-2h5.5L14 4.5ZM3.517 14.841a1.13 1.13 0 0 0 .401.823c.13.108.289.192.478.252.19.061.411.091.665.091.338 0 .624-.053.859-.158.236-.105.416-.252.539-.44.125-.189.187-.408.187-.656 0-.224-.045-.41-.134-.56a1.001 1.001 0 0 0-.375-.357 2.027 2.027 0 0 0-.566-.21l-.621-.144a.97.97 0 0 1-.404-.176.37.37 0 0 1-.144-.299c0-.156.062-.284.185-.384.125-.101.296-.152.512-.152.143 0 .266.023.37.068a.624.624 0 0 1 .246.181.56.56 0 0 1 .12.258h.75a1.092 1.092 0 0 0-.2-.566 1.21 1.21 0 0 0-.5-.41 1.813 1.813 0 0 0-.78-.152c-.293 0-.551.05-.776.15-.225.099-.4.24-.527.421-.127.182-.19.395-.19.639 0 .201.04.376.122.524.082.149.2.27.352.367.152.095.332.167.539.213l.618.144c.207.049.361.113.463.193a.387.387 0 0 1 .152.326.505.505 0 0 1-.085.29.559.559 0 0 1-.255.193c-.111.047-.249.07-.413.07-.117 0-.223-.013-.32-.04a.838.838 0 0 1-.248-.115.578.578 0 0 1-.255-.384h-.765ZM.806 13.693c0-.248.034-.46.102-.633a.868.868 0 0 1 .302-.399.814.814 0 0 1 .475-.137c.15 0 .283.032.398.097a.7.7 0 0 1 .272.26.85.85 0 0 1 .12.381h.765v-.072a1.33 1.33 0 0 0-.466-.964 1.441 1.441 0 0 0-.489-.272 1.838 1.838 0 0 0-.606-.097c-.356 0-.66.074-.911.223-.25.148-.44.359-.572.632-.13.274-.196.6-.196.979v.498c0 .379.064.704.193.976.131.271.322.48.572.626.25.145.554.217.914.217.293 0 .554-.055.785-.164.23-.11.414-.26.55-.454a1.27 1.27 0 0 0 .226-.674v-.076h-.764a.799.799 0 0 1-.118.363.7.7 0 0 1-.272.25.874.874 0 0 1-.401.087.845.845 0 0 1-.478-.132.833.833 0 0 1-.299-.392 1.699 1.699 0 0 1-.102-.627v-.495Zm8.239 2.238h-.953l-1.338-3.999h.917l.896 3.138h.038l.888-3.138h.879l-1.327 4Z"/>
</svg>
<% end %>
</div>
With that on place, when I click the the download file dialog window now opens and I can download the csv file.
Adding the select columns functionality
With that in place and working I want to take thing a step further. I neat thing now would be to be able to select the columns to display in the CSV file.
The thing I need to do to achieve that is to:
- Replace the previous download link with a dropdown that contains a form
- Replace the hardcoded fields list in export controller and use dynamic input
Replace the download link
Add a live dropdown component
For the dropdown functionality, I will add a dropdown component. In theory, the dropdown component can be reused but for now, I will just use it once.
# lib/tutorial_web/live/shared_components.ex
defmodule TutorialWeb.Live.SharedComponents do
use Phoenix.Component
alias Phoenix.LiveView.JS def dropdown(assigns) do
~H"""
<div class="relative inline-block text-left">
<button class="btn btn-secondary" phx-click={ JS.toggle(to: "##{@id}", in: {"duration-300", "opacity-0", "opacity-100"}, out: {"duration-75", "opacity-100", "opacity-0"}) } >
<%= render_slot(@toggle) %>
</button>
<div id={@id} class="absolute right-0 z-20 hidden" phx-click-away={JS.hide(to: "##{@id}", transition: {"duration-75", "opacity-100", "opacity-0"})}>
<%= render_slot(@inner_block, assigns) %>
</div>
</div>
"""
end
end
Import the new component in the LiveView file.
# lib/tutorial_web/live/customer_live/index.ex
import TutorialWeb.Live.SharedComponents
When I have imported the components file, I can implement it in the view. I want the dropdown to display a form that posts to the export controller. And the fields in the form should be checkboxes for each possible column.
<!-- lib/tutorial_web/live/customer_live/index.html.heex -->
<div class="flex justify-end mb-2">
<.dropdown>
<:toggle>
Export
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="w-6 h-6 ml-1" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M14 4.5V14a2 2 0 0 1-2 2h-1v-1h1a1 1 0 0 0 1-1V4.5h-2A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v9H2V2a2 2 0 0 1 2-2h5.5L14 4.5ZM3.517 14.841a1.13 1.13 0 0 0 .401.823c.13.108.289.192.478.252.19.061.411.091.665.091.338 0 .624-.053.859-.158.236-.105.416-.252.539-.44.125-.189.187-.408.187-.656 0-.224-.045-.41-.134-.56a1.001 1.001 0 0 0-.375-.357 2.027 2.027 0 0 0-.566-.21l-.621-.144a.97.97 0 0 1-.404-.176.37.37 0 0 1-.144-.299c0-.156.062-.284.185-.384.125-.101.296-.152.512-.152.143 0 .266.023.37.068a.624.624 0 0 1 .246.181.56.56 0 0 1 .12.258h.75a1.092 1.092 0 0 0-.2-.566 1.21 1.21 0 0 0-.5-.41 1.813 1.813 0 0 0-.78-.152c-.293 0-.551.05-.776.15-.225.099-.4.24-.527.421-.127.182-.19.395-.19.639 0 .201.04.376.122.524.082.149.2.27.352.367.152.095.332.167.539.213l.618.144c.207.049.361.113.463.193a.387.387 0 0 1 .152.326.505.505 0 0 1-.085.29.559.559 0 0 1-.255.193c-.111.047-.249.07-.413.07-.117 0-.223-.013-.32-.04a.838.838 0 0 1-.248-.115.578.578 0 0 1-.255-.384h-.765ZM.806 13.693c0-.248.034-.46.102-.633a.868.868 0 0 1 .302-.399.814.814 0 0 1 .475-.137c.15 0 .283.032.398.097a.7.7 0 0 1 .272.26.85.85 0 0 1 .12.381h.765v-.072a1.33 1.33 0 0 0-.466-.964 1.441 1.441 0 0 0-.489-.272 1.838 1.838 0 0 0-.606-.097c-.356 0-.66.074-.911.223-.25.148-.44.359-.572.632-.13.274-.196.6-.196.979v.498c0 .379.064.704.193.976.131.271.322.48.572.626.25.145.554.217.914.217.293 0 .554-.055.785-.164.23-.11.414-.26.55-.454a1.27 1.27 0 0 0 .226-.674v-.076h-.764a.799.799 0 0 1-.118.363.7.7 0 0 1-.272.25.874.874 0 0 1-.401.087.845.845 0 0 1-.478-.132.833.833 0 0 1-.299-.392 1.699 1.699 0 0 1-.102-.627v-.495Zm8.239 2.238h-.953l-1.338-3.999h.917l.896 3.138h.038l.888-3.138h.879l-1.327 4Z"/>
</svg>
</:toggle>
<ul class="mt-1 border shadow-xl menu bg-base-100 border-base-300 rounded-box w-52">
<li class="p-4">
<div class="mb-2">
<.form let={f} for={:export_field} action={Routes.export_path(@socket, :create)}> <%= for attr <- ~w(name address zip city longitude latitude)a do %>
<label class="justify-start cursor-pointer label">
<%= checkbox f, attr, checked: true, class: "checkbox-sm" %>
<%= label f, attr, class: "ml-2 label-text" %>
</label>
<% end %> <%= submit "Export", class: "btn btn-secondary w-full" %>
</.form>
</div>
</li>
</ul>
</.dropdown>
</div>
Replace the hardcoded fields
I have already made some preparations for this change in the controller. The only thing left to do here is to make the controller take dynamic inputs from the form.
Instead of the hardcoded fields:
fields = [:name, :address, :zip, :city, :phone, :longitude, :latitude]
I need to change to:
def create(conn, %{"export_field" => export_fields} = _params) do
fields =
Enum.reduce(export_fields, [], fn {field, active}, acc ->
field = String.to_existing_atom(field)
if active == "true", do: [field|acc], else: acc
end)
**NOTE** Each field that I post are either `"true"` or `"false"`. I only want to use the truthy ones.
If I test this now, I should be able to pick the columns I want and then export the customers to a CSV file.