Multiple images upload and management in Rails with Dropzone and Carrierwave


The issue

Suppose we have 2 model Product and Image. 1 product may have many images. Certainly Image has product_id field.

The problems I want to solve on product form:

  • Easy image uploading: prefer AJAX over uploading all images when submit form. It looks more convenient to end users and save time when submit.
  • Since I use AJAX for image uploading there is an issue when we add new product: product does not have id yet so we could not specify product_id for uploaded images.
  • Also we need to be able to manage images of a product: in product edit form I need to display existed images as well the ability to remove them or add new images

Initially this sounds like lots of custom work. But in the end the solution is much more simpler and elegant than I thought.

The Solution

These are the gems that we are going to use

gem 'dropzonejs-rails', '~> 0.7.4'
gem 'carrierwave'
gem 'mini_magick'

The reason I use ~> 0.7.4 for dropzonejs-rails is because I need to use Dropzone version 4. I couldn't make mock file thumbnails work for Dropzone 5.

Add this line to application.js

//= require dropzone

And this line to application.css

*= require dropzone/dropzone

In the backend I would use Carrierwave to handle image upload. Nothing fancy, just standard use.

We need an images controller to handle image upload and remove:

class ImagesController < ApplicationController

  def create
    image = Image.create! product_id: params[:product_id],
      image: params[:file].values.first
    render json: { id: image.id, url: image.image_url }
  end

  def destroy
    image = Image.find params[:id]
    image.destroy!
    head :no_content
  end

end

On the frontend I used Dropzone a popular JS library for image uploading with lots of cool options and flexible configurations

HTML code (Slim)

= hidden_field_tag 'image_ids'
.js-dropzone.dropzone style="height: 200px;"

JS code

$(document).ready(function() {
    // Dropzone config
    var productId = #{@product.id}
    params = {
      'authenticity_token': $('meta[name="csrf-token"]').attr('content')
    }
    if (productId) {
      params.product_id = productId
    }

    Dropzone.autoDiscover = false;
    var myDropzone = new Dropzone('div.js-dropzone', {
      url: '/product_images',
      autoProcessQueue: true,
      uploadMultiple: true,
      addRemoveLinks:true,
      dictRemoveFileConfirmation: 'Are you sure?',
      params: params
    });

    myDropzone.on('removedfile', function (file) {
      var id = file.id
      if (id === undefined) {
        id = JSON.parse(file.xhr.response).id;
      }
      $.ajax({
        url: '/product_images/' + id,
        method: 'DELETE',
        data: {'authenticity_token': $('meta[name="csrf-token"]').attr('content')}
      }).done(function() {
      });
    });

    var currentImages = #{json_product_images(@product).html_safe};
    currentImages.forEach(function(image) {
      myDropzone.emit('addedfile', image);
      myDropzone.createThumbnailFromUrl(image, image.url, function() {
        myDropzone.emit('complete', image);
      });
      myDropzone.files.push(image);
    });

    $('form').submit(function(e) {
      e.preventDefault();
      var images = myDropzone.files;
      var imageIds = '';
      images.forEach(function(image) {
        var _id = image.id
        if (_id === undefined) {
          _id = JSON.parse(image.xhr.response).id;
        }
        imageIds += ' '
        imageIds += _id
      });
      $('#image_ids').val(imageIds);
      $(this).off('submit').submit();
    });
});

And a little helper function to provide a list of mock files (existed product images) to display in Dropzone

module ProductHelper
  def json_product_images product
    product.images.map do |image|
      {
        accepted: true,
        id: image.id,
        name: image.image.url.rpartition('/').last,
        url: image.image.url,
        size: image.image.size
      }
    end.to_json
  end
end

Some explaination for the code above:

  • We need to extract authenticity_token from meta tag and include it in Dropzone request so the server does not raise ActionController::InvalidAuthenticityToken error
  • If product is persisted we should include product_id in Dropzone request and create image with such product_id
  • When edit a product, we expect to have existing product images displayed, therefore we need to serialize current product images into a JSON format and added to Dropzone as 'mock files'
  • We need to modify removedfile event so that the remove action also remove the image from remote server. If the file is mock file we could identify it with file.id, but if it is recently uploaded one we need to use JSON.parse(file.xhr.response).id instead
  • Because when we on a new product form, the product does not have id yet, so uploaded images have no product_id value, how can we connect product to these images when submit the form? I added a hidden field = hidden_field_tag 'image_ids' and modify the submit event so that it would update image_ids with string of image ids we have in Dropzone, and later parse them in controller like follow permitted_params[:image_ids] = params[:image_ids].to_s.split(' ')

Please note that the JS code above is not well written. But the whole thing work like a charm with minimal effort.