Multiple Attachments in Rails
the orginal URL:http://www.practicalecommerce.com/blogs/post/432-Multiple-Attachments-in-Rails
Creating intuitive interfaces can be challenging, particularly when it comes to uploading files such as images or attachments. Suppose that we are creating an application that let's someone submit a blog post, to which they can add multiple attachments. A great example of this kind of thing is BaseCamp , which is a project management application. BaseCamp is one of those applications that leaves developers in awe, as they have managed to create extremely intuitive interfaces. Inspired by the way that BaseCamp handles multiple file uploads, I set out to create my own version. Here is what I learned.
Objective
My objective was to create an interface that would a user to submit a blog post, with the ability to attach multiple files to that job post. The interface needed to be intuitive, graceful, and (hopefully) on par with the way that BaseCamp does it. To start out, I knew that I would be using Ruby on Rails to create my application, and that I would also be using the attachment_fu plugin to handle file uploads.
A bit of searching about multiple file uploads, and I was ready to get started. Having done a little research and working out the logic of the problem, I figured I would have the following objectives:
- Allow user to attach files to a blog post.
- Enforce a limit of 5 files per blog post.
- Allow a user to remove files from a blog post.
The Models
Let's start with the models that we will need in order to pull this off. Since we are trying to let a user create a blog post, we will start with a model called Post
. We'll also need a model that will represent our attached file, which I am going to call Attachment
. The following is a sample migration file to create these models:
class CreatePosts < ActiveRecord::Migration
def self.up
create_table :posts do |t|
t.string :title
t.text :content
t.timestamps
end
create_table :attachments do |t|
t.integer :size, :height, :width, :parent_id, :attachable_id, :position
t.string :content_type, :filename, :thumbnail, :attachable_type
t.timestamps
t.timestamps
end
add_index :attachments, :parent_id
add_index :attachments, [:attachable_id, :attachable_type]
end
def self.down
drop_table :posts
drop_table :attachments
end
end
Most of the fields in the attachments
table are required by the attachment_fu
plugin, but you'll notice that I have added an integer field called attachable_id
and a string field called attachable_type
. These fields are going to be used for a polymorphic relationship. After all, if this thing works I don't want to be limited to only attaching files to blog posts, but would rather have the option of adding attachments to other models in the future. Additionally, I've added an indexes to the attachments
table based on my experiences with the SQL queries that attachment_fu
generates. Without going in to detail, these indexes help immensely when your application begins to scale.
So once you have migrated your database, it's time to move on to the actual models themselves. Let's start with the Post
model (/app/models/post.rb ):
class Post < ActiveRecord::Base
has_many :attachments, :as => :attachable, :dependent => :destroy
validates_presence_of :title
validates_uniqueness_of :title
validates_presence_of :content
end
This is a pretty basic model file. To start with, we declare that this model has_many
attachments, which we will reference as "attachable" (remember the extra fields we added to the attachments
table?), and that if someone deletes a post, the attached files should also be deleted. Then we do some simple validations to make sure that each post has a unique title and some content.
Moving on, let's take a look at our Attachment
model (/app/models/attachment.rb ):
class Attachment < ActiveRecord::Base
belongs_to :attachable, :polymorphic => true
has_attachment :storage => :file_system,
:path_prefix => 'public/uploads',
:max_size => 1.megabyte
end
Again, this is an extremely basic model file. The first thing we do is set the belongs_to
relationship, which is polymorphic. Notice that we are referring to attachments as attachable
, like we did in our Post
model.
We are going to be storing our uploaded files in
/public/uploads
. When it comes to deployment, be sure that this directory is symlinked to a shared directory, or you will lose all your attachments each time you deploy.
Now that we have our two models in place, let's set up some controllers and get our views figured out.
Setting Up Controllers
Ok, so what we know is that we will be creating blog posts. First off, let's create some controllers to handle all of this. I'll be coming back to the controller actions later, but for now we want to generate them:
script/generate controller posts
script/generate controller attachments
I like to create a controller for each resource in order to keep things RESTful. You never know where your app may go, so better safe than sorry. Speaking of resources, let's create some routes as well (/config/routes.rb ):
map.resources :attachments
map.resources :posts
We'll come back to write our controller actions later, as first we want to tackle our view files.
The Basic Views
I'm going to operate under the assumption that we are using a layout template, which loads in all of the default JavaScript files for Rails:
<%= javascript_include_tag :defaults %>
From there, let's start by looking at the basic forms that we are starting with to manage blog posts:
(/app/views/posts/new.html.erb )
<% form_for(:post, @post, :url => posts_path, :html => {:onsubmit => 'return post.validate();', :multipart => true}) do |f| %>
<%= error_messages_for 'post' %>
<%= render :partial => 'post_form', :locals => {:f => f, :post => @post} %>
<%= f.submit "Create Blog Post", :id => 'post_submit' %>
<% end -%>
(/app/views/posts/edit.html.erb )
<% form_for(:post, @post, :url => post_path(@post), :html => {:onsubmit => 'return post.validate();', :multipart => true, :method => :put}) do |f| %>
<%= error_messages_for 'post' %>
<%= render :partial => 'post_form', :locals => {:f => f, :post => @post} %>
<%= f.submit "Save Changes", :id => 'post_submit' %>
<% end -%>
(/app/views/posts/_post_form.html.erb )
<%= f.text_field :title %>
<%= f.text_area :content, :rows => 7 %>
By using a partial to abstract out the blog post fields, we can concentrate on the other parts of the form. You'll notice that there are no file fields or any uploading stuff at all in our forms. We are going to add this in, but it needs to be one at a time. The reason for this is that we have two very different scenarios that require different logic:
- A user is creating a new blog post – under this scenario, there are no attachments to our post, since it is new. All we need to be concerned about is allowing multiple uploads and uploading them to the server.
- A user is editing a blog post – things get a little more complicated here. Let's assume that we are limiting the number of attachments to 5 per blog post. If we are editing a post that already has 2 attachments, we need a way to make sure that they cannot go over the limit. Additionally, the user will need a method of removing attachments that are already assigned to the blog post (which also plays in with the attachment limit).
First of all, let's build the JavaScript that we will need to make this happen, and then we will come back to our views and make the adjustments that we need.
The JavaScript
Alright, some of you have probably been reading through this post wondering when I am going to actually start talking about how to upload multiple files. Your patience is about to pay off! The way that this trick works is that we use JavaScript to store the files that a user wants to attach, and also to display an interface. To set the expectation of what we are looking at, here is a timeline of events for uploading multiple files:
- A user selects a file to attach.
- That file is stored via JavaScript.
- The file name is appended to the page with a link to remove the file.
- The number of attachments is evaluated, and the file field is either reset or de-activated.
As you can see in the screenshot at the left, once a user has selected a file to attach, it is displayed to the user and they have the option of removing it. Note: these files have not been uploaded yet, they are simply stored in a JavaScript object .
First of all, I found a nice little script by someone called Stickman after searching a bit on this. However, I found that I needed to make some small adjustments to the original script and the examples provided, particularly:
- Changed the uploaded attachments from
:params['file_x']
toparams[:attachment]['file_x']
. - Changed to update a
ul
element withli
elements, rather than creatingdiv
elements. - Changed to "Remove" button to a text link.
- Uses Prototype and Scriptaculous .
These sample files are included at the bottom of this post. So let's go through the steps that we need to get this working on our example. The first thing we need to do is add the scripts to /public/javascripts/application.js
so that we have the JavaScript methods that we need in place. You will notice that there is also a validation script in there to handle client-side validation of the blog post form (post.validate()
).
The "New Blog Post" Form
Let's take a look at what we need to add to our "new blog post" template:
(/app/views/posts/new.html.erb )
<% form_for(:post, @post, :url => posts_path, :html => {:onsubmit => 'return post.validate();', :multipart => true}) do |f| %>
<%= error_messages_for 'post' %>
<%= render :partial => 'post_form', :locals => {:f => f, :post => @post} %>
<% fields_for @attachment do |attachment| -%>
<%= attachment.file_field :data %>
<% end -%>
<%= f.submit "Create Blog Post", :id => 'post_submit' %>
<% end -%>
As you can see, we have added some code between our partial (below the blog post fields) and the submit button. Let's take a quick look at what each of these does:
<% fields_for @attachment do |attachment| -%>
This block allows us to create form fields for another model. In our case, the form that we are working with is linked to the Post
model, but we would like to upload files to the Attachment
model. Inside this block we have a variable called attachment
that maps to an Attachment
object, similar to the @post
variable mapping to a Post
object.
<%= attachment.file_field :data %>
Here we are adding the file field. The script overrides much of the attributes for this one, but just for good form I have called it data. Note how the label corresponds to attachment_data
, since that is the ID that Rails will generate for that file field.
Here are have put an empty ul
element that will hold the "pending files" that we want to upload. Remember when we select a file for upload, it will be stored and displayed here (with the option to remove it). Notice that this unordered list element has an id of pending_files
.
This is the real meat of the script, where we create an instance of the MultiSelector object. It takes two parameters, the first is the id
of the element to update after a file has been selected, and the second is an option limit number. In our example, we are putting a limit of 5 attachments per blog post. We could leave this blank to allow unlimited attachments.
Secondly, we add the file field element to our MultiSelector instance, which hooks it all up to the file field that we had created above. And really, that is about all that we need to do.
The "Edit Blog Post" Form
With the edit form, we have a little bit more to deal with. Specifically, because there could already be attachments to the blog post that we want to edit, we have a few issues to face:
- We need to evaluate how many new attachments are allowed.
- We need to display the attachments that already exist.
- A user needs to be able to remove the attachments that already exist.
So let's take a look at the final code for our edit form, and go through what each of those changes is:
(/app/views/posts/edit.html.erb )
<% form_for(:post, @post, :url => post_path(@post), :html => {:onsubmit => 'return post.validate();', :multipart => true, :method => :put}) do |f| %>
<%= error_messages_for 'post' %>
<%= render :partial => 'post_form', :locals => {:f => f, :post => @post} %>
<% fields_for @newfile do |newfile| -%>
<% if @post.attachments.count >= 5 -%>
<% else -%>
<% end -%>
<% end -%>
<% if @post.attachments.size > 0 -%>
<%= render :partial => "attachment", :collection => @post.attachments %>
<% end -%>
<%= f.submit "Save Changes", :id => 'post_submit' %>
<% end -%>
You'll notice some similarities, but we have to make adjustments here on the edit form. Let's start with the changes that we've made:
<% fields_for @newfile do |newfile| -%>
<% if @post.attachments.count >= 5 -%>
<% else -%>
<% end -%>
<% end -%>
Again we are using a fields_for
block here to assign fields to a new model. However, in order to control whether or not the file field is disabled I have hard-coded the file field. The only reason that the fields_for
block is there is to keep consistent, and for example purposes. Notice that if the post that we are going to edit already has 5 attachments, the file field is disabled to prevent more attachments.
<% if @post.attachments.size > 0 -%>
<%= render :partial => "attachment", :collection => @post.attachments %>
<% end -%>
Once again we have our ul
element that displays the attachments. Since the blog post that we are editing may already have attachments, we loop through each attachment that it may already have an display a partial for each one:
(/app/views/posts/_attachment.html.erb )
<%= "#{attachment.filename} %>
<%= link_to_remote "Remove", :url => attachment_path(:id => attachment), :method => :delete, :html => { :title => "Remove this attachment" } %>
As you can see, all this partial does is display a li
element that displays the filename of the attachment and also an Ajax link to remove that attachment. We'll deal with the Ajax link more when we get to our controllers. We still have one more section left that is new in our edit form:
This is the familiar script that creates our MultiSelector object. However, this time we are using a variable called @allowed
to declare the number of attachments that are allowed to be uploaded. Remember that we have a limit of 5 attachments for each blog post, so we need to evaluate how many this particular post already has, and then respond accordingly.
The Controllers
So far we have put everything in place that we need to get multiple file uploads to happen. We've created the models that we need to hold our data, and we've created the views that we need to create and edit our models. Now all we need to do is to tie it all together. Remember that we have two resources, Post
and Attachment
, that we are working with, so we also have two controllers. Since most of the action happens in the Post
controller, let's start with that one:
(/apps/controllers/\posts_controller.rb )
def new
@post = Post.new
@attachment = Attachment.new
end
def create
@post = Post.new(params[:post])
success = @post && @post.save
if success && @post.errors.empty?
process_file_uploads
flash[:notice] = "Blog post created successfully."
redirect_to(post_path(:id => @post))
else
render :action => :new
end
end
def edit
@post = Post.find(params[:id])
@newfile = Attachment.new
@allowed = 5 - @post.attachments.count
end
def update
if @post.update_attributes(params[:post])
process_file_uploads
flash[:notice] = "Blog post update successfully."
redirect_to(post_pat(:id => @post))
else
render :action => :edit
end
end
protected
def process_file_uploads
i = 0
while params[:attachment]['file_'+i.to_s] != "" && !params[:attachment]['file_'+i.to_s].nil?
@attachment = Attachment.new(Hash["uploaded_data" => params[:attachment]['file_'+i.to_s]])
@post.attachments << @attachment
i += 1
end
end
Obviously, you would want to include the other RESTful actions to your actual controller such as index
, show
and destroy
. However, since these are the only ones that are different for multiple file uploads, they are the only ones that I am showing. You'll notice that we have a nice little action that handles the multiple file uploads. Not a bad 8 lines of code, but basically the create
and update
actions are expecting the following parameters:
{
"post" => {"title" => "Blog Post Title",
"content" => "Blog post content..."},
"attachment" => {"file_0" => "FILE#1",
"file_1" => "FILE#2"}
}
It will then loop through all of the valid attachment
files, save them and assign them to the blog post in question. Also notice that we have set the @allowed
value in the edit
action to make sure that our view knows how many attachments a blog post already has.
At this point we are in good shape. Our forms will work and we can upload multiple attachments. However we still haven't solved one of our problems, which is that we need to be able to remove attachments that were previously assigned to a blog post, which comes up on our edit form. Let's take a look at our other controller and see how we handle this challenge:
(/apps/controllers/\attachments_controller.rb )
def destroy
@attachment = Attachment.find(params[:id])
@attachment.destroy
asset = @attachment.attachable
@allowed = 5 - asset.attachments.count
end
So let's take a look at this, which is a pretty basic destroy
controller. Remember that we have Ajax links to this action from the attached files on our edit form. Each link sends the id
of the attachment to be removed, which is passed to this controller. Once we have deleted the attachment from the database, we then want to get the "asset" that this attachment belongs to. In our example, the "asset" is a blog post, but remember that we made the Attachment
relationship polymorphic for a reason. By getting the "asset" that the attachment was assigned to, we can get a count of how many attachments are left, which let's us update our familiar allowed
variable.
The only thing left to do is to update the edit form to remove the attachment. Remember that any attachments that are added to the list via the file field are handled by our MultiSelector
object, including removing them. However, since we are trying to remove the attachments that were already assigned to our blog post, we'll need to use an rjs
template:
(/app/views/attachments/destroy.rjs )
page.hide "attachment_#{@attachments.id.to_s}"
page.remove "attachments_#{@attachments.id.to_s}"
page.assign 'multi_selector.max', @allowed
if @allowed < 5
page << "if ($('newfile_data').disabled) { $('newfile_data').disabled = false };"
end
Again this is a pretty simple little file that does a lot. The first thing that we do is to hide the li
element for this attachment, and then remove it completely from the DOM just to be sure. In order to update the number of allowed uploads, we assign
a new value to multi_selector.max
, which is the variable in our script that controls the maximum number of attachments (use -1
for unlimited attachments). Finally, just in case there were 5 attachments before we removed one, which would mean that the file field is disabled, we re-enable that file field if it is appropriate.
And that is about it! Aside from some CSS styling, we now have the ability to upload multiple files and attach them to a blog post using Ruby on Rails. Please download the sample files to see the code in action, and I would love to hear feedback and comments.