Blog tech

Rails et Javascript

Rédigé par Nicolas Zermati | 20 novembre 2013

Depuis quelques années le Javascript prends une place de plus en plus prépondérante dans les applications web. Voyons aujourd’hui ce que Rails propose comme mécanismes permettant de simplifier l’introduction de Javascript dans les applications au travers de jquery-ujs.

Comme le laisse supposer son nom, cette bibliothèque dépends de jQuery. Jquery-ujs a plus de trois ans et a été introduite dans Rails 3.

Objectifs

L’objectif de la bibliothèque est d’ajouter, de manière discrète en terme de balisage HTML, trois comportements :

  • l’ajout de messages de confirmation lors d’un clic sur un lien,
  • l’utilisation de verbes HTTP autre que le GET lors d’un clic sur un lien et
  • les requêtes asyncrhones au serveur lors des évènements suivants :
    • liens,
    • boutons,
    • formulaires et
    • champs de formulaire (select, textarea, input).

Attention, les deux derniers comportements rendront probablement votre site inutilisable à quelqu’un qui n’utilise pas Javascript, que ce soit pour des raisons de sécurité ou encore d’accessibilité. Ce n’est pas systématique mais il faudra être rigoureux pour que ce ne soit pas le cas.

Ajout de messages de confirmation

En ajoutant un attribut data-confirm à un lien, Rails va :

  • déclencher un évènement confirm sur le lien,
  • appeler la fonction $.rails.confirm(message)message est la valeur de l’attribut et
  • déclencher un évènement confirm:complete sur le lien.

La fonction $.rails.confirm appelle la fonction window.confirm. Il est possible de surcharger $.rails.confirm avec une fonction de son choix pour, par exemple, utliser une fenêtre modale stylisée à la place du contrôle par défaut du navigateur.

L’évènement confirm, s’il est capturé et que son handler retourne false, va annuler l’appel à $.rails.confirm et empêcher le lien d’être suivi.

L’évènement confirm:complete, s’il est capturé alors son handler aura à sa disposition la réponse de l’utilisateur :

$('a[data-confirm]').on('confirm:complete', function(event, answer) {
    if (answer) {
      // User has confirmed the action
    } else {
      // User has canceled the action
    }
});

Il est peu probable qu’utiliser data-confirm perturbe l’aspect fonctionnel de votre application. En effet, un utilisateur qui ne dispose pas du javascript d’activé n’aura tout simplement pas les messages de confirmation.

Rails propose une option dédiée pour son helper link_to afin de générer l’attribut data-confirm.

<%= link_to "Retour", '/', confirm: "Êtes vous sûr ?" %>

Utiliser un verbe HTTP différent de GET avec des liens

Rails utilise les verbes HTTP pour différencier deux routes ayant la même URL. Les navigateurs ne prennent pas tous en charge tous les verbes. Pour pallier à ce problème une convention en place depuis longtemps consiste à envoyer le verbe HTTP souhaité dans un paramètre nommé _method. Lorsque Rails va recevoir une requête POST, il va tenter de surcharger le verbe HTTP avec la valeur du paramètre _method.

Lorsqu’un formulaire est généré en rails à l’aide de form_tag et de son option method, un champ caché nommé _method est inséré dans le formulaire. Pour les liens, Rails propose également une option method. Plutôt que de générer du balisage HTML supplémentaire, c’est jquery-ujs qui va prendre le relais.

<!-- Exemple du formulaire HTML utilisant le verbe PATCH -->
<form action="/posts/16" method="POST">
  <input type="hidden" name="_method" value="PATCH" />
  <!-- Plus de champs... -->
  <input type="submit" value="Modifier" />
</form>

Les liens qui disposent de l’attribut data-method seront transformés en un formulaire caché au moment ou l’on cliquera sur le lien. Ce formulaire contiendra le champ _method et ce dernier prendra la valeur de l’attribut data-method. C’est ce formulaire qui sera envoyé et le lien ne sera pas suivi.

<%# Générer un lien utilisant le verbe DELETE %>
<%= link_to "Supprimer", "/posts/32", method: :delete %>
<!-- Formulaire réellement envoyé lors du clic sur le lien -->
<form method="POST" action="/posts/32">
  <input name="_method" value="delete" type="hidden" />
</form>

Cette technique ne fonctionne plus dès lors que le navigateur utilisé ne permet pas l’exécution du javascript. En effet, l’URL du lien sans le bon verbe HTTP ne permet pas de trouver la route attendu au sein de Rails.

Requêtes asynchrones

Le dernier élément offert par jquery-ujs, et certainement le plus important, est la mise en place de requêtes asynchrones.

Grâce à l’attribut data-remote, certains éléments vont déclencher une requête asynchrone vers le serveur web. Nous allons voir quels autres attributs sont utilisables, quels effets produisent ces autres attributs ainsi que quelques exemples.

Paramétrage de la requête

Les attributs qui sont utilisables en plus de data-remote sont :

  • data-type qui permet de choisir le type réponse attendue parmi xml, json, script, ou html,
  • data-cross-domain qui permet de définir l’option crossdomain (voir la documentation de jQuery) et
  • data-with-credential qui permet d’ajouter l’option withCredentials (à nouveau dans la documentation de jQuery)

Ces attributs peuvent être ajoutés sur tous les éléments supportant data-remote. Par défaut l’option type de $.ajax est GET et le type de réponse attendue est non défini (entête Accept à */*).

Il existe aussi l’attribut data-disable-with que l’on va pouvoir placer sur des champs de formulaire. Ces champs seront désactivés lorsque le formulaire parent est envoyé en asynchrone et sont réactivés lorsque la requête se termine.

L’exemple suivant affiche En cours... à la place de Envoyer dans le bouton de validation durant le traitement asynchrone du formulaire. Il désactive également le bouton pendant ce temps évitant de soumettre deux fois la même requête lors d’un double clic.

<form action="/posts/64" method="POST" data-remote="true">
  <!-- Autres champs -->
  <input type="submit" value="Envoyer" data-disable-with="En cours..." />
</form>

Selon l’élément qui utilise data-remote, les éléments utilisés pour construire la requête diffèrent. Ci-dessous, se trouvent le détails de ces éléments.

Formulaire

Pour les formulaires, le paramètre type de $.ajax est récupéré depuis l’attribut method.

L’URL de la requête est extraite de l’attribut action.

Les données envoyées sont extraites des champs du formulaire grâce à la fonction serializeArray de jQuery (doc).

Si vous utilisez un bouton (button) pour soumettre le formulaire, sa valeur sera également ajoutée.

Champ de formulaire ou bouton

Pour les champs de formulaire ou les boutons, le paramètre type de $.ajax est récupéré depuis l’attribut data-method.

L’URL de la requête est extraite de l’attribut data-url.

La donnée envoyée est la valeur du champ de formulaire à laquelle est ajoutée la valeur de l’attribut data-params s’il est présent. Ce dernier attribut doit être encodé comme une URL (name=val&foo=bar).

Lien

Pour les liens, le paramètre type de $.ajax est récupéré depuis l’attribut data-method.

L’URL de la requête est extraite de l’attribut href.

La donnée envoyée est la valeur de l’attribut data-params s’il est présent.

Quelques exemples

On se place dans le classique exemple du blog. On a une page d’administration qui est une sorte de single page app, c’est à dire que la page est rendue une première fois et chaque action effectuée sur celle-ci modifie la page.

Attention, lorsque vous modifiez les éléments de votre page dynamiquement, il faut être vigilent sur la gestion des évènements. En effet, si vous liez directement les éléments du DOM pour gérer les évènements alors il faudra refaire cette liaison a chaque changement du DOM. Si, au contraire, vous utilisez le mécanisme de propagation des évènements, il vous sera possible modifier le DOM tout en conservant votre gestion d’évènements.

// N'utilisez pas
$('.partial .element').on('click', function() { ... });

// Utilisez plutôt
$(document).on('click', '.partial .element', function() { ... });

Créer un billet de manière asynchrone

-# app/views/posts/index.html.haml

%section
  %h2 Créer un nouveau billet
  = render partial: "form"

%section
  %h2 Liste des billets existants
  = render partial: "list"
-# app/views/posts/_form.html.haml

= form_for @post, remote: true, html: { class: 'post' } do |f|
  %div{ class: @post.errors[:title] ? "error" : "" }
    = f.label :title
    = f.text_field :title

  %div{ class: @post.errors[:content] ? "error" : "" }
    = f.label :content
    = f.text_area :content

  = f.submit "Envoyer", disable_with: "En cours..."
-# app/views/posts/_list.html.haml

%ul#post-list
  - Post.all.each do |post|
    = render partial: "list_elem", locals: { post: post }
-# app/views/posts/_list_elem.html.haml

%li.post
  = post.title
  = link_to "Supprimer", post_path(post), remote: true, method: :delete, confirm: "Êtes vous sûr ?", class: 'delete'
# app/controllers/posts_controller.rb

class PostsController < ApplicationController
  respond_to :js, only: [:create]

  def index
    @post = Post.new
  end

  def create
    @new_post = Post.new(params.require(:post).permit(:title, :content))
    @success  = @new_post.save
    @post     = @success ? Post.new : @new_post
  end
end
// app/views/posts/create.js.erb

$("form.post").replaceWith("<%= escape_javascript( render( partial: 'form' ) ) %>");

<% if @success %>
  $("#post-list").append(
    "<%= escape_javascript( render( partial: 'list_elem', locals: { post: @new_post } ) ) %>"
  );
  window.alert("Votre billet a été créé.");
<% else %>
  window.alert("Erreur lors de la création de votre billet.");
<% end %>

Si vous avez suivi correctement vous avez compris que :

  • l’utilisateur arrive sur la page d’index,
  • il voit deux sections : création d’un billet et liste des billets,
  • lorsqu’il créé un billet, il fait une requête asynchrone,
  • si le billet est sauvegardé correctement :
    • le formulaire est remis à zéro,
    • la liste des billets est mise à jour et
    • un message de confirmation est affiché
  • sinon
    • le formulaire est affiché de nouveau (avec les erreurs) et
    • un message d’alerte est affiché

Ici, le serveur a répondu avec du javascript. Ce javascript a été exécuté par le client automatiquement par jQuery. Si le serveur avait renvoyé du HTML alors le comportement aurait été différent et on aurait du utiliser les callbacks.

Supprimer un billet de manière asynchrone

Pour l’exemple, la suppression d’un billet s’appuiera sur les callbacks.

# app/controllers/posts_controller.rb

...

def destroy
  post = Post.where(id: params[:id]).first
  if post
    post.destroy
    render nothing: true
  else
    render partial: 'list', status: 405
  end
end

...
// app/assets/javascripts/application.js

$(document).on('ajax:success', '#post-list .post a.delete', function() {
  $(this).closest('li').remove();
});

$(document).on('ajax:error', '#post-list .post a.delete', function(event, xhr) {
  if (xhr.status == 405) {
    $("#post-list").replaceWith(xhr.responseText);
    alert("Le post demandé n'a pas été trouvé.");
  }
});

Ici, on peut voir comment les callbacks sont utilisés.

Dans le cas où le billet est trouvé, on supprime l’élément de la liste. On évite ainsi de rendre la liste entière des billets.

Dans le cas où le billet n’est pas trouvé, on retourne une réponse avec le statut HTTP 405 et un corps contenant la liste complète des billets. En effet, on présume que la liste des billets du client n’est plus à jour.

Il est très important de bien définir ce qui doit se passer en cas d’erreur. Même si vous n’utilisez pas les callbacks et passez par du JS, comme c’était le cas lors du premier exemple, il est indispensable de prendre en compte le cas ou le serveur ne répond plus.

// app/assets/javascripts/application.js

$(document).on('ajax:error', function(event, xhr) {
  if (xhr.status == 500 || xhr.status == 0) {
    alert("Une erreur serveur s'est produite.")
  }
});

Maitriser le type de la requête

On l’a vu plus tôt, il est possible de personnaliser le champ dataType de l’appel $.ajax à l’aide de l’attribut data-type. Par défaut tout type de réponse est accepté mais une priorité est laissée aux scripts. Ainsi, si une action Rails peut répondre à deux formats, html et JS par exemple, ce sera le JS qui sera prioritaire.

Il est parfois plus pratique de retourner un morceau de HTML plutôt que de générer du javascript.

Prenons le cas de l’édition. On peut vouloir un formulaire d’édition indépendant et, en parallèle, avoir la possibilité d’introduire ce même formulaire au sein d’une autre page.

# app/controllers/posts_controller.rb

def edit
  @post = Post.find(params[:id])
  respond_to do |format|
    format.js { render :edit }
    format.html
  end
end

Notre action va donc retourner du HTML à la place du Javascript. Dans ces cas là, il faut informer jQuery qu’il ne devra pas interpréter le résulat de la requête comme un script. Pour cela on utilise data-type.

%li.post
  = post.title
  = link_to "Supprimer", post_path(post), remote: true, method: :delete, confirm: "Êtes vous sûr ?", class: 'delete'
  = link_to "Éditer", edit_post_path(post), remote: true, class: 'edit', :'data-type' => 'text'

Ici, on indique que l’on souhaite recevoir du texte depuis le serveur. Si l’on se contente de ça, jQuery enverra une requête acceptant en priorité le type text/plain. Et, dans le cas d’un type de donnéess non pris en compte, c’est le premier format.js qui sera utilisé.

Pour s’assurer que Rails choisisse le bon format de réponse, on peut utiliser l’évènement ajax:beforeSend pour redéfinir l’entête Accept.

// app/assets/javascripts/application.js

// Modifier l'entête Accept vers le type Javascript
$(document).on('ajax:beforeSend', '#post-list .post a.edit', function(event, xhr, settings) {
  xhr.setRequestHeader('accept', settings.accepts.script)
});

$(document).on('ajax:success', '#post-list .post a.edit', function(event, html) {
  $(html).insertBefore($('section:first'))
});

Vous pouvez retrouver le code de ces exemples sur notre dépot ujs-sandbox.

À titre d’exercice…

Dans les trois exemples précédents on a vu comment créer et supprimer des billets. Essayez maintenant de traiter la mise à jour d’un billet en gardant à l’idée que la demande peut venir soit d’un formulaire ajouté dynamiquement dans la page d’index soit d’un formulaire seul.

Conclusion

Nous avons fait un tour assez exhaustif de jquery-ujs. N’oubliez pas de consulter la liste des callbacks si ce n’est pas déjà fait et n’hésitez pas à vous plonger directement dans le code. Vous y découvrirez comment personnaliser le comportement de la bibliothèque au-delà des attributs prévus.

L’équipe Synbioz.

Libres d’être ensemble.