Multiple Attachments in Rails

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} %>
        <p>
            <%= f.submit "Create Blog Post", :id => 'post_submit' %>
        </p>
    <% 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} %>
        <p>
            <%= f.submit "Save Changes", :id => 'post_submit' %>
        </p>
    <% end -%>

(/app/views/posts/_post_form.html.erb )

<p>
      <label for="post_title">Title:</label>
      <%= f.text_field :title %>
    </p>
    <p>
      <label for="post_content">Content:</label>
      <%= f.text_area :content, :rows => 7 %>
    </p>

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:

  1. 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.
  2. 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:

  1. A user selects a file to attach.
  2. That file is stored via JavaScript.
  3. The file name is appended to the page with a link to remove the file.
  4. The number of attachments is evaluated, and the file field is either reset or de-activated.

Attached Files 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'] to params[:attachment]['file_x'] .
  • Changed to update a ul element with li elements, rather than creating div 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| -%>
        <p>
            <label for="attachment_data">Attach Files:</label>
            <%= attachment.file_field :data %>
        </p>
        <% end -%>
        <ul id="pending_files"></ul>
        <script type="text/javascript">
            var multi_selector = new MultiSelector($('pending_files'), 5);
            multi_selector.addElement($('attachment_data'));
        </script>
        <p>
            <%= f.submit "Create Blog Post", :id => 'post_submit' %>
        </p>
    <% 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.

<p>
        <label for="attachment_data">Attach Files:</label>
        <%= attachment.file_field :data %>
    </p>

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.

<ul id="pending_files"></ul>

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 .

<script type="text/javascript">
        var multi_selector = new MultiSelector($('pending_files'), 5);
        multi_selector.addElement($('attachment_data'));
    </script>

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:

  1. We need to evaluate how many new attachments are allowed.
  2. We need to display the attachments that already exist.
  3. 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| -%>
        <p>
            <label for="newfile_data">Attach Files:</label>
            <% if @post.attachments.count >= 5 -%>
                            <input id="newfile_data" type="file" />
            <% else -%>
            <input id="newfile_data" type="file" disabled />
            <% end -%>
        </p>
        <% end -%>
        <ul id="pending_files">
            <% if @post.attachments.size > 0 -%>
            <%= render :partial => "attachment", :collection => @post.attachments %>
            <% end -%>
        </ul>
        <script type="text/javascript">
            var multi_selector = new MultiSelector($('pending_files'), <%= @allowed %>);
            multi_selector.addElement($('newfile_data'));
        </script>
        <p>
            <%= f.submit "Save Changes", :id => 'post_submit' %>
        </p>
    <% 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| -%>
    <p>
        <label for="newfile_data">Attach Files:</label>
        <input id="newfile_data" type="file" />
        <% if @post.attachments.count >= 5 -%>
        <% else -%>
        <input id="newfile_data" type="file" disabled />
        <% end -%>
    </p>
    <% 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.

<ul id="pending_files">
        <% if @post.attachments.size > 0 -%>
        <%= render :partial => "attachment", :collection => @post.attachments %>
        <% end -%>
    </ul>

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 )

<%= "<li id=\"attachment_#{attachment.id}\">#{attachment.filename} %>
    <%= link_to_remote "Remove", :url  => attachment_path(:id => attachment), :method => :delete, :html => { :title  => "Remove this attachment" } %></li>

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:

<script type="text/javascript">
        var multi_selector = new MultiSelector($('pending_files'), <%= @allowed %>);
        multi_selector.addElement($('newfile_data'));
    </script>

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.

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值