Paperclip makes it very easy to manage file attachments in Rails, but at the same time it isn't a one-fits-all solution. There are certain capabilities that Paperclip doesn't support, and one such example is re-rendering an image attachment which fails to save due to validation errors. The maintainers of the library have stated that such functionality is outside the purpose of the library, so we took on the path of providing a concise solution to the problem we were facing.
During brainstorming, we considered the following approaches:
We did not have the available resources to replace Paperclip with Carrierwave, or any other similar library, given that we have a large codebase, and Paperclip is heavily used across different layers of our application. Replacing would have meant an extensive amount of code rewrites and refactoring which could have consequently lead to bugs and unexpected edge-case faults and fixes. We also didn't find an imminent need to replace it, because it had so far performed its capabilities well and without much hassle.
We managed to find examples (paperclip-keeponvalidation) of other developers monkey-patching core classes of Paperclip and ActiveRecord to make them persist an attachment if its 'hosting' model record fails validation. We at Wellbe prefer to avoid monkey-patches at all costs; one strong argument against the practice is that you'd be fiddling with core functionality and your changes may have unintended consequences in other parts of your application, and even with rigorous and extensive tests and Quality Assurance processes in place, you may not catch it before it's out in the wild.
It seemed like a good idea at first to not allow the form to be submitted before entered data was valid but that, however, is work-intensive depending on the extent and complexity of your validation logic. It goes without saying that there's validation expectations enforced on the image file itself as well. Recreating all this logic on the client side for all models that have attachments would have proven cumbersome and still would not have solved the core issue - we wanted to re-render the image if validation of other attributes failed. Client side validation simply avoided it.
The solution was simple - in order to fill the gap we simply needed to build what was missing. As I mentioned before, the image files are also validated as, for example, we don't allow files larger than 5 MB, only image MIME types are allowed, etc. Having had prior experience with Dropzone.js, it has many goodies to offer in a lightweight library: it will perform all of the aforementioned file validations and won't allow the files to be uploaded, it offers both click-to-select and drag-and-drop functionality, it submits the file to a separate endpoint, it's easy to configure, it exposes a clean API, and, lastly, it's easy to use.
We use bower-rails so adding dropzone was trivial:
# Bowerfile
asset 'dropzone', '~> 4.3.0'
# app/assets/javascripts/application.js
//= require dropzone/dist/dropzone
# app/assets/stylesheets/application.scss
//= require dropzone/dist/dropzone
//= require dropzone/dist/basic
The next piece of the puzzle was to separate the initial image upload into its own dedicated model which would only contain the attachment. We decided to call it TempImage
:
# app/models/temp_image.rb
class TempImage < ActiveRecord::Base
has_attached_file :image
validates_attachment_content_type :image, content_type: /\Aimage\/.*\Z/
# Other validation we wanted to perform
end
Next up we created the controller that will process new temporary images sent by Dropzone:
# config/routes.rb
Wellbe::Application.routes.draw do
# ...
resources :temp_images, only: :create
end
# app/controllers/temp_images_controller.rb
class TempImagesController < ApplicationController
def create
@temp_image = TempImage.new(temp_image_params)
respond_to do |format|
if @temp_image.save
format.json{ render json: @temp_image, status: :created }
else
format.json do
render json: { errors: @temp_image.errors.full_messages }, status: :unprocessable_entity
end
end
end
end
private
def temp_image_params
params.require(:temp_image).permit(:image)
end
end
For the purposes of this article, the model that these temporary images end up in is called User
. The purpose of a temp image is to exist as its own entity but only for a short amount of time, after which it will be moved to a User record.
# app/models/user.rb
class User < ActiveRecord::Base
attr_accessor :temp_avatar_id
# The avatar definition
has_attached_file :avatar
validates_attachment_content_type :avatar, content_type: /\Aimage\/.*\Z/
# Callbacks to manage an associated temp_image
with_options if: ->(user){ user.temp_avatar_id.present? } do
before_save :move_temp_avatar_to_avatar
after_commit :destroy_temp_avatar, on: [:create, :update]
end
# Reference to a temp avatar
def temp_avatar
@temp_avatar ||= TempImage.find(temp_avatar_id) if temp_avatar_id.present?
end
private
# Assign the the temp avatar image to the avatar attribute
def move_temp_avatar_to_avatar
self.avatar = temp_avatar.image
end
# Discard
def destroy_temp_avatar
temp_avatar.destroy
self.temp_avatar_id = nil
end
end
The above code eventually ended up in a concern because we use this functionality in other models, but in essence it holds onto the id of a temp_image in a temp_avatar_id
attribute. The temp_avatar_id
attribute originates from Dropzone's save operation and is set in UsersController
by form submission, which we will examine below. Before a record is saved, the temp avatar's image attribute is assigned to the avatar attribute. This simply tells Paperclip to copy the attachment of one model to another. After committing an INSERT
or UPDATE
operation, a temp_image that the User instance is keeping reference to is destroyed along with the actual attachment it holds.
At this point we know that we'll need to permit a temp_avatar_id
parameter in our users controller
# app/controllers/users_controller.rb
class UsersController < ApplicationController
# ...
def user_params
param.require(:user).permit(:temp_avatar_id, ...)
end
end
Next off we add a hidden field in our users form that holds the id of the TempImage we create using dropzone:
# app/views/users/_form.html.erb
<%= form_for @user, multipart: true, html: { class: "ui form container" } do |f| %>
<%# ... %>
<div class="image-upload temp-image" data-url="<%= temp_images_path %>">
<div class="field">
<div class="ui small image" data-temp-image-preview="true">
<% if f.object.temp_avatar %>
<%= image_tag f.object.temp_avatar.image.url %>
<% else %>
<%= image_tag f.object.avatar.url(:small) %>
<% end %>
</div>
<div class="dropzone"></div>
<%= f.hidden_field :temp_avatar_id, data: { hidden_temp_field: true } %>
</div>
</div>
<% end %>
Notice the attributes data-url="<%= temp_images_path %>"
on the field container and data: { hidden_temp_field: true }
on the hidden field. We'll need these for our javascript code which will initialize a dropzone instance.
// app/assets/javascripts/temp_image_upload.js
(function($){
window.TempImageUpload = (function(){
TempImageUpload = function(selector) {
$(selector).each(function(el, _) {
var $el = $(el),
// The url Dropzone should POST to
url = $el.data('url'),
// The hidden field to hold the temp_avatar_id
$hidden_field = $el.find('[data-hidden-temp-field]'),
// The img tag used for previewing of selected file
$preview = $el.find('[data-temp-image-preview] img'),
// the dropzone element
$dropzoneEl = $el.find('.dropzone');
el.dropzone = $dropzoneEl.dropzone({
url: url,
paramName: 'temp_image[image]',
clickable: true,
headers: {
'X-CSRF-Token': $('meta[name=csrf-token]').attr('content')
},
init: function() {
dropzone = this;
this.on('success', function(file, temp_image) {
// Set value of hidden field to the temp image we just processed
$hidden_field.val(temp_image.id);
// Change the preview img src to the new temp image
var fr = new FileReader();
fr.onload = function () {
$preview.attr("src", fr.result);
}
fr.readAsDataURL(file);
dropzone.removeFile(file);
})
}
});
});
}
return TempImageUpload;
})();
})(jQuery);
// app/assets/javascripts/application.js
$(document).on("turbolinks:load", function() {
window.tempImageUpload = new TempImageUpload('.temp-image');
});
With all the code in place, when we click on the dropzone selection element, or drag-and-drop an image into it, dropzone sends the file to our temp_images_path
. In turn, the request sends back the id of the temp image that was saved. We use that as our temp_avatar_id
in the user form. After we submit it, the User model copies the temp image into its avatar attribute and destroys the temp image. The benefit of doing all this is to ensure that when you attempt to upload an avatar for a user but other attributes don't pass the validations, the image you uploaded displays and persists the next time you submit the form.
Also, Tiffany is a better editor than I am so I'm going to have her look at it. I didn't see anything that seemed like proprietary data, but I also want David to have a run through. Then when you're ready with the stuff you have called out as needing a link, we'll figure out how to get this on Medium.