Read more

Dynamically uploading files to Rails with jQuery File Upload

Dominik Schöler
June 14, 2016Software engineer at makandra GmbH

Say we want …

  • to create a Gallery that has a name and has_many :images, which in turn have a caption
  • to offer the user a single form to create a gallery with any number of images
  • immediate uploads with a progress bar per image
  • a snappy UI
Illustration UI/UX Design

UI/UX Design by makandra brand

We make sure that your target audience has the best possible experience with your digital product. You get:

  • Design tailored to your audience
  • Proven processes customized to your needs
  • An expert team of experienced designers
Read more Show archive.org snapshot

Enter jQuery File Upload Show archive.org snapshot . It's a mature library that can do the job frontend-wise. On the server, we'll use Carrierwave Show archive.org snapshot , because it's capable of caching images.

(FYI, here's how to do the upload all manually.)

Rails setup

Build these models:

class Gallery < ActiveRecord::Base
  has_many :images, dependent: :destroy
  accepts_nested_attributes_for :images, allow_destroy: true,
    reject_if: :all_blank # Ignore the blank template record
end

class Image < ActiveRecord::Base
  belongs_to :gallery
  mount_uploader :teh_image, YourUploaderHere # Mount Carrierwave on attribute :teh_image
end

And these controllers:

class GalleryController < ApplicationController
  # ...
  private def gallery_params
    params.require(:gallery).permit [:name, { images_attributes: [:id, :caption, :_destroy, :teh_image_cache] }]
  end
end

class UploadsController < ApplicationController
  def gallery_image
    image = params.require(:gallery).require(:add_images).first
    uploader = Image.new.teh_image
    uploader.cache! image
    
    # Return the image's cache name to the client
    render json: uploader.cache_name
  end
end

HTML

In the gallery form, we'll need a template for adding new images. I chose to add a blank record for this purpose.

# app/views/galleries/_form.html.haml

= form_for @gallery, html: { 'gallery-form': true } do |gallery_form|
  .form-group
    = gallery_form.label :name
    = gallery_form.text_field :name
    
  %label Images
  %table
    - template = gallery_form.object.images.build # Build template record
    = gallery_form.fields_for :images do |image_form|
      %tr{ class: ('hidden gallery-form--template' if image_form.object == template) }
        %td
          = image_tag image_form.object.teh_image
          
          # Used by Carrierwave to connect a record to an already uploaded file
          = image_form.hidden_field :file_cache
          # Used by Rails to distinguish new from existing records
          = image_form.hidden_field :id
          # Used by Rails to know when an image should be destroyed
          = image_form.hidden_field :_destroy
        
        %td
          = image_form.text_field :caption
        %td
          = link_to '×', '#', title: 'Remove image', class: 'gallery-form--remove'
  
  -# :add_images is no attribute of Gallery, but will be the params key when
  -# sending the image files to the server
  = gallery_form.label :add_images do
    = form.file_field :add_images, class: 'hidden', multiple: true
    Add Images
    
  = gallery_form.submit
          

Javascript

Now to the dynamic part. I built it with an Unpoly Show archive.org snapshot compiler, but the code will work without as well.

up.compiler '[gallery-form]', (element) ->
  
  imageTemplate = element.find('.gallery-form--template') or throw 'Missing template'
  fileField = element.find('input[type=file]') or throw 'Missing file input'
  uploadURL = '/uploads/gallery_image' # Where to POST the images
  submitButton = element.find('input[type=submit]') # Disabled during upload

  init = ->
    element.on 'click', '.gallery-form--remove', removeImage

    fileField.fileupload
      add: addImages # Invoked when images are added (here via the file field)
      dropzone: false
      progress: Helper.updateProgressBar # Run for each file each few ms
      start: -> submitButton.attr 'disabled', true # Run when the first upload starts
      stop: -> submitButton.removeAttr 'disabled' # Run when the last upload ends
      type: 'POST'
      url: uploadURL

  addImages = (e, data) ->
    for file in data.files
      imageRow = Helper.addImageToForm()

      # Store a reference to the progress bar in `data` so it can be retrieved
      # later in `progress` events
      data.progressBar = Helper.addProgressBar(imageRow)
      
      # Send the image *now*, and set the cache name when it's done
      data.submit().then Helper.setCacheName(imageRow)
      
      # Render an image preview
      Helper.preview file, imageRow.find('img')
      
      # Cancel upload when removing an image
      imageRow.find('.gallery-form--remove').on 'click', data.abort

  removeImage = (e) ->
    e.preventDefault()
    imageRow = $(event.target).closest('tr')
    destroyField = imageRow.find('input[id$=_destroy]')

    imageRow.hide() # Hide the image from the form
    destroyField.val('true') # Tell Rails to destroy the image
    submitButton.removeAttr 'disabled' # Prevent unsubmittable form (dirty fix)

  Helper =

    # Clone the template row
    # @return: The newly created image row
    addImageToForm: ->
      row = imageTemplate.clone()
      Helper.increaseTemplateIndices()

      row.removeClass 'gallery-form--template'
      row.insertBefore imageTemplate
  
    # Prepare the template to be cloned for the next image
    increaseTemplateIndices: ->
      # E.g. ("[1]", "1") -> "[2]"
      increase = (match, $1) ->
        match.replace $1, parseInt($1) + 1

      imageTemplate.find('input').each ->
        $input = $(this)

        $input.attr
          name: $input.attr('name').replace(/\[(\d+)\]/, increase)
          id: $input.attr('id').replace(/_(\d+)_/, increase)

    # @return A promise callback handler
    setCacheName: (template) ->
      # Store the image cache name to the form, so the already uploaded
      # image file gets stored with the image record on save
      return (response) ->
        cacheField = template.find('input[type=hidden][id$=file_cache]')
        cacheField.val response

    preview: (file, img) ->
      blobURL = URL.createObjectURL(file)
      img.attr 'src', blobURL

    # @return: The progress bar element
    addProgressBar: (element) ->
      progressBar = $('<div>').addClass('image--progress').css 'width', '100%'
      progressBar.appendTo element.find('.image')
      
    # This progress bar *shrinks* from full width to the right
    updateProgressBar: (event, data) ->
      progress = data.loaded / data.total * 100
      remainingPercent = 100 - Math.round(progress) + '%'
      
      data.progressBar.css 'width', remainingPercent

  init()
Dominik Schöler
June 14, 2016Software engineer at makandra GmbH
Posted by Dominik Schöler to makandra dev (2016-06-14 07:57)