Search code examples
ruby-on-railsruby-on-rails-7hotwire-railsturbo

How to show percent done of a file upload with turbo_stream and no custom js?


I have a form with:

<%= form_with(model: doc, url: my_docs_path(@application, doc), id: "#{dom_id(doc)}_form", html: {multipart: true}) do |form| %>
<%= form.file_field :doc %>
<%= form.submit 'Upload Doc' %>
<% end %>

which hits my DocsController#create just fine and inside the respond_to I have a format.turbo_stream that is making create.turbo_stream.erb work great. A new document records appears on the screen after upload without any custom javascript and without a full page refresh.

But for a large file upload the user gets the impression the page is just "stuck" during upload. It's missing logic like this:

<form autocomplete="off" enctype="multipart/form-data">
<br/>
<input type="file" name="file" id="f1" accept="video/*" onchange="uploadFile('/file/{{.name}}')"/>
<span id="p">---</span>
<br/>
</form>

<script type="text/javascript">
window.uploadFile = function(url){
  var formData = new FormData();

  var fileInputElement = document.getElementById("f1");
  formData.append("file", fileInputElement.files[0]);

  var xhr = new XMLHttpRequest();
  xhr.open("POST", url);
  xhr.upload.onprogress = function (e) {
    if (e.lengthComputable) {
      document.getElementById("p").innerHTML = ""+((e.loaded/e.total)*100);
    }
  }
  xhr.upload.onloadstart = function (e) {
    document.getElementById("p").innerHTML = "0";
  }
  xhr.upload.onloadend = function (e) {
    document.getElementById("p").innerHTML = ""+e.loaded;
    document.location.href = '/';
  }
  xhr.send(formData);
}
</script>

Without using this javascript, is there a way in rails7 to make this xhr.upload.onprogress event set a percent done to the user?


Solution

  • Honestly, I didn't expect this to work. The idea is to use Turbo::Broadcastable and broadcast_update_to method:

    def broadcast_update_to(*streamables, **rendering)
      Turbo::StreamsChannel.broadcast_update_to(*streamables, target: self, **broadcast_rendering_with_defaults(rendering))
    end
    

    https://github.com/hotwired/turbo-rails/blob/v1.3.2/app/models/concerns/turbo/broadcastable.rb#L137

    Except that we'll use Turbo::StreamsChannel class directly.

    Just one caveat, I don't know a good way or place to hook it all up. This was me just going up the rails stack until it looked like the place, which is Rack::Multipart::Parser::BoundedIO. There is a read method that receives the incoming bytes:

    # $(bundle show rack)/lib/rack/multipart/parser.rb
    
    def read(size, outbuf = nil)
      return if @cursor >= @content_length
    
      left = @content_length - @cursor
    
      str = if left < size
              @io.read left, outbuf
            else
              @io.read size, outbuf
            end
    
      if str
        @cursor += str.bytesize
    
        #
        # NOTE: looks like @cursor tracks the total bytes received, so if we
        #       send it back in a turbo_stream as the file is uploading it
        #       should update on the page.
        Turbo::StreamsChannel.broadcast_update_to(
          "upload_channel", target: "progress", html: @cursor
        )
        # it uploads crazy fast locally so it fly by. i had sleep(0.5) here.
        # and you have to restart the server when you make changes here.
        #
    
      else
        # Raise an error for mismatching content-length and actual contents
        raise EOFError, "bad content body"
      end
    
      str
    end
    

    https://github.com/rack/rack/blob/v3.0.2/lib/rack/multipart/parser.rb#L58

    On the form page we need to subscribe to upload_channel and have a #progress target for turbo stream to update:

    # _form.html.erb
    
    <%= turbo_stream_from "upload_channel" %>
    # NOTE: you should see in the logs:
    #       Turbo::StreamsChannel is transmitting the subscription confirmation
    #       Turbo::StreamsChannel is streaming from upload_channel
    
    <%= tag.div id: "progress" %>
    
    # TODO: make a form here, hit upload, see updates inside the #progress.