Dynamically uploading files to Rails with jQuery File Upload

Posted . Visible to the public.

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

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()
Profile picture of Dominik Schöler
Dominik Schöler
Last edit
Tobias Kraze
License
Source code in this card is licensed under the MIT License.
Posted by Dominik Schöler to makandra dev (2016-06-14 05:57)