Handling ajax-y file uploads
I’m working on a site for a client where a user needs to upload a logo for their newly created widget. The client wants the logo to be visible during the widget creation process (so the user can make sure the logo they’re uploading is correct). A user can add multiple widgets all at the same time.
I discovered a slightly different strategy for implementing this functionality and as its pretty common, something I’ve done before and I’m kinda pleased by the new strategy, I thought I’d share.
responds_to_parent
A lot of posts exist on the web about how to do file uploads via ajax. Short answer is, you can’t. There are lots of work-arounds to make it look like that’s exactly what you’re doing, though. I’ve been using responds_to_parent for years now. It’s pretty rad. Excerpted from its readme:
Adds responds_to_parent to your controller to respond to the parent document of your page. Make Ajaxy file uploads by posting the form to a hidden iframe, and respond with RJS to the parent window.
Basically what happens is that you create a hidden iframe on the page which is the target of a form submission with a file upload. responds_to_parent executes the javascript returned to the browser via the form submission (and going into the hidden iframe) in the context of the iframe containing page. Typically, this bit of javascript is where you update the old logo with the newly uploaded one.
links to other articles w/ explanations
I don’t want to re-cover a lot of previously covered ground, so here’s a collection of links explaining the basics. The piece I want to concentrate on, is a good way of handling the problem of page flow and separate forms which cannot be embedded, which is beyond the scope of these links.
- http://khamsouk.souvanlasy.com/2007/5/1/ajax-file-uploads-in-rails-using-attachment_fu-and-responds_to_parent
- http://kpumuk.info/ruby-on-rails/in-place-file-upload-with-ruby-on-rails/
Incidentally, here’s another solution using jQuery for the hacky part and not responds_to_parent.
two schemes
Ok, so we have our controllers and models and responds_to_parent all setup and ready to go, but now we have a problem. In every page design I’ve seen that demands this functionality, the file upload–in this case the logo attaching–is done in the middle of a form with all sorts of other values that need to be filled out. This makes sense from a UI point of view, but isn’t possible in a HTML form sense. Because you can’t have a form inside another form (what would the submit button be submitting?), you can’t simply put your file upload form where it belongs on the page.
In the past my solution has been to put the file upload form where it belongs page-flow wise and then instead of putting all the other form values inside a form, they’re simply unattached inputs. Then I create a form at the bottom of the page inside a hidden div which contains all the fields that I want to submit. I name the visible, unattached inputs by a standard scheme and then I bind to the click event of the submit button such that all of values are copied from the visible, unattached inputs to the invisible real form, which is what gets submitted.
Here’s some example javascript which explains the theory:
CopyValuesForm = Class.create({
FIELDS = $w('first_name last_name street_address city state zip'),
initialize: function() {
this.targetForm = $('target-form');
$('fake-submit').observe('click',
this.submit.bindAsEventListener(this));
},
submit: function(event) {
event.stop();
this.FIELDS.each(function(field) {
this.realField(field).setValue = $F(this.fakeField(field));
}.bind(this));
this.targetForm.submit();
},
fakeField: function(field) {
return $('fake_' + field);
},
realField: function(field) {
return $('widgets[' + field ']');
}
});
This works and isn’t a bad solution. Its kinda annoying that you have to copy lots and lots of values instead of copying just the file-to-be-uploaded value, but file upload values can’t be copied for security reasons via javascript. It also stinks because your unattached inputs don’t play nicely with all of Rails built-in form wizardry like hooking in the error message into the fields. You can overcome all of this, but its with more javascript, some of which has to be customized per-field.
But there’s another solution which obviates the need for copying values and allows you to use built-in Rails form magic to its fullest.
moving the display instead of the form
In this scheme we move the file upload form to the bottom of the page and keep the normal form intact. We use javascript to change where the file upload form is displayed in the page instead of copying the values.
First the javascript:
var ContainingObject = Class.create({ removeWidget: function(event) { event.stop(); var widget_container = event.element().up('.widget'); var widget_id = widget_container.id; new Ajax.Request('/containing_objects/1/widgets/2', { method: 'delete', onSuccess: function(transport) { // we no longer contain this widget in the ui, so get rid of it this.widgets.unset(widget_id).remove(); // reposition the remaining forms this.widgets.values().invoke('positionUploadForm'); }.bind(this) }); } }); var Widget = Class.create({ initialize: function(container) { this.container = container; this.dom_id = container.id; this.positionUploadForm(); }, remove: function() { this.uploadForm().remove(); this.container.remove(); }, uploadForm: function() { return $('widget-form-' + this.dom_id); }, positionUploadForm: function() { var form = this.uploadForm(); form.absolutize(); form.clonePosition(this.container.down('.form-goes-here')); } });
There’s of course elided logic there for adding widgets, creating widget objects, etc. The important pieces to note are that the form is positioned as per the container (.form-goes-here) on initialization and also on removal of any other widget. It occurs to me writing this, that it would be an excellent application for an event-based setup whereby a widget:remove event is fired from the Widget object (in the remove method) upon its ContainingObject. All Widgets could observe their ContainingObject for this event. Anyways … the html/css is pretty rote. Simply put a div in the normal flow of the form:
<div class=".form-goes-here"></div>
and make sure you specify that its large enough for the form which will be cloned on top of it in your css:
.form-goes-here {
width: 200px;
height: 100px;
}
cute work with content_for, yield
You’d want to define the file upload form in the same partial that contains the rest of your form fields (to be shared between the new and edit views) even though you actually want it to spit out into html at the bottom of the page. That’s exactly what content_for was designed to do.
<% content_for :upload_form do -%> <div class="logo-form-container"> <% form_for :logo, :url => logos_path, :html => { :multipart => true, :target => 'upload-frame', :id => "widget-form-#{dom_id(widget)}" } do |f| -%> <p> <%= f.label :uploaded_data, 'Logo file' %><br/> <%= f.file_field :uploaded_data %> </p> <p> <%= f.submit 'Upload image' %> </p> <% end -%> </div> <% end -%>
and then in your new/edit.html.erb files:
<%= yield :upload_form %> <iframe id="upload-frame" name="upload-frame" border="0" width="0" height="0" style="display:none;"></iframe>
I also discovered this somewhat cute way of getting the file upload form into the page even when there’s some ajax to add another form. The use-case here is that your page uses Ajax to add another widget and bring along all the form fields (including the logo) which need to be submitted. In the new.js.rjs:
page.insert_html :bottom, "widgets-container", :partial => @widget page.insert_html :before, "upload-frame", yield(:upload_form)
summing it up
So we know there can’t be embedded forms and we know that we have to do some shenanigans for Ajax looking file uploads. Instead of copying the values from all of the fields in one form into a hidden form, we can position the upload form on the page so it appears to flow normally without actually being embedded. This is simpler and allows us to rely more on the built-in Rails form support and error messages.
Thanks to Mark Catley for the great plugin I’ve used so many times over the years.





