HermannHH
HermannHH

Reputation: 1772

Rails file download using send_data with follow-up action

I am working on a code base where I need to allow the user to download a PDF document that already resides on AWS S3. I have implemented a download concern that was used for a previous feature.

For this feature, I need to update the UI (A progress stepper) after the user has completed the file download. I was initially thinking that this would be as simple as:

  1. User clicks download
  2. API call is made where the file is downloaded using send_data. In this API call, I'd also update the Foo model to change state to indicate that the user has downloaded the file;
  3. Execute a redirect_to request.referer to reload the data. The changed state in Foo will be responsible for showing the updated progress in the UI;

I was mistakenly thinking that this was going to be simple. The reasons for complexity:

  1. send_data is already rendering data, so I can't refresh the page using redirect_to as this triggers a multiple render error;
  2. send_data does not work with the remote: true option, so requesting data via an AJAX link and updating the ERB template is out;
  3. I can write everything into a JS on click function, but this seems like a bit of a hack. I probably need to retrieve the file directly from AWS and skip my api? I'm suspecting that I might run into CORS issues as I don't have control over the server.

This is what my rails download method looks like currently:

def download
  attachment = Attachment.find_by_id(params[:attachment_id])
  content = send_data(
    attachment.file.read,
    filename: "#{attachment.title}.#{attachment.file.file.extension}",
    type: attachment.content_type,
    disposition: "attachment",
  )
end

Th js code that basically worked looks like this where all the relevant paths & filenames are passed on to the JS via data-attributes:

$(document).on("click", "#download", function(e){
  e.preventDefault();
  const data = $('#temp-information').data();
  var req = new XMLHttpRequest();
  req.open("GET", data.path, true);
  req.responseType = "blob";
  const filename = data.title;
  req.onload = function (event) {
    var blob = req.response;
    console.log(blob.size);
    var link=document.createElement('a');
    link.href=window.URL.createObjectURL(blob);
    link.download= filename;
    document.body.appendChild(link);
    link.click();
  };
  if (typeof window.navigator.msSaveBlob !== 'undefined') {
    // Fix to work in IE11
    window.navigator.msSaveBlob(blob, filename);
  } else {
    req.send();
  }
});

What is the most effective & rails'y way of handling a file download & updating the UI after the download has been completed?

Upvotes: 4

Views: 3465

Answers (1)

Andrew
Andrew

Reputation: 43113

It's not 100% clear what you're trying to accomplish. If you're trying to let the user see download progress, I'm not sure that you really need to do anything except send_data, and most browsers will then begin downloading the file, including showing a progress bar.

Since it seems you want to do something after the file download is complete, that's quite a bit trickier. There's nothing Rails-specific about the problem, and the approach you have used looks pretty reasonable to me.

On this SO thread you'll find a lengthy discussion of this problem and various ways that people have tried to solve it. In general the solutions follow the same basic structure, which is to simply poll the server.

In your Rails app you could implement that roughly as follows. Suppose you added a field status to your attachment model...

def download
  attachment = Attachment.find_by_id(params[:attachment_id])
  attachment.update(status: "downloading")
  send_data(
    attachment.file.read,
    filename: "#{attachment.title}.#{attachment.file.file.extension}",
    type: attachment.content_type,
    disposition: "attachment",
  )
  attachment.update(status: "complete")
end

Then you can add an endpoint that returns the status of a file. Thus when the user starts to download the file you begin to poll that endpoint.

def attachment_status
  attachment = Attachment.find_by_id(params[:attachment_id])
  respond_to do |format|
    format.json do
      {status: attachment.status}
    end
  end
end

Then in Javascript, for example using HttpPromise:

var http = new HttpPromise;
function poll(doneFn) {
  http.get("/status.json") // you will need to set your actual status endpoint path here
      .success(function(data,xhr){
        if (data.status == "complete") {
          doneFn();
        }
      });
};
function downloadFinished(){
  // ... do whatever you want on finish here ...
};
setInterval(function(){ poll(downloadFinished) }, 5000);

It's not the most beautiful thing in the world, but it should get the job done.

Good luck!

Upvotes: 1

Related Questions