For a little over two weeks, I had the opportunity to learn JavaScript by working on an issue on the MarkUs's GitHub. This issue is about updating the MarkUs application from Prototype to jQuery. Indeed, in order for MarkUs to migrate to Rails 3.1 (it is currently implemented with Rails 3.0), it has to support the new default javascript library, jQuery, hence motivating this work.
As I had never programmed in Javascript before, the first step was to document myself on javascript, but also to learn how Prototype and jQuery worked. Fortunately, these two libraries have a fairly extensive documentation (Prototype API & jQuery API).
A student, @m-bodmer had already tackled this issue a while back when working on MarkUs. Thankfully, he had created a pull request (link), and I could easily retrieve his branch to update it with the recent changes in the codebase, and understand his patch.
He worked on the jQuery integration, while still keeping Prototype in parallel.
For this, we used the call jQuery.noConflict(); which allows us to switch between one and the other (see the
doc).
As Prototype is the default, we call it with $ (ie, the default). jQuery is simply managed with calling jQuery (e.g. jQuery('#elem') instead of $('elem')).
While working on this issue, I encountered a series of problems:
-
The first one is MarkUs' views. We should avoid creating so many
.rjsfiles (more informations here). -
The second problem is the numbers of
renderof.js.erb(e.g._boot.js.erbwhich is everywhere). We should, if possible, remove them all. Yet, to do so, we need to solve the following two problems. First, there are, within these files,FilterTablesowned by Prototype (things that we must remove in future to benefit of jQueryDataTables). Second, these files have embedded Ruby. There are ways to solve these problems (for example, sending varables when calling JS or sending data with new html attributesdata-something). I have solved the latter, but the former remains to be done. -
The third problem appears in some functional tests. The request format is not good, so I was forced to specify it (
:format => :js).
get_as @admin, :get_annotations,
:assignment_id => @assignment.id,
:id => @category.id,
:format => :js<script type="text/javascript">
//<![CDATA[
jQuery(document).ready(function() {
users_table = new FilterTable({
[...]
});
});
new Ajax.Request('<%=populate_students_path()%>',
{asynchronous:true, evalScripts:true,
onComplete:function(request){$('loading_list').hide();},
parameters: 'authenticity_token=' + AUTH_TOKEN})
//]]>
</script>First, I removed the <script> tags from JS files and placed them in the body of the .html.erb without changing the call to this file:
<script type="text/javascript">
<%= render :partial => 'boot.js.erb' %>
</script>Then, I removed the //<![CDATA[ tags which -for me- should not be here (cf. article on stackoverflow).
The change of Ajax request is documented here.
I removed the populate.rjs called by the request
page.call "populate", @students.to_jsonin benifite of .done() method of Ajax.
jQuery.ajax({
url: "<%= populate_students_path() %>",
data: 'authenticity_token=' + AUTH_TOKEN,
type: "POST",
async: true,
dataType: 'json'
}).done(function (data) {
jQuery('#loading_list').hide();
populate(JSON.stringify(data));
});This required an explicit call to JSON in Ajax (dataType: 'json') and an adequate response in the action:
respond_to do |format|
format.json { render :json => @students }
endI haven't change the populate() JS function now called by the .done() method. It requires to change the data object -transformed by the jQuery (I haven't tried to change its behavior)- in JSON using JSON.stringify(data).
Another problem, the call of the partial. Rails now expects a partial in JS. To change this behavior, I had to explicitly add .html.erb extension.
result[:filter_table_row_contents] =
render_to_string :partial => 'users/table_row/filter_table_row.html.erb',
:locals => {:user => user, [...]}This work was to change this piece of code:
<%= link_to_function t("annotations.add_annotation_category"), %| if($('add_annotation_category_prompt') != null) {
$('add_annotation_category_prompt').select();
$('add_annotation_category_prompt').focus();
} else {
#{remote_function :url => {:action => "add_annotation_category", :id => @assignment.id}, :method => 'get', :after => "if($('no_annotation_categories_info') != null) { $('no_annotation_categories_info').hide();}" }
}|%>First, I removed all the JS part to put it in a .js file.
Then, so that my request knows the required path, I created a variable path I send to the created function function using #{}. Which gives:
<% path = add_annotation_category_assignment_annotation_categories_path(
:id => @assignment.id) %>
<%= link_to_function t('annotations.add_annotation_category'),
:onclick => "add_annotation_category('#{path}')" %>JS side, I created a file named index.js inside javascripts/AnnotationCategories/.
I think eventually, it would be wise to follow a template that would be to create for each model a .js corresponding and not to make -as I did- a folder with a view's name inside.
So the function add_annotation_category() looks like this:
function add_annotation_category(path) {
var category_prompt = jQuery('#add_annotation_category_prompt');
if (category_prompt.size()) {
category_prompt.select();
category_prompt.focus();
} else {
jQuery.ajax({
url: path,
type: 'GET'
});
var info = jQuery('#no_annotation_categories_info');
if(info.size()) {
info.hide();
}
}
}First, I cut the call to element by creating an intermediate variable category_prompt. To find out if an element exists or not -knowing that jQuery, unlike Prototype, always returns an object not null-, I use the .size() method I compare with 0 implicitly.
Regarding the Ajax request, I use variable passed as parameter for the URL and I specify the type GET.
Before I tackled this work, each time we used models, we had to recode part of the javascript.
Then, I decided to take care of modals, so that we whenever have to recode to do the same thing. Example for the file annotation_categories/_boot.js.erb:
<script type="text/javascript">
//<![CDATA[
var modalUpload = null;
var modalDownload = null;
function upload_annotation_categories(){
modalUpload.open();
return false;
}
function download_annotation_categories(){
modalDownload.open();
return false;
}
document.observe("dom:loaded", function(){
modalUpload = new Control.Modal($('upload_dialog'),
{
overlayOpacity: 0.75,
className: 'modalUpload',
fade: false
});
modalDownload = new Control.Modal($('download_dialog'),
{
overlayOpacity: 0.75,
className: 'modalDownload',
fade: false
});
});
//]]>
</script>As this is not "DRY" (do not repeat yourself, one of Rails's moto), I decided to created a javascript class, that we can reuse. I first removed all modals not working properly. Then, I created a ModalMarkus class in the application.js file (we should place it in another file):
var ModalMarkus = function (elem) {
this.elem = elem;
this.modal_dialog = jQuery(this.elem).dialog({
autoOpen: false,
resizable: false,
modal: true,
width: 'auto',
dialogClass: 'no-close'
});
};
ModalMarkus.prototype = {
open: function () {
this.modal_dialog.dialog('open');
},
close: function () {
this.modal_dialog.dialog('close');
}
};This "class" thus creates modals with automatic size and two methods open() & close() (I took inspiration from the implementation of bootstrap-modal.js file).
I also added a dialogClass to add a CSS rule to remove the header and the footer of modals I found unnecessary.
.no-close .ui-dialog-titlebar,
.no-close .ui-dialog-buttonpane {
display: none;
}Thanks to this work, we can initialize modals by simply adding this piece of code in a javascript file:
jQuery(document).ready(function () {
window.modal_download = new ModalMarkus('#download_dialog');
});And modals can be called with:
<%= button_to_function t('download'), 'modal_download.open();' %>Finally, one of last things I did is the migration of files from .rjs to .js.erb -in the case that the .rjs is not easily deletable-. To do this, the jQuery & Prototype APIs are essential, but I also found help in various documents easily findable after a Google search (e.g. SlideShare).
Some examples:
From Prototype
page.replace 'add_annotation_category', :partial => 'annotation_category', :locals => {:annotation_category => @annotation_category}
page.insert_html :top,
'annotation_category_pane_list',
:partial => 'new_annotation_category',
:locals => {:assignment_id => @assignment.id}
page['add_annotation_category_prompt'].focusTo jQuery
jQuery('#add_annotation_text').replaceWith("<%= escape_javascript(render :partial => 'annotation_text', :locals => { :annotation_text => @annotation_text }).html_safe %>");
jQuery('#annotation_category_pane_list').prepend("<%= escape_javascript("#{render :partial => 'new_annotation_category', :locals => { :assignment_id => @assignment.id }}").html_safe %>");
jQuery('#add_annotation_category_prompt').select();There is still a lot of work to do, but it is, I think, interesting work to do. Thanks to this issue, I learnt JS, but I also saw more in depth how MarkUs worked. I also gives the opportunity to appear unnecessary, poor made and replaceable code.
G.