Background

When you build a form in “Rails way”, you don’t have to do much on your own. It automatically validates the parameter, and re-renders the form with proper messages in the event of errors.

However, when it comes to file upload form, things get complicated. For instance, when the form is redisplayed because of the form error, the file input value is lost. You don’t want to make users select the same file again, just because they mistyped their email address or phone number. Especially when they chose a bunch of files to upload. It’s also bad in terms of website performance (We don’t want them to send large image files again and again).

We could use javascript file upload plugins (lots of them are opensource, cool, well animated and fantastic). But they normally make source code difficult to manage if you’ve been doing things in “Rails way”.

Here I’ll introduce a way to build a file upload form in “Rails way”, which gets along with Rails activeform and requires minimum effort to build, but still does enough. Concretely,

  • Uploads multiple files at a time.
  • Can add/remove multiple file input fields dynamically.
  • Shows preview when an image file is chosen.
  • If an errors exist in the form, the file inputs memorize and redisplay the selected file.
  • Also, the server caches the once-uploaded-file in the event of form redisplay, so no additional network traffic occurs.
  • Can remove existing files.
  • Use plain html form, NOT xhr (therefore, no file drag and drop feature).

See Github Repository for the complete app.

Gems to Use

CarrierWave

Use CarrierWave to handle files in the app.

You could also consider using the following.

Cocoon

Cocoon helps generating multiple input fields for has_many relation models.

Instruction

Scaffolding the App

New up a rails app, and add the following lines to Gemfile, then run bundle install.

Gemfile

1
2
3
4
5
gem "carrierwave"
gem "cocoon"

gem "slim"
gem "slim-rails"

Create models and carrierwave uploaders.

1
2
3
4
rails g scaffold user name:string
rails g model user_photo photo:string user_id:integer
rails g uploader photo
rake db:migrate

And make models looks like followoing.

app/models/*.rb

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class User < ActiveRecord::Base
  has_many :user_photos
  validates_presence_of :name
  accepts_nested_attributes_for :user_photos, allow_destroy: true
end

class UserPhoto < ActiveRecord::Base
  belongs_to :user
  mount_uploader :photo, PhotoUploader
end

Form for has_many relation with Cocoon

Add the following lines inside form_for block in the default user form.

app/views/users/_form.html.slim

1
2
3
4
5
6
7
= form_for @user do |f|

  / ..
  = f.fields_for :user_photos do |photo|
    = render 'user_photo_fields', f: photo
  .links= link_to_add_association 'add a photo', f, :user_photos

Then, create the following partial file.

app/views/users/_user_photo_fields.slim.html

1
2
3
.nested-fields
  = f.file_field :photo
  = link_to_remove_association "remove", f

link_to_add_association and link_to_remove_association are helper methods provided by cocoon that generates link tags. These links dynamically adds/removes nested form for has_many relation model. _user_photo_fields.html.slim is the template included as nested form. You have to add the following line to application.js to take advantage of cocoon. (Also, you shouldn’t change div.nested-fields surrounding link_to_remove_association, because cocoon is depending on it)

app/assets/js/application.js

1
//= require cocoon

tweak strong_parameter so that the backend accepts parameters for user_photos.

app/controllers/users_controller.rb

1
2
3
def user_params
  params.require(:user).permit(:name, :email, user_photos_attributes: [:id, :photo, :_destroy])
end

Now open up a browser and go to /users/new, you can at least create a user with photos through the form.

Showing Thumbnail

Currently if you go to /users/:id, only name of the user is shown. To show the thumbnails of the photos, add the following line to show.html.slim.

app/views/users/show.html.slim

1
2
- @user.user_photos.each do |user_photo|
  .thumb= image_tag user_photo.photo.url

and some styles.

app/assets/stylesheets/users.css.scss

1
2
3
4
5
6
.thumb{
  img{
    width: 50px;
    height: 50px;
  }
}

It’d be nice if you can also see thumbnails right after choosing photos. Add some javascript and style change to achieve this.

app/assets/javascripts/users.js.coffee

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
$(document).on('page:change', ()->
  onAddFile = (event) ->
    file = event.target.files[0]
    url = URL.createObjectURL(file)

    thumbContainer = $(this).parent().next('td').find('.thumb')

    if thumbContainer.find('img').length == 0
      thumbContainer.append('<img src="' + url + '" />')
    else
      thumbContainer.find('img').attr('src', url)

  # for redisplayed file inputs and file inputs in edit page
  $('input[type=file]').each(()->
      $(this).change(onAddFile)
  )

  # register event handler when new cocoon partial is inserted from link_to_add_association link
  $('body').on('cocoon:after-insert', (e, addedPartial) ->
    $('input[type=file]', addedPartial).change(onAddFile)
  )

  # tell cocoon where to insert partial
  $('a.add_fields').data('association-insertion-method', 'append')
  $('a.add_fields').data("association-insertion-node", 'table.user-photo-form tbody')
)

Pretiffy the nested form a bit by putting them in table tag.

app/views/users/_form.html.slim

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
= form_for @user do |f|
  /..
  table.user-photo-form
    thead
      tr
        td input
        td preview
        td remove
    tbody
      = f.fields_for :user_photos do |photo|
        = render 'user_photo_fields', f: photo
  .links= link_to_add_association 'add a photo', f, :user_photos

Wrap nested forms in tr.

app/views/users/_user_photo_fields.html.slim

1
2
3
4
tr.nested-fields
  td= f.file_field :photo
  td.thumb
  td= link_to_remove_association "remove", f

Add some styles.

app/assets/stylesheets/users.css.scss

1
2
3
4
5
6
.user-photo-form{
    td{
        width: 300px;
        padding: 10px 20px 10px 20px;
    }
}

Redisplaying thumbnail on validation fail

Now select some files and submit the form with name field being blank. The validation fails, and you see the selected files are competely lost in the redisplayed form. Carrierwave has a nice feature for handling this situation. It not only lets us keep track of the uploaded files, but also caches them so that we don’t have to send the same ones on every validation fail.

What we need to do is just to addd a hidden field for cached photo as #4 below shows. Then CarrierWave automatically associates the model with cached file and saves when validation passes. (if you select an another new file, this cache will be ignored)

app/views/users/_user_photo_fields.html.slim

1
2
3
4
5
6
7
8
tr.nested-fields
  td
    = f.file_field :photo
    = f.hidden_field :photo_cache, :value => f.object.photo_cache
  td.thumb
    - if f.object.photo.url.present?
      = image_tag f.object.photo.url
  td= link_to_remove_association "remove", f

Add photo_cache to strong_parameter

app/controllers/users_controller.rb

1
2
3
def user_params
  params.require(:user).permit(:name, :email, user_photos_attributes: [:id, :photo, :photo_cache, :_destroy])
end

Rejecting Empty Photo Fields

If you fill in the name, click ‘add a photo’ button several times and post it, you’ll see models with no photo are created. To avoid this, use reject_if option.

app/models/*.rb

1
2
3
4
5
6
7
class User < ActiveRecord::Base
  has_many :user_photos
  validates_presence_of :name
  accepts_nested_attributes_for :user_photos, \
    reject_if: proc{ |param| param[:photo].blank? && param[:photo_cache].blank? && param[:id].blank? }, \
    allow_destroy: true
end

You could also add a validator on the UserPhoto model to prevent creation of model with empty file.