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 specifyproduct_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 raiseActionController::InvalidAuthenticityToken
error - If product is persisted we should include
product_id
in Dropzone request and create image with suchproduct_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 withfile.id
, but if it is recently uploaded one we need to useJSON.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 noproduct_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 updateimage_ids
with string of image ids we have in Dropzone, and later parse them in controller like followpermitted_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.