www.flickr.com
Michael Kovacs' photos More of Michael Kovacs' photos
Recommend Me Cable Car Software logo

Sunday, July 02, 2006

Rails realities part 15 (AJAX modal dialog)

I spent some time this past week looking at the various solutions out there for creating a modal dialog and while I was nearly able to make each of them work there were varying degrees of ugliness required.


First off my requirements:


I need to have a page containing a form that also contains a few different lists of user created items. Now I know that some will think that this might be a usability nightmare but in the flow of the user it makes sense and works. So imagine if you will:

< form>
(text fields, area)
list1
< a href>Add new thing to list1< /a>
list2
< a href>Add new thing to list2< /a>
list3
< a href>Add new thing to list3< /a>
(text fields)
submit
< /form>

Now there are some problems with the above requirement. Namely how do I get items into the list without taking the user to another page or doing a full page refresh? I can't create inline popup divs for each list to allow users to add new entries to each list because that requires a form tag and you can't have a form that lives inside of a form.

< form>
(text fields, area)
list1
< a href>Add new thing to list1
< div id='list1_dialog' style='display: none;'>
< ajax remote form id='dialog_form'>
fields...
submit button
< /form>
< /div>
...
< /form>


That leaves the options of having a div that lives outside of the form and is shown to the user for adding/editing items in the list.

Believe it or not accomplishing that is not an easy feat mainly because I want a modal dialog that will allow for adding/editing these lists without doing a page refresh. Luckily I was able to figure out a solution and while I'm sure all of the following modal dialog implementations will work I only got my solution working with just one, no doubt due to my own javascript deficiencies.

The 3 modal dialog contenders were:
  1. Submodal
  2. Prototype window class
  3. Lighbox gone wild

All of these implementations are just fine and I was able to get a modal dialog working with each of them in fairly short order.

That said, the implementation that I ended up using was lightbox gone wild.
(If you'd like to play along at home I'd suggest going to download LGW and follow along with the posting as it will make things clearer. Just put lightbox.js and lightbox.css in your /javascripts and /stylesheets directory of your rails app, refer to them in your .rhtml page where you'd like to pop the dialog from and you'll be set.)

LGW uses prototype in its implementation just like the prototype window class implementation, but it was considerably cleaner, smaller, and easier to understand. The whole implementation itself is just over 200 lines of javascript code and one stylesheet.

In fact if you don't need anything special you don't even need to write a lick of code to get the thing working.
The following is the minimum required HTML to get a modal dialog working with LGW.

< link rel="stylesheet" href="css/lightbox.css" type="text/css" />
< script type="text/javascript" src="scripts/lightbox.js">< /script>
< a href="text.html" class="lbOn">About Lightbox< /a>

This code creates a link to text.html. When the link is clicked lightbox will create a modal dialog that contains the contents of text.html

Inside of text.html you create a link to close the dialog...

< a href="#" class="lbAction" rel="deactivate">close< /a>

That's it!

Under the covers there's some cool prototype javascript hacks going on. Namely there are event listeners being setup for all of the elements that are tagged with 'lbAction' and 'lbOn'.

For items tagged with 'lbOn', when they are clicked a modal dialog is created and shown to the user.

For items tagged with 'lbAction' an event listener is created for click events on those items. The callback that is associated with the click is specified by the 'rel' attribute on the element. So in our example above the close link will have an eventlistener created on it that calls back to 'deactivate'.

Seems simple enough and elegant so I should be all set right? Unfortunately no. Now I may very well be missing something but I couldn't get things to work properly, in IE of course, without some trickery that I'll go over.

First my link in 'page.rhtml':

< %=link_to 'Add contact', {:action => 'add_contact_dialog'}, :class => 'lbOn'%>

That link triggers my dialog. In my dialog screen (dialog.rhtml) I have:

< %=form_remote_tag :html => {:id => 'add_contact_form', :name => 'add_contact_form'}, :url => {:action => 'add_contact'}%>
< %=submit_tag button%>< a href="#" class="lbAction" rel="deactivate">cancel< /a>
< %=end_form_tag%>

Submitting this form to 'add_contact' renders my RJS template (add_contact.rjs):

page.insert_html :bottom, 'ContactList', :partial => 'list_entry', :object => @contact, :locals => {:hide => true}
page.visual_effect :blind_down, @html_id
page.call 'add_lightbox', 'edit'+@html_id

What this template does is
  1. take the contact I created, create a list element for it with "edit" and "delete" links. The element is not shown to start because we...
  2. create a visual effect to make the element appear that indicates to the user that a new item was added to the list.
  3. create a lightbox listener for the "edit" link I created so clicking the link pops the dialog and loads this contact's information for editing.
The page.call is calling a javascript function that I created (add_lightbox) and added to lightbox.js, that takes the given DOM id and creates a listener in addition to some other things...

function add_lightbox(id) {
dismiss();
value = new lightbox($(id));
}

function dismiss() {
lightbox.prototype.deactivate();
if(browser == 'Internet Explorer') {
// this is here because lightbox doesn't scroll IE after dismissing after we post forms
window.scrollTo(0, lightbox.prototype.getYPos());
}
}

As you can see there's some extra stuff going on besides just adding a new lightbox to the element that was passed in. This is the critical point in the implementation where I could only get things working by doing things in this particular order.

There are three important points here:
  1. deactivate() must be called from the RJS template (In this instance it is called by dismiss()). It will *NOT* work in IE6 if you try to use a callback with the remote_form_tag, eg:

    < %=form_remote_tag :html => {:id => 'add_ref_form', :name => 'add_ref_form'}, :url => url, :complete => 'lightbox.prototype.deactivate();'%>

    Why? I have no idea. I tried other options as well, :loaded, :loading, nothing worked. Of course it works fine with Firefox and Safari. I didn't check IE7 because quite frankly it doesn't matter yet. I need IE6 to work.
  2. You must scroll IE after dismissing the dialog.
  3. You can only create the lightbox for the newly inserted item after the request is complete because the unique identifier for the edit link isn't available until after it's created. To be fair you could come up with another convention but why? It makes sense to use the ID of the thing you're editing.
IE and scrolling:
If you read the tutorial on the LGW page you'll see that they talk about the problem with IE not scrolling back to where it was and that they have code that fixes it. Unfortunately I couldn't get it to work whenever I performed an AJAX form submission. What I ended up doing was creating a class level variable in the lightbox implementation that I use to store the Y-axis position of the scrollbar (I don't care about the X-axis position because my page will never scroll horizontally). I doubt this is the optimal way to accomplish this but hey, it works.

Anyway, the modification of the lightbox.js code is as follows...
Add the function definition 'getYPos' to the prototype object in the lightbox.prototype declaration (hint: put this after the last function in the prototype declaration and put a ','
after the last function definition before pasting in the following code.)

getYPos: function() {
return lightbox.yPos;
}

Then modify the getScroll function as follows:

getScroll: function(){
if (self.pageYOffset) {
this.yPos = self.pageYOffset;
} else if (document.documentElement && document.documentElement.scrollTop){
this.yPos = document.documentElement.scrollTop;
} else if (document.body) {
this.yPos = document.body.scrollTop;
}
lightbox.yPos = this.yPos; <-- set the class level property
},

This modification sets the class variable yPos on lightbox whenever the dialog is activated and adds an accessor method to get the value.

So going back to the flow of the add_lightbox called from our RJS template we..
  1. dimiss the dialog
  2. scroll the window to the previous position if IE
  3. create a new lightbox listener for the newly added DOM element
That's it! (heh, not quite as simple as the first example but still not bad now that I've already suffered through the pain of figuring it all out)

The code to edit is very similar except that we don't add the lightbox listener we simply call our dismiss() function after making the edit and HTML update in our RJS template (edit_contact.rjs) as follow:

page.replace_html @html_id, @contact.to_s
page.call 'dismiss'
page.visual_effect :highlight, @html_id, :duration => 3


I hope this is clear to folks. I was thinking of either coming up with a patch, plugin, or submit this to the folks over at LGW and see if they can improve upon it. But for people that need a modal dialog that needs to contain a form and that will dismiss when you click "OK" as well as submit the form, this solution is pretty easy and will suit your needs.

Enjoy!

P.S. - If you liked this post how about a Digg?

Comments:
First, thank you for this post. I am greatly appreciative of the work you have done here and it gets me almost to the point of having my own modal dialog form implemeneted. That being said, you lost me here:

That link triggers my dialog.

I have an this in my initial view:

link_to 'Add New Broker', { :action => 'new' }, :class => 'lb0n'

In my controller, this is where I need to launch my dialog for new.rhtml that contains the following:

form_remote_tag :html => { :id => 'add_broker_form', :name => 'add_broker_form' }, :url => { :action => 'create_broker' }

submit_tag a href = "#" class="lbAction" rel="deactivate" Cancel
end_form_tag

My question is, how do I get that dialog launched in my controller?

Thank you again.
 
Hi Diamond,

If I'm understanding your question properly, new.rhtml contains your dialog code, which you've stated, so when the "Add new broker" link is clicked the lightbox implementation will call your action, "new", and put the result of that action call in a lightbox. The class 'lbOn' is the trigger for the lightbox to be displayed.

I hope this answers your question, if not feel free to follow up :-)
 
Thanks a lot for this awesome peice of work! However, although it works in the following -

link_to "Login", {:controller => "main", :action => "index"}, :class => "lbOn"

, it doesn't seem to work in the following case -

link_to "Add item", {:controller => "motorcycle", :action => "add_item_form"}, :class => "lbOn"

It just takes me to a view of the contents of the rhtml (rendered) as a page of its own.

They both look essentially the same to me, I can't figure out what is going on... The only difference is that in the first case, the link is on a page by itself, whereas the second has it inside another div tag.

Also, the first one essentially just renders an rhtml file, whereas the second renders a partial.

[read class instead of clas - blogger doesn't allow class attribute inside html within comments]
 
Hi Amit,

From the sound of it I would guess that you don't have the lightbox.js included on the page with the second link. What happens when you load a page that includes the javascript is that it fetches all elements in the page that are of class 'lbOn' and attaches a listener on the 'click' event which opens the dialog. Whenever the link just displays the target page in its own page instead of the lightbox it's because this listener wasn't setup.

Hope that helps.
 
Hi Michael,

Thank you very much for your sample modal dialog. It helps me a lot on my project.

However, I can only make the add new work, not for the edit one.
 
Hi,

I got the LGW example to work on a simple Rails served page but when I tried using it on a partial that was rendered after the initial page load it didn't work at all, basically functione as a standard html link. I had the stylesheet and css links in my Rails application.rhtml.

Have you got any thoughts or ideas on what I am doing wrong?
 
@billie - So if you have it working for add the edit dialog should be trivial. Just take the same RJS template from your add and simply call the dismiss() function. Perhaps you can provide more detail about what you're doing?
 
@shawn - Without seeing some code it's hard to say what the problem is, but be sure that you've got your link's class attribute set to 'lbOn'. That will trigger the dialog being shown. The action you specify in your link is what will be displayed in the dialog.

Example:
link_to 'Add something', :action => 'new_something_dialog', {:class => 'lbOn'}

The in your controller you can render a partial that's returned and displayed in the dialog.

Example:
def new_something_dialog
render :partial => 'something_dialog'
end

In that partial you have your ajax remote form tag that submits the form to the controller.

Example:
(in 'something_dialog' partial)

form_remote_tag :url => {:action => 'add_new_something'}

(in your controller)
def add_new_something
# do stuff to add
# this action has an RJS template that dimisses the dialog with the functions I've specified in the posting.
end

I hope this helps clarify things a little for you. And yes, having the javascript and stylesheet in application.rhtml is fine.
 
@Sean - Sorry about the misspelling of your name, realized it after I posted :-)
 
Michael, thanks for responding. Here is the view fragment that will call the LGW 'popup':

link_to 'Test', {:action => 'help',:controller=>'welcome'}, :class => 'lbOn'


Pretty simple to test the concept.

This works fine when I embed it in the page that is loaded normally. However, it gets quirky when I embed the above fragment in a Rails partial that is loaded in from the initially loaded page using the remote link thingy in Rails. Does that make sense? It should be pretty easy to recreate. Just have a remote link to in Rails that loads a new partial onto the current page and in that newly loaded partial embed the above LGW link code. It won't behave as a LWG link but as a standard HTML anchor.

I hope this clarifies things.

Don't stress over the misspelling, I've seen it all, although only since I left Ireland.
 
Hey Sean,

So I think I understand the problem. It's actually the same problem that exists when you want to edit something that you've added to that page via the remote form post and updated the page with new HTML.

To clarify, in my posting I add a new thing to a list of things via that dialog. After the new thing is added I update the original page and add a new item to the list of "things". I do that with an RJS template. In that RJS template you'll see that I not only add a new item to the list of things but I also register a new LBGW listener to attach to the "edit" link in that new list item.

Your problem sounds analogous to this same situation. LBGW only initializes links containing a class='lbOn' when the page is loaded. If you perform a remote request and update your original page with HTML that contains a link with the class='lbOn' attribute it won't work because that link didn't exist when the page was loaded and the initialization cycle took place for all the lbOn links. What you'd need to do is to add a new LBGW listener yourself which is what I do in the snippet of javascript that's in my posting and that I call from my RJS template.

I hope this makes sense to you and clarifies what you'd need to do to make your link work properly.
 
Sean,

So to clarify the javascript you'd want to execute after you add your new link via that remote call is:

new lightbox($(id_of_your_new_link));

This would initialize a listener for that newly added link.
 
Excellent Michael. You're a star. That extra bit of script did the trick. I thought it was something like that but I wasn't familiar enough with the LGW API to get the right code.

I think this is a great technique, integrating easily with Rails, and a much better alternative to pop up windows or endless page re-directs for the user.

Thanks again
 
Hello.

I had a similar requirement in my project, and ended up settling on the Xilinus Prototype Window approach - see the following blog entry for my reasons...

let's get modal

Cheers,
Rich :)
 
HI ,
Can any one can provide me with code of PDT in java.
And if possible plz do reply me at gourav.ajmani@gmail.com
 
I have worked with some similar solutions and have found a plugin thats is used for iframe uploads that works well for closing the current window on post success and updates the document in the background. It's a rails plugin called responds_to_parent. It really works well for this. I used it for a 'add a note' modal that updates the list of notes in the parent document without refresh. May be something worth looking at.
 
Hi Michael. I've finally gotten around to updating my post on modal dialogs. See this newer entry:

more on modal dialogs

This deals with performing ajax requests after the dialog is closed. Hope it helps you out.
 
I am looking for a solution to have a modal window contain a form that will email the information to the site owner when a client inserts the information and clicks submit.

I have come across this solution and was trying to clarify if this would be good for my project. I know this one will allow me to use a form, but is it able to email the information.

If this is not the one for me, could anyone direct me to the right solution. Thanks for any help!
 
Good job. Your posting is really helpful.
 
How does one manage a scrollable area within the lightbox?
 
Using the modal method outline above how would you do form validation?
 
I've done validation one of two ways:

1) Javascript before submission
2) update a status div in the modal dialog window

Option 2 is what I usually do these days.
 

Post a Comment





<< Home

This page is powered by Blogger. Isn't yours?