jarvism

jarvism

Hello!
I’m trying to use CSV to give LiveView users a downloadable report. I am getting confused by all the possibly outdated(?) info I’m finding here and elsewhere. I’m too new to Elixir and Phoenix to understand.
So far, I have a LiveView where the user uploads a CSV. I then process that and add some data, ending up with a list of maps like:

[
  %{
    "Carrier" => "T-Mobile",
    "Country" => "USA",
    "Email" => "",
    "FirstName" => "Test",
    "LastName" => "",
    "MobileNo" => "12345678910",
    "Status" => "Active",
    "TemplateID" => "123",
    "UniqueID" => "1234567"
  },
...
]

I have a controller for the download:

defmodule JHWeb.ExportController do
  use JHWeb, :controller

  def create(conn, %{"download_data" => data}) do
    file = data
    |> CSV.encode()
    |> Enum.to_list()
    |> to_string()

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

which is obviously cobbled together from code examples I could find. I have also tried:

file = File.open!("download.csv", [:write, :utf8])
data |> CSV.encode |> Enum.each(&IO.write(file, &1))

per the CSV documentation, but it also fails.

Finally, I can’t figure out what I’m supposed to do in the LiveView. I have a button:

<button :if={@download_data} phx-click="download_csv"
          phx-disable-with="Processing..."
 >Download CSV</button>

with an event handler:

def handle_event("download_csv", _params, socket) do

    # Get Base URL
    uri = socket.assigns.uri
    base_url = uri.scheme <> "://" <> uri.authority

    export_resp = Req.post!("#{base_url}/api/v1/export", json: %{download_data: socket.assigns.download_data})
 
    {:noreply, socket}
  end

I get a 500 error:

(Protocol.UndefinedError) protocol String.Chars not implemented for {“Carrier”, “T-Mobile”} of type Tuple

I don’t have enough of a mental model or frame of reference to know what I’m doing wrong, what the correct way is to do downloads from a LiveView, or how to proceed in general. Help is greatly appreciated. :slight_smile:

Showing Posts 1 to 10

andreaseriksson

andreaseriksson

You dont need to create the csv file but what I think you do wrong is pass in a map. I think it needs to be a list of lists:

data
|> Enum.map(&Map.value/1)
|> CSV.encode()
...
LostKobrakai

LostKobrakai

You cannot trigger a download through websockets / LV. You’d need to redirect the user to the url where the file is served. There’s also hacks e.g. using iframes to trigger the download without breaking the running LV page.

mayel

mayel

Indeed. You typically just want to link to a controller for that (you can use something like <a download href="/csv_export?download_data=xyz" to avoid the user navigating away from your LiveView or opening a new tab).

waseigo

waseigo

Hello – I think I’m facing something similar.

I have a LiveView with a string in one of its assigns fields, and a button within a form component.

When the button is clicked, I want to take the string and serve it to the browser for download as a text file with a name I programmatically create.

If possible, I want to avoid having to create the text file locally to then send it.

What is the correct way of doing this in 2024 with Phoenix from within a LiveView?

If (as I understand it) this requires a “helper deadview” to redirect to (I assume for a GET request), how can I make this robust w.r.t. the type and amount of data?

Do I have to use Req to make a GET request to the deadview from within the event handler of the LiveView?

LostKobrakai

LostKobrakai

Going into a little more detail to what I wrote above.

There’s “two way” to make a browser suggest to its user that something can be downloaded.

  • It navigates (as in a new http request, not some js based “navigation”) to a response, where:
    • the response has a content type the browser cannot render
    • there is a http header on the response telling the browser that the result is meant to be downloaded
    • the link, which caused the navigation had a download attribute
  • JavaScript, which kinda mimics the above on one or more levels, where you either make the browser navigate to an url or to a “file” js has “in memory”.

So you either need to serve your file on a route, which returns the file on http requests (controller, not LV) or you send the file via websockets to your JS and do one of those “mimic navigation” options.

