Implementing upload progress and remove button with ActiveStorage DirectUpload

Posted . Visible to the public.

DirectUpload allows you to upload files to your file storage without having to wait for the form to submit. It creates AJAX requests to persist the file within your form and wraps them in a little API. This card will show you how to use it in order to create in-place file uploads with progress and a remove button.

This is basic functionality, you may add additional elements, styles and logic to make this look fancy, but the core functionality is the same. I created a file upload that looks like this:

Image
Image

We assume that we have (for example) this HAML:

= form_for @user do |form|
  %div(file-input)
    # This field holds your existing blob's signed ID to make sure the file attachment is not lost after form roundtrip in case no new file is attached.
    = form.hidden_field_tag(:invoice, @user.invoice.signed_id)
    # This is the actual file input that we want to hide via CSS and fill using JS
    = form.file_field_tag(:invoice, html: { class: 'visually-hidden' })
    
    # You may also use CSS classes, of course
    %span(file-input-name)
    %span(file-input-progress)
    %button(file-input-add)
      Add file
    %button(file-input-clear)
      Clear file

Now we can the define an Unpoly compiler (or any other UJS mechanism) to look for a file-input attribute and run code on it:

// This class needs to be imported because we need to patch the default controller. It is defined below.
import CustomDirectUploadController from '../../active_storage/custom_direct_upload_controller'

up.compiler('[file-input]', (element) => {

  const input = element.querySelector('input[type="file"]')
  const storedInput = element.querySelector('input[type="hidden"]')
  const fileNameSpan = element.querySelector('[file-input-name]')
  const fileProgressSpan = element.querySelector('[file-input-progress]')
  const addButton = element.querySelector('[file-input-add]')
  const clearButton = element.querySelector('[file-input-clear]')

  const clearFile = function(evt) {
    evt.preventDefault()

    input.value = ''
    storedInput.value = ''
    updateClearButton()
  }

  const pickFile = function(evt) {
    evt.preventDefault()

    input.click()
  }

  // Handler as soon as the user selected a file through the browser's dialog
  const onFilePicked = function() {
    // Create a DirectUploadController with the input and file and run it.
    // This will now create a blob record via AJAX and then upload the file via
    // a separate AJAX request and expose data over events used below.
    const controller = new CustomDirectUploadController(input, input.files[0])
    controller.start(storedInput)
  }
  
  const hasFile = function() {
    return (storedInput?.value !== undefined && storedInput?.value !== '') || input.value !== ''
  }

  const updateClearButton = function() {
    clearButton.classList.toggle('visually-hidden', !hasFile()) // Make sure to have styles for hiding
  }
  
  const finishUpload = function() {
    updateClearButton()
    // Do additional stuff here, like creating a file card or updating an image preview using the FileReader API, for example
  }
  
  const renderProgress = function({ detail }) {
    const { progress } = detail
    fileProgressSpan.innerText = `${progress}%`
  }

  const showError = function(evt) {
    evt.preventDefault() // The default will render an alert

    const { error } = evt.detail
    Flash.show(error) // or whatever you want to do
  }

  up.on(addButton, 'click', pickFile)
  up.on(clearButton, 'click', clearFile)
  up.on(input, 'change', onFilePicked)
  // See https://guides.rubyonrails.org/active_storage_overview.html#direct-upload-javascript-events
  up.on(input, 'direct-upload:progress', renderProgress)
  up.on(input, 'direct-upload:error', showError)
  up.on(input, 'direct-upload:end', finishUpload)
})

There is a small fix that we need to apply, so we will create our custom controller class and inherit from ActiveStorage.DirectUploadsController:

import * as ActiveStorage from '@rails/activestorage'

class CustomDirectUploadController extends ActiveStorage.DirectUploadController {
  
  // By default, this adds a hidden input with the freshly created blob's signed ID for every upload,
  // which should replace existing files. However, if you remove the file again and save the form,
  // the attachment may persist due to multiple (previous) hidden inputs that will orphan in your form.
  // This happens because DirectUpload doesn't recognize our initial hidden input for storing the blob's
  // signed ID, so we patch the controller to accept it as argument and use it instead.
  start(storedInput, callback) {
    this.dispatch("start")

    this.directUpload.create((error, attributes) => {
      if (error) {
        this.dispatchError(error)
      } else {
        storedInput.value = attributes.signed_id
      }

      this.dispatch("end")
      callback(error)
    })
  }
}

export default CustomDirectUploadController
Dominic Beger
Last edit
Dominic Beger
License
Source code in this card is licensed under the MIT License.
Posted by Dominic Beger to makandra dev (2025-03-28 14:43)