Skip to content

Instantly share code, notes, and snippets.

@vesselinv
Last active March 28, 2017 21:39
Show Gist options
  • Save vesselinv/1bd5aa198c7099d2f5b031c4e7e7bb3b to your computer and use it in GitHub Desktop.
Save vesselinv/1bd5aa198c7099d2f5b031c4e7e7bb3b to your computer and use it in GitHub Desktop.
Temp Images

Temporary Image Uploads using Paperclip and Dropzone

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:

Replace Paperclip

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.

Monkey-patch Paperclip

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.

Replicate the model's validation logic on the client side

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.

Enter the Dropzone.js

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

Divide and Conquer

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.

Gluing the pieces

# 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.

Making the front-end work

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.

@CoralWeigel
Copy link

  • Need to add commas in the first paragraph: We also, as a matter of fact, didn't find an imminent need to replace it because it had so far performed its capabilities well and without much hassle.

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.

@squareleaf
Copy link

Grammar and punctuation changes - I just retyped most of them as they should be. I tried not to stomp on your writing style too much:

  1. Changes as shown: There are certain capabilities that Paperclip doesn't support, and one such example is re-rendering an image attachment that fails to save due to validation errors.
  2. During brainstorming, we considered the following approaches
  3. We did not have the available resources to replace Paperclip with Carrierwave or any similar library, given that we have a large codebase and Paperclip is heavily used across different layers of our application.
  4. We also didn't find an imminent need to replace it, because it had so far performed its capabilities well and without much hassle.
  5. Developer spelled wrong: 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.
  6. Consequences misspelled, move comma: 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.
  7. core issue at stake: we wanted to re-render the image
  8. only image MIME types are allowed, etc.
  9. Left out 'a', misspelling: 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
  10. 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.
  11. but only for a short amount of time, after which it will be moved to a User record
  12. Committing should have two t's, clarify words: After committing an INSERT or UPDATE database operation, a temp_image that the instance keeps reference to is destroyed
  13. We'll need these for our javascript code
  14. dropzone sends the file to our temp_images_path.
  15. 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.
  16. Misspelled attribute: the User model copies the temp image into its avatar attribute

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment