BizMeetsDev

2 Trades - 1 Cup

editable_content_tag : The in_place_editor for Rails 2.0

Posted by rick Sat, 09 Feb 2008 21:45:00 GMT

The in_place_editor was a nifty helper in rails 1.2 days, but has been removed from 2.0. This is mostly due to the fact that it was obsoleted by REST. It also doesn’t play well with the new CSRF (Cross Site Request Forgery) prevention measures that are default in Rails 2.0.

REST takes Rails’ “Convention Over Configuration” mantra to a whole new level, so we can leverage that to create a new, more flexible, in_place_editor helper function that requires almost no controller modifications to work.

Like the original, editable_content_tag uses Scriptaculous’ Ajax.InPlaceEditor which, by default, doesn’t play well at all with Rails’ new rest scheme.

Its prototype looks like:

new Ajax.InPlaceEditor( element, url, [options]);

Where element is the id of the element you wish to edit in-place, the url is the url to which the update should be sent, and options is a hash which provides a myriad of… well.. options.

InPlaceEditor relies on the standard post method to send the updated value to the server. It then expects to receive a 200 OK response from the controller whose content is only the updated value of the attribute. This means that you must create a custom controller function for every attribute you wish to modify, or some clever switching based upon the modified attribute type. This won’t do.

Make Rails speak JSON

We solve this with two simple steps, first, we modify our RESTful update controller method to respond to a JSON request by serializing the object we’ve just updated, like so:

def update
    redirect_to(@artist) unless @authorized
    respond_to do |format|
      if @artist.update_attributes(params[:artist])
        #flash[:notice] = 'Artist was successfully updated.'
        format.html { redirect_to(@artist) }
        format.json { render :json => @artist }  # <---- add this
        format.xml  { head :ok }
      else
        format.html { render :action => "edit" }
        format.xml  { render :xml => @artist.errors, :status => :unprocessable_entity }
      end
    end
end

So, upon a successful update, we receive a JSON serialized Artist object. We can then display the correct value by overriding the onComplete option with a new function in the options hash, and placing it in the call to the InPlaceEditor constructor:

  onComplete: function(transport, element) 
  { element.innerHTML=transport.responseText.evalJSON().property}"
  /* property being the name of the attribute we're editing */

Make Scriptaculous Speak REST

The second problem that InPlaceEditor presents is that its default behavior is to POST, when we wish to put. This can be solved by adding an item to the ajaxOptions hash inside the options hash:

ajaxOptions: {method: 'put'}

Cake!

The third obstacle presented has to do with the parameters sent to the update request. The attribute parameter doesn’t follow Rails’ hash style params, also, we’re not passing through our authenticity_token which tells rails that this request came from a legit session. We can tackle those by modifying yet another option in the function call: callback:.

This option isn’t very descriptively named, but it references the “callback” function that InPlaceEditor calls when it is trying to build the query string that it passes in our new PUT request.

It looks like:

callback: function(form, value) 
               { return 'authenticity_token=(big hex string)&artist[name]=value'; }

Now when we are building this function call, we obviously must take care to pass in the correct values for the auth string, the object and the attribute, the value is provided to the callback function by InPlaceEditor.

So when building this call in our ruby helper, the complete function call looks like:

output = "
    new Ajax.InPlaceEditor('#{options[:id]}', '#{options[:url]}', { 
                        ajaxOptions: { method: 'put' },
                        callback: function(form, value) 
                          { return 'authenticity_token=#{form_authenticity_token}&#{objname}[#{prop}]=' + escape(value) },
                        onComplete: function(transport, element) 
                          { element.innerHTML=transport.responseText.evalJSON().#{prop};}"

Putting it Together

So now that our javascript is out of the way, let’s build the rest of the helper. I add three option hashes, the 1st is the options for the content tag, so that items such as class and ID may be added or overridden. The 2nd hash, editOptions, allows options to be passed to the InPlaceEditor, the 3rd hash, ajaxOptions, allows options to be passed specifically to the ajaxOptions property in the InPlaceEditor.

I also add a boolean editable option so that I can clean up my view logic in deciding whether or not to make the field editable.

def editable_content_tag(elemtype, obj, prop, editable, options = {}, editOptions = {}, ajaxOptions = {})
    objname = obj.class.to_s.downcase
    options[:url] = "/#{objname.pluralize}/#{obj.id}" unless options.has_key? :url
    options[:url] += '.json'
    options[:id] = dom_id(obj)+"_#{prop}" unless options.has_key? :id
    ajaxOptions[:method] = 'put'
    edops = jsonify editOptions
    ajops = jsonify ajaxOptions

    tg = content_tag  elemtype, 
                      obj.send(prop),
                      options = options

    if editable then
      tg += "
           <script type='text/javascript'>\n
               new Ajax.InPlaceEditor('#{options[:id]}', '#{options[:url]}', { 
                        ajaxOptions: { #{ajops} },
                        callback: function(form, value) 
                          { return 'authenticity_token=#{form_authenticity_token}&#{objname}[#{prop}]=' + escape(value) },
                        onComplete: function(transport, element) 
                          { element.innerHTML=transport.responseText.evalJSON().#{prop};}"
      tg += ",#{edops}" unless edops.empty?
      tg += "});\n"
      tg += "         </script>\n"

    end
end

#stupid helper helper to convert a hash into a JSON options list
# (without the encompasing {}'s or any type of recursion
#Is there a rails API function that does this? 
def jsonify hsh
    str = ''
    first = true
    hsh.each do |k,v|
      str += ', ' unless first
      str += "#{k}: "
      str += "'" unless (v.class == Fixnum or v.class == Float)
      str += v.to_s
      str += "'" unless (v.class == Fixnum or v.class == Float)
      first = false
    end
    str
  end

So that’s it! Just include these helper functions in your application.rb and make the 1 line change to your update function in your favorite controller, and viola!

In your view, the minimum params to invoke your helper are: the element type you wish to create, the object you wish to access and the attribute of the object you wish to access.

<%= editable_content_tag :span, @artist, 'name' %>

For alternate options, such as class and text box size type options, the code looks like:

<%= editable_content_tag :div, @artist, 'profile', @authorized, 
                                         {:class => 'artist_name'}, {:rows => 8, :cols => 30}%>

That’s it. Let me know how you would improve this, also, if there is interest, I can formally turn this into a plugin.

Posted in , | 11 comments | Tags , , , , | atom

Comments

Leave a response

  1. Rej
    3 days later:

    Love the idea!

    I didn’t know you could make InPlaceEditor do those things.

    I think I will use this but would modify this so that it uses XML, more rails-ish to me.

  2. Randy
    4 days later:

    This article was perfectly timed for me, thanks!

    I got it to work for the first field on my page, but with two or more on the same page, they all get the same element id, which causes them all to not be clickable.

    I added “options = { :id => “firstfield”} for the first, and “secondfield” for second and I get the yellow mouse over effect again, but there is another weirdness going on now. With manually setting the element ids, the original first field I had working is now the only one that is working. The second one will allow me to click and edit and shows the “saving …” but it doesn’t save. Upon looking in the console tail output, I can see that the first field is being submitted as a [put] but the second is [get] ?!?

    I took a peek at the html source from within the browser and both are showing up identical except for their element ids. I then tried entering “ajaxOptions = { :method => “put”}” for the second, trying to force put method and it still submits as [get].

    Since the second one is sending via get, the URL also gets chock full of all the content of all the fields on the page.

    Any thoughts?

  3. Randy
    4 days later:

    Upon further investigation, I cut out the first field, leaving the second one only. It still is being submitted using GET.

    If I insert the first one and remove the second, it submits via PUT but the log also shows that it’s updating all the fields in the record, not just the one I edited. Shouldn’t it just update the one field?

  4. Rick
    6 days later:

    Randy, strangely enough, it worked on my page (I’ve got like 6 of them, but you’re right, all tags did have the same ID, I fixed this)

  5. RAndy
    6 days later:

    Rick: Are all of them sending via PUT? I can’t for the life of me figure out why one sends via PUT and the other via GET when they are both identical code, except for adding the :id in options. It even shows “method = put” in the browser source.

  6. Randy
    6 days later:

    I just found that It works fine in Firefox. I’ve been using Mac Safari 3.0.4 on OSX10.4.10.

    I added three more fields (all fine in Firefox) and in Safari, they are working correctly via PUT. The one that still isn’t working is of type ‘text’ in mysql, but so is one of the other ones that is working fine. Could there be something about the surrounding tags that could be affecting this one editablecontenttag? I suppose I can tell my client to use firefox instead, but why would one be sending via GET and not the other four?

    Needless to say, sending via GET does not update the text field in the RESTful controller.

  7. darius
    8 days later:

    an alternative to your callback solution:

    in jQuery you can set the ajax, along with the authenticity token. I use a helper function which calls link_to_remote, then strips the ‘onclick’ javascript, and passes it to jQuery.ajaxSetup, which stores the parameters within the jQuery namespace.

    I’m sure you can do the same with Prototype.

  8. Rick
    8 days later:

    Randy: That is wierd. It sounds like a quirk of the InPlaceEditor on Safari. That is not a good thing. I only have linux boxes so my range of testing is not that broad :/

    Darius: I like that idea, in fact, I prefer the way JQuery does things in almost all cases. I don’t know if Prototype offers that, I just used the example on their wiki page.

  9. Matthew
    13 days later:

    So what’s up with the options = options in editable-content-tag? Does that have some side effect I don’t know about, or is it a typo?

  10. Rick
    13 days later:

    In the call to content_tag i’m specifying that the parameter named options is being passed a hash named options. It looks funny,but it works.

  11. Matthew
    13 days later:

    Is that different than :options => options? Anyhow, options is the third parameter of content_tag, so you should be safe to just pass it in.

    Also, your ‘editable’ parameter doesn’t do what you’d expect: if you set it to false, the return value of the function will be nil rather than the tag value (because that block doesn’t get executed).

    And I think you can use to_json on the hashes, like so:

    edops = editOptions.to_json

    so long as you change the javascript that gets emitted by the helper so that it expects the curly braces.

    Just stuff I ran into while working off your code. Thanks. =)

Leave a comment