Tutorial

CSV Export with Phoenix and LiveView

formscomponentCSVliveview

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

  1. Add CSV library to mix file
  2. Create a controller for the export
  3. Add export CSV route to the routes file
  4. 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\"".

# lib/tutorial_web/controllers/export_controller.ex
defmodule TutorialWeb.ExportController do
  use TutorialWeb, :controller

  alias Tutorial.Customers

  def create(conn, _params) do
    fields = [:name, :address, :zip, :city, :phone, :longitude, :latitude]
    csv_data = csv_content(Customers.list_customers(), fields)

    conn
    |> put_resp_content_type("text/csv")
    |> put_resp_header("content-disposition", "attachment; filename=\"export.csv\"")
    |> put_root_layout(false)
    |> send_resp(200, csv_data)
  end

  defp csv_content(records, fields) do
    records
    |> Enum.map(fn record ->
      record
      |> Map.from_struct()
      |> Map.take([]) # gives an empty map
      |> Map.merge( Map.take(record, fields) )
      |> Map.values()
    end)
    |> CSV.encode()
    |> Enum.to_list()
    |> to_string()
  end
end

NOTE that I have hard coded the fields for now. Further down, I will replace the hard coded fields to something that comes from the params and is user configured.

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/router.ex
scope "/", TutorialWeb do
  pipe_through :browser

  # other routes

  post "/export", ExportController, :create
end

Add the Export button and test the solution

To test this, I can just add a normal link to the export page and make it have use post instead of get by adding method: :post option.

<!-- 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:

  1. Replace the previous download link with a dropdown that contains a form
  2. 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.


Related Tutorials

Published 26 Dec - 2020
Updated 01 Oct - 2021

Bootstrap 5 and Phoenix LiveView

This tutorial is updated for Phoenix 1.6 with Esbuild Even though a large part of the Phoenix community seem to embrace Tailwind, there are still a..

Published 13 May - 2021

How to create a custom select with Alpine JS and Phoenix LiveView

In this tutorial, I want to go through how to build a custom select field that is used in Tailwind UI. And I will build it with Alpine JS and Phoeni..