If you use the controller option you’d basically redirect the user to the route where your controller is accessible, like you’d redirect them to any other url.

Bonus: When the browser is navigated away from a LV it usually stops JS (because it navigates away). If the target turns out to be a download it doesn’t render anything new though, leaving the prev. page visible, but not running. You can work around this by either using target="_blank" on links to open in a new tab or use an iframe to navigate to the downloads route without affecting the page containing the iframe.

waseigo

waseigo

Thank you for the overview.

I created a Phoenix Controller that handles POST requests at /api/download with a function that has this:

    conn
    |> put_resp_content_type("image/svg+xml; charset=utf-8")
    |> put_resp_header(
          "content-disposition", 
          "attachment; filename=#{filename}")
    |> send_resp(200, content)

In the LiveView I have this:

<form action="/api/download" method="post">
  <input type="hidden" name="content_to_download" value={@content}>
  <button type="submit">Download</button>
</form>

Clicking on this button indeed downloads the file, but as you said leaves the LiveView inoperable. Since I need to POST @content to the download endpoint, I cannot use a hyperlink with target="_blank", correct?

If so, then is using an iframe the only JS-less alternative?

And if yes: I add this above the form:

<iframe 
  id="download-iframe" 
  name="download-iframe" 
  style="display:none;"></iframe>

Using target="_blank" on the form disrupts the LV but non-irrevocably; when I change any input on another form that the LV contains (which determines the value of @content), I get the “Something went wrong” toast and the LV reconnects, but it’s a confusing UX.

Using target="download-iframe" on the form disrupts the LV irrevocably. I have to reload the page to get it working again.

Using formtarget="download-iframe" on the submit button does the same.
Using formtarget="_blank" on the submit button does the same.

What am I missing?

LostKobrakai

LostKobrakai

I don’t think using a form to push the data around is particularly great architecture given the fact that you’re sending the data to the client for it to submit it back to the server and for the server to send it back to the client again. I’d probably look for a different approach.

I’d argue that the mismatch here is the expectation of a general solution for a problem, which doesn’t have a general solution. When the data is related to your websocket connection, but a websocket connection cannot start download then it’s a question of more or less workarounds to still trigger the desired behaviour and all of those workarounds come with caveats, which means making tradeoffs.

If you’re already pushing the file’s content to the client I’d use JS on the client to download to trigger the download directly from that data on the client.

But sending downloadable data to the client that way is not great for anything large, in binary format and what would potentially need to deal with disconnects, retries, chunking, caching, … Those might require more elaborate setups and approaches.

waseigo

waseigo

You are right, it’s bad design… In this case it’s palatable, because the data sent is tiny (max. 1 kB), but it’s still a bad design (and it doesn’t work, as it makes the LV inoperable or forces it to break and reload with a “Something went wrong” toast.)

Would a solution using phx-hook work? I added this to app.js:

let Hooks = {}
Hooks.DownloadHook = {
  mounted() {
    this.el.addEventListener("click", () => {
      const textContent = this.el.dataset.content;
      const blob = new Blob([textContent], { type: "image/svg+xml;charset=utf-8" });
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = "download.svg";
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      URL.revokeObjectURL(url);
    });
  }
};

…but I get the following in the browser console:

unknown hook found for "DownloadHook" 
<button id=​"download-button" 
phx-hook=​"DownloadHook" 
data-content=​"<svg>...</​svg>">
​…​</button>​

I found various conversations about hooks not being mounted etc., but I am at a loss at what the solution to this would be.

The thing is, I need a LiveView to have a handle_event that live-updates the content of the SVG data to be downloaded based on form selections.

And once the SVG data is sent back to the client as part of the assigns (@content) and set as the value of data-content in the button element, then I want to use JS to download that as a file.

(I don’t really want to use JS, since I try to avoid JS as I have no clue about it, but the alternative solution with a Phoenix Controller is bad design and doesn’t really work, either.)

Is there really no simple pattern of downloading the content of an assigns variable as a file from within a LiveView…?

waseigo

waseigo

Everyone, I scoured the web and cobbled together a solution. I’ll do a writeup on my blog soon. For now, to summarize:

In the LiveView’s .ex file:

  @impl true
  def handle_event("download", form, socket) do
    svg = form_to_svg(form)
    fp = filename_args(form)
    filename = "obidenticon_#{fp}.svg"
    filename_args(form)

    Process.send_after(self(), :clear_flash, 2000)

    {:noreply,
     socket
     |> put_flash(:info, "Downloading #{filename}")
     |> push_event("download-file", %{
       svg: svg,
       filename: filename
     })}
  end

In the LiveView’s HEEx template:

  <.form for={@form} phx-change="validate" phx-submit="download">
    ...
    <button type="submit">Download SVG</button>
  </.form>

  <script>
    window.addEventListener("phx:download-file", (event) => {
    console.log("Event received:", event);
    var element = document.createElement('a');
    const encodedSvg = encodeURIComponent(event.detail.svg);
    const hrefValue = 'data:image/svg+xml;charset=utf-8,' + encodedSvg;
       
    element.setAttribute('href', hrefValue);
    element.setAttribute('download', event.detail.filename);

    element.style.display = 'none';
    document.body.appendChild(element);
    element.click();
    document.body.removeChild(element);
      });
  </script>

It’s up and running at https://obidenticon.overbring.com/

steffend

steffend

Phoenix Core Team

Did you actuall add the Hooks to the liveSocket?

...
let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks})

A hook is the way to go. You can also add a hook to any node in the DOM and use this.handleEvent("phx:download-file", (event) => {}) in the hook to handle the pushEvent from the server.

The problem with including a script tag in the template is that it only works if the page included in the initial dead render. If you have live navigation and navigate to this page from another one, the script won’t be executed.

Where Next? Top

Trending in Questions Top

RSP87
I’m working on a project that simulates the bumbl example in the programming phoenix book. It acts almost like an email client. We have a...
New
kpanic
Hi everyone, I am toying with the idea of building a “match maker” for giving personal help to people that wants to start coding. I sta...
New
nseaSeb
Hello, I know there is an approach for handling lists that allows for optimized traversal, but I can’t recall the specific method (somet...
New
brecabral
Documentation While reading the Scoped Routes section, I noticed that the documentation currently refers to a problem without explainin...
New
velrest
So my question is quite simple and i have found no conclusive answer on forum, google or AI. Should we use :erlang.float for Integer to ...
New
asweet-confluent
I recently noticed that Elixir’s Logger defaults its primary log level to :debug when no :logger, :level application configuration is pre...
New
apz
I’m new to elixir and just tried to install the elixirLS extension for VScode(ium) and it is throwing some errors that I would like help ...
New

Other Trending Topics Top

GenericJam
Edit: 2026 May 15 - This post is archived. Mob is alive!! Main docs: mob v0.7.11 — Documentation A bit of explanation for the slightly c...
New
JesseHerrick
Hey, I’m Jesse and I’m the main contributor behind Dexter, a full-featured, lightning-fast Elixir LSP optimized for large codebases. It s...
New
mudasobwa
I am happy to introduce the very α version of the new programming language compiled to BEAM. Welcome Cure. It has literally three kille...
New
marciok
Hi there! We created Gust: A task orchestrator inspired by Airflow. For those who have never heard about Aiflow, it’s a Python-based wor...
New
mhanberg
Hi everyone! The first release candidate for the Expert language server project is now available! We’ve published a press release detai...
New
jimsynz
Beam Bots (or just BB for short) is a framework for building fault-tolerant robotics applications in Elixir using familiar OTP patterns. ...
New

We're in Beta

About us Mission Statement

Options

Thread Display Mode




Thread Preview

Skip Thread Previews