Showing posts with label restful rails. Show all posts
Showing posts with label restful rails. Show all posts

Monday, 28 April 2008

redirect_to POST

Our current site uses the acts_as_authenticated plugin. When a user's session has timed out, we redirect them to the login page, and save the URL they were trying to reach. If they successfully login, we do a redirect to that URL. This all comes as standard in the plugin.

The problem is that redirect_to doesn't seem to preserve the HTTP method (ie GET or POST etc). Most of the time, a user will have clicked on a link, or have a link saved in their bookmarks. The URL will be something like /users/42/orders which would be a a GET-based request that is supposed to show the users current orders. However, sometimes a user will have left their browser session open while they went off to lunch, displaying their set of orders... along with a set of buttons that, say, delete or clone an order.

The problem comes in when this timed-out user tries to click on one of those buttons. A timed-out user that clicks on, say, the "Clone this order" button gets the following error: no route found to match "/user/42/orders/23/clone" with {:method=>:get}

Buttons (which are really a form-post) generally have an HTTP POST action - even the ones with Rails-faked-up PUT or DELETE actions are really POST under the covers. Our nicely RESTful application is set up to not accept GET-based requests for dangerous (non-idempotent) actions such as "clone".

Now, acts_as_authenticated uses two methods for storing/redirecting to URLs. The first is called store_location. The code here grabs the URI that the user requested, and stuffs it into the session. The second is called redirect_back_or_default - it tries to redirect back to the uri stored in the session - or to a given default.

The method: redirect_to is the Rails-standard way of sending you on to another URL via an HTTP redirect. Unfortunately, it seems that this method only tries to use GET-based URIs, so when we tried redirecting to the POST-only URI for the "clone" request, we got the routing error.

At first I thought all that was needed was to save the request's HTTP method, and pass that into redirect_to. Saving the method is easily done, and I stuffed it into the session along with the URI. Then I dug around in vain trying to find out how to pass in the HTTP method as a parameter.

Reading all the doco yielded no joy, nor did digging into the rails core code to see how it deals with uris, sessions, requests or redirection. It wasn't until I delved deep into the HTTP spec itself, and asked a few questions on RoR Oceania that I finally confirmed that it simply isn't "done" to try to redirect a user to anything other than a GET-based URI.

This is somewhat disappointing, as I don't see why a GET has to be treated like something special by HTTP - the use-case for allowing redirection to any other verb is there. :(

Still, there's no getting around this limitation, and the system must fail gracefully (which the routing spew doesn't). So the next best thing is to capture an attempt to access a non-GET request and try something different.

I figured that most of the time when a user tries to get to a non-GET request, it'll be from a button from another page on the site. eg, they'll have left their brower open on the "My orders" page, and have clicked a button from there. It's far less likely they'll somehow have hooked up a bookmark to a POST-based URI, and just as unlikely that some other site will have a button to our site. Therefore, I figued the best option is to try sending the person back to the page they started on (ie the "My orders" page), rather than the page they are asking for (ie whatever button they clicked on).

This can be solved fairly easily. If the user asked for a GET-based request we have no problem, just store it as usual. But if they asked for something else, then we need to save the referer-URI instead. I've also added a flash error to tell the user what happened, and why they have to click the button again.

So the usual acts_as_authenticated code becomes:

    # Store the URI of the current request in the session.
    #
    # We can return to this location by calling #redirect_back_or_default.
    def store_location
      # if the user has asked for a non-get request (eg posted a form). We
      # can't redirect to that - so try getting the referrer (probably the
      # index page they came from). We will post them back to their previous
      # page and warn them about what is going on
      session[:return_get] = request.get?
      session[:return_to] = request.get? ? request.request_uri : request.env["HTTP_REFERER"]
    end
    
    # Redirect to the URI stored by the most recent store_location call or
    # to the passed default.
    def redirect_back_or_default(default)
      # if the user had asked for a non-get request (eg posted a form). We
      # can't redirect to that - so we must warn the user that they will not
      # be going where they were expecting
      flash[:error] = "You clicked on a button or submitted a form, but we cannot redirect you back to that. Please try submitting again." unless session[:return_get]
      redirect_to(session[:return_to] || default)
      session[:return_to] = session[:return_get] = nil
    end

Friday, 8 June 2007

Sliding Door Tabs take 2

So, a while back I wrote a quick widget for making sliding door tabs. But it wasn't enough, I wanted *MORE*. I wanted them to be responsive to conditions.

For example, I have a resource User, and I want the "users" tab to stay open no matter which user page I'm on (index, show, edit etc)... *unless*, of course, it happens also to be the "my account" page (ie I'm viewing myself)... for which I already have a tab. This is a cut above just being "on" if we happen to perfectly match the current url.

So I rewrote the helper methods. Now my tabs come with optional separators (that can be independantly styled), and with the ability to pass in a conditional to override whether or not to show the tab as "on".

  def make_tab(t)
    # use the conditional if one was passed - default to current page 
    cond = t.has_key?(:cond) ? t[:cond] : current_page?(t[:options])
    content_tag "li", link_to(t[:name], url_for(t[:options])),
                             :class => (cond ? 'current' : nil)
  end

  # Builds a tab that is just a separator
  def make_separator
    "<li class='separator'></li>"
  end

  # Make tablist out of a given set of tabs.
  def build_tablist(tabs, do_sep = true)
    list = "<ul>"
    tabs.each_with_index do |t,i|
      list << make_tab(t)
      list << make_separator if do_sep && i != tabs.size - 1
    end
    list << "</ul>"
    list
  end

And an example of use (that assumes existance of restful_auth)

<div id="topnav" class="tabNav">

    <% # standard set of tabs
      tabs = [] 
      if logged_in?
        tabs << {:name => 'dashboard', :options => dashboard_url}
        tabs << {:name => "my account", :options => user_path(current_user)}
        if current_user.is_admin?
          tabs << {:name => "widgets", :options => widgets_path, 
            :cond => (current_controller?(hash_for_widgets_path))}
          tabs << {:name => "users", :options => users_path, 
            :cond => (current_controller?(hash_for_users_path) && 
                      !current_page?(user_path(current_user)))}
        end
        tabs << {:name => 'logout', :options => logout_url}
      else
        tabs << {:name => 'login', :options => login_url}
        tabs << {:name => 'forgot password', :options => forgot_password_url}
      end
-%>
      
  <!-- tabbed browsing of main site areas --> 
  <%= build_tablist tabs, false -%>
</div>

Note that it also uses a convenience function: "current_controller?" This is below and could almost certainly be done better... but it currently serves to check if the given url-options match the current controller.

  # determines if the given set of options contains the current controller
  def current_controller?(options)
    return true if current_page?(options) # trivial case
    if options.respond_to?(:has_key?) && options.has_key?(:controller)
      return options[:controller] == controller.controller_name
    end
    # try matching it - assumes it's a string url
    options =~ /#{controller.controller_name}/
  end

Friday, 16 March 2007

Choices list

A basic CRUD list page tends to have various ways of manipulating the content - sorting, filtering etc. Most of these apply to a single column of data, so the UI will generally end up with a button, link or other input in the column-head. I found that I often needed a list of choices where some referred to a single column and others didn't (eg "Sort by: date, popularity, controversy") or choices that have no bearing on the data at all (eg "View as: User, Moderator, Admin").

It was annoying to have to type out the HTML for these lists of choices each time, with appropriate styling to highlight the "current item" etc so I wrote up a helper. My example uses RESTful paths - but could be quickly modified for legacy paths too.

    def make_choicelist(title,fieldname,choices,path_hash)
    # creates a horizontal list displaying a set of choices the user can
    # pick from including urls for each choice.
       cur_val = params[fieldname]
       str = "<div class=\"hlist\"><span class=\"head\">#{title}</span>"
       choices.each do |label,value| 
         path = url_for path_hash.merge(fieldname => value)
         str << "<span class=\"#{(cur_val && cur_val == value.to_s) ? "current" : "item"}"
         str << "\">#{link_to_unless_current(label, path)}</span>"
       end 
       str << "</div>"
       str
    end

You can then pass it:

  • The title of the choice list
  • The name and current value of the field that saves the state of this item
  • The set of choices (label+value) that this field can take
  • The basic URL of the page apart from this field - as a hash (so we can merge in the new value)
    
   <%  choices = [["Admin", :admin],
                ["Moderator", :mod]
                ["User", :user]] -%>
  <%= make_choicelist "View as:", :user_type, choices, 
                 hash_for_widgets_path -%>

The code will generate the list with the given title, and will add each option after it - highlighting whichever is the current choice. You can pair this with your stylesheet to restyle the choice-list appropriately. A really basic style example is below:

/* used for horizontal lists - generally for depicting choices */
.hlist {
  padding: 0px;
  background-color: #EEE;
  color: #333;
  border: 1px solid #333;
}
/* short, descriptive heading for the list */
.hlist .head {
  padding-left: 10px;
  padding-right: 10px;
  font-weight: normal;
}
/* list items */
.hlist .current, .hlist .item {
  font-weight: bold;
  margin-right:3px;
}
/* the currently-selected item */
.hlist .current {
  padding: 3px;
  border: 2px inset black;
  background-color: #333;
  color: #CCC;
}
/* an unselected item */
.hlist .item {
  background-color: silver;
  font-weight: bold;
}
/* the link for an unselected item */
.hlist .item a {
  border: 2px outset black;
  color: #333;
  background-color: silver;
  text-decoration: none;
}
.hlist .item a:hover {
  border: 2px inset black;
  background-color: #444;
  color: #CCC;
}

Which will give you something that looks like this: