Skip to content

Instantly share code, notes, and snippets.

@ghigt
Last active December 20, 2015 12:18
Show Gist options
  • Select an option

  • Save ghigt/6129448 to your computer and use it in GitHub Desktop.

Select an option

Save ghigt/6129448 to your computer and use it in GitHub Desktop.
From Prototype to jQuery

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).

Merge old Pull Request and Master

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')).

Problems

While working on this issue, I encountered a series of problems:

  1. The first one is MarkUs' views. We should avoid creating so many .rjs files (more informations here).

  2. The second problem is the numbers of render of .js.erb (e.g. _boot.js.erb which 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, FilterTables owned by Prototype (things that we must remove in future to benefit of jQuery DataTables). 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 attributes data-something). I have solved the latter, but the former remains to be done.

  3. 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

Work on _boot.js.erb

<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_json

in 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 }
end

I 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, [...]}

Work on AnnotationCategories/

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.

Modals

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();' %>

From RJS to JS.ERB

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'].focus

To 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();

Conclusion

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.

Durant un peu plus de 2 semaines, j'ai eu l'occasion de découvir le Javascript au travers d'une issue sur le GitHub de MarkUs. Ce travail consistait à mettre à jour l'application MarkUs en migrant depuis Prototype vers jQuery. En effet, MarkUs étant actuellement implémenté avec la version 3.0 de Rails, la migration vers 3.1 entraine le changement de la librairie par défaut Prototype vers jQuery.

N'ayant jamais fait de Javascript auparavant, il a fallu que je me documente à la fois sur le JS en lui-même mais aussi découvir comment fonctionnait Prototype et jQuery. Ils ont heureusement une documentation assez fournie (Prototype API & jQuery API).

Merge old Pull Request and Master

Mon premier travail à dû être de mettre à jour -merge with origin/master- la Pull Request initiée par @m-bodmer (Pour plus d'information sur son travail [lien]). Il avait déjà travaillé sur l'intégration du jQuery dans MarkUs conjointement à Prototype. Nous utilisons pour cela l'appel à jQuery.noConflict(); qui nous permet de switch de l'un à l'autre (Voir la doc). Comme Prototype est par défaut, pour l'utiliser nous faisons juste appel à $. Le jQuery est lui géré tout simplement en appelant jQuery (e.g. jQuery('#elem') instead of $('elem')).

First Problems

  1. Le premier problème, IMO, avec les views dans MarkUs est qu'il y a trop de fichiers .rjs et que nous pourrions facilement faire autrement et ainsi éviter de créer des fichiers supplémentaires.
  2. Le second problème est qu'il y a trop de render de .js.erb (e.g. _boot.js.erb which is everywhere). Je pense qu'il faut les éviter au maximum et si possible tous les supprimer. Deux problèmes m'ont contrain à ne pas pouvoir -pour l'instant- les supprimer. Le premier est qu'il y a à l'intérieur de ces fichiers l'utilisation des FilterTables appartenant à Prototype (choses qu'il faudra supprimer par la suite au profit des DataTables de jQuery) que je n'ai pas touché. Et le deuxième -qui n'en est plus un- est qu'il y a du Ruby embedded dans ces fichers. Il existe des moyens pour résoudre ces problèmes comme, par exemple, envoyer des variables lors de l'appel au JS (un exemple est dans la suite).
  3. Un troisième problème est apparu lors de certains tests fonctionnels. Le format de la requette n'étant pas la bonne, j'ai été obligé d'expliciter ce dernier (:format => :js).
get_as @admin, :get_annotations,
               :assignment_id => @assignment.id,
               :id => @category.id,
               :format => :js

Work on _boot.js.erb

<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>

La première chose que j'ai faite est de supprimer les balises <script> sur fichier JS et les placer dans le corp du .html.erb sans modifier l'appel à ce fichier :

<script type="text/javascript">
  <%= render :partial => 'boot.js.erb' %>
</script>

J'ai aussi supprimé les balises //<![CDATA[ qui pour moi n'ont pas lieu d'être (cf. article sur stackoverflow). Le changement de la requette Ajax est documentée ici. L'action qu'appelait la requette Ajax faisait un appel à populate.rjs

page.call "populate", @students.to_json

que j'ai supprimé au profit la méthode .done() de l'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));
        });

Il a fallu pour cela que je fasse un appel explicite au format JSON en Ajax (dataType: 'json') et une réponse adéquate dans l'action :

respond_to do |format|
  format.json { render :json => @students }
end

Je n'ai pas modifié la fonction JS populate() maintenant appelée via la méthode .done() ce qui oblige à remettre l'objet data -tranformé par le jQuery (je n'ai pas cherché à changer son comportement)- en JSON à l'aide de JSON.stringify(data). Autre problème rencontré, l'appel à la partial s'attent à ce que ce soit au format JS. Pour modifier ce comportement, j'ai dû rajouter explicitement l'extension .html.erb.

result[:filter_table_row_contents] =
       render_to_string :partial => 'users/table_row/filter_table_row.html.erb',
                        :locals => {:user => user, [...]}

Work on AnnotationCategories/

Ce travail à consister à changer cette portion de 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();}" }
        }|%>

Premièrement, j'ai supprimé toute la partie JS pour la mettre dans un fichier .js. Ensuite, afin que ma requette puisse connaitre le path demandé, j'ai créé une variable path que j'ai envoyée à la fonction créée à l'aide de #{}. Ce qui donne :

<% path = add_annotation_category_assignment_annotation_categories_path(
            :id => @assignment.id) %>
<%= link_to_function t('annotations.add_annotation_category'),
                     :onclick => "add_annotation_category('#{path}')" %>

Côté JS, j'ai créé un fichier nommé index.js à l'intérieur de javascripts/AnnotationCategories/. Je pense que par la suite, il serait plus judicieux de suivre un template qui serait de créer pour chaque modèle un .js correspondant et non de faire -comme je l'ai fait- un dossier avec le nom de la view à l'intérieur. Donc la fonction add_annotation_category() ressemble à ça :

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();
    }
  }
}

Tout d'abord, j'ai découpé l'appel à l'élément en créant une variable intermédiaire category_prompt. Pour savoir si un element existe ou non -sachant que le jQuery, contrairement à Prototype, renvoie toujours un objet non null-, j'utilise .size() que je compare à 0 implicitement. Pour ce qui est de la requette Ajax, j'utilise la variable passée en paramètre pour l'url et je précise le type GET.

Modals

Ensuite, j'ai décidé de m'occuper des modals que nous sommes à chaque fois obligé de réimplémenter pour refaire la même chose. Exemple pour le fichier 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>

J'ai donc supprimé tous les modals qui ne fonctionnaient pas correctement (il en reste certaines qui fonctionnent mais qu'il faudra remplacer). Pour faire quelque chose d'assez générique, j'ai créé une "classe" en JS dans le fichier application.js -classe que l'on pourrait plutôt placer autre part...-.

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');
  }
};

Cette "classe" crée donc des modals avec une taille automatique et 2 méthodes open() & close() (je me suis inspiré de l'implémentation du fichier bootstrap-modal.js). J'ai aussi rajouté un dialogClass afin de rajouter une règle CSS permettant de supprimer le header et le footer des modals que je trouvais inutile.

.no-close .ui-dialog-titlebar,
.no-close .ui-dialog-buttonpane {
    display: none;
}

Pour instancier les modals, il suffit maintenant de faire ceci dans un .js :

jQuery(document).ready(function () {
  window.modal_upload = new ModalMarkus('#upload_dialog');
  window.modal_download = new ModalMarkus('#download_dialog');
});

Modal que l'on peut ensuite appeler comme suit :

<%= button_to_function t('download'), 'modal_download.open();' %>

From RJS to JS.ERB

Enfin, une des dernières choses réalisée est la migration des fichiers .rjs vers .js.erb -si le .rjs n'est pas facilement supprimable-. Pour ce faire, les APIs jQuery et Prototype sont indispensables, mais je me suis aussi aidé de divers documents facilement trouvable après un recherche Google (e.g. SlideShare). Quelques exemples : 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'].focus

To 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();

Conclusion

Il reste encore beaucoup de travail, mais c'est quelque chose de très intéressant à faire je trouve. Ça m'a permis d'apprendre le JS mais aussi de voir plus en profondeur comment fonctionnait MarkUs. Ça donne d'ailleurs l'occasion de faire apparaitre du code inutile, mal fait et remplaçable. Good luck ;)

G.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment