Rails: ¡Mira mamá! ¡Sin JavaScript!

15
Marzo
2007

Los mayores siempre han dicho que el JavaScript no debe ser esencial para el funcionamiento correcto de la página, que un usuario sin JavaScript activado debe ser capaz de realizar las mismas funciones básicas que un usuario con JavaScript activado. ¿Cumple Rails con este principio o debe el desarrollador poner trabajo de su parte?

El origen del problema

Un enlace a una página web utiliza para obtener la página de un servidor el “verbo” (método) GET de HTTP, que según las reglas tiene que ser idempotente, es decir, no debe modificar el estado en el servidor, para ese fin están otros “verbos” como POST, y los menos conocidos PUT y DELETE (existen al menos otros cuatro más).

Normalmente para enviar datos al servidor se utilizan formularios, a los que mediante un atributo se les puede pedir que envien los datos al servidor utilizando el “verbo” POST. Para borrar datos, sin embargo, no necesitamos proporcionar ningún dato a ningún formulario, por lo que es más “sencillo” (desde el punto de vista del usuario) y visualmente más agradable que la acción de eliminar (o votar, o lo que sea) se dispare utilizando un enlace. El problema del enlace es que sólo sabe hacer GET y ya hemos dicho que ese era un “verbo” idempotente.

¿Y para seguir el estándar vamos a dejarlo todo feo poniendo un horrible botón? Bueno, si la solución del botón no es aceptable y seguir el estándar no parece una gran razón quizá Google (y los demás buscadores) sí lo sea. Cuando Google mira las páginas web que tiene indexadas sigue (casi) todos los enlaces, por lo que si tienes un digg con un montón de enlaces de “votar” Google los seguirá todos, haciendo que tu ranking se llene de votos falsos.

Y todos diremos “seguro que por eso digg requiere registro”. De acuerdo, Google no vota porque no tiene usuario, pero resulta que hace unos años Google se sacó de la manga Google Web Accelerator (no, si la culpa al final va a ser de Google), una especie de proxy de escritorio que descargaba por adelantado las páginas enlazadas desde la página que el usuario estaba visitando en ese momento. Y el usuario sí está registrado. Quizá los votos falsos no sean un problema demasiado grave, pero eliminar una reunión, añadir un artículo al carrito o eliminar el artículo que habíamos añadido quizá sí sean problemáticos (y sobre todo molestos para el usuario).

En resumen, los enlaces no deben utilizarse para modificar el estado en el servidor.

El problema en Rails con arquitectura “clásica”

Vale, pero esto iba de Rails ¿no? Sí, a eso iba. Desde hace muchas versiones Rails permite generar un enlace que realice una petición POST al servidor (dentro de un momento vemos cómo) añadiendo la opción :method => :post (en Rails 1.2 y superiores) o :post => true (antes de Rails 1.2) y además permite proteger las acciones de los controladores para que sólo respondan a un verbo o a otro (utilizando el método verify o discriminado con request.post?).

Desgraciadamente el scaffolding por defecto de Rails (1.2, pero en la 1.1 era parecido) genera lo siguiente para el controlador (omito lo que no me interesa):

# GETs should be safe (see http://www.w3.org/2001/tag/doc/whenToUseGet.html)
verify :method => :post, :only => [ :destroy, :create, :update ],
  :redirect_to => { :action => :list }

def destroy
  Product.find(params[:id]).destroy
  redirect_to :action => ‘list’
end

Y lo siguiente para la vista:

<td><%= link_to 'Destroy',
  { :action => ‘destroy’, :id => product },
  :confirm => ‘Are you sure?’,
  :method => :post %></td>

Es decir, en el controlador verifica que el método con el que se llama a la acción destroy es POST y los enlaces los genera para utilizar el método POST.

Pero si un enlace siempre es GET, ¿cómo hace Rails para que se envíe un POST? Bueno, pues hace trampas utilizando JavaScript. Rails genera lo siguiente para el anterior enlace:

<td><a href="/products/destroy/1"
  onclick=”if (confirm(’Are you sure?’)) {
    var f = document.createElement(’form’);
    f.style.display = ‘none’;
    this.parentNode.appendChild(f);
    f.method = ‘POST’;
    f.action = this.href;
    f.submit();
  };
  return false;”>
Destroy
</a></td>

Todo ese trozito de JavaScript viene a decir más o menos: cuando me pinchen crea un formulario aquí al lado, que se invisible, cuyo método sea POST y su URL la URL del enlace, y envía ese formulario. De esta forma Rails recibirá una petición POST y no GET cuando se pinche en el enlace.

¿Pero que pasa si el usuario no tiene JavaScript activado? Bueno, pues no pasa nada (para tragedia del usuario). El enlace generaría una petición GET que Rails, de acuerdo al método verify del controlador, rechazaría y enviaría al usuario de nuevo a la acción list.

Pero como nos gusta ser buenos desarrolladores web tenemos que proporcionar al usuario un método para eliminar los registros incluso si no tiene JavaScript activado en el navegador. Lo mejor es que es muy sencillo.

Lo primero que hay que hacer es quitar la acción destroy de la lista de acciones del método verify y modificar el método destroy para que aparezca de esta forma:

def destroy
  return if request.get?
  Product.find(params[:id]).destroy
  redirect_to :action => ‘list’
end

La primera línea (la única que hemos incluido) hace que Rails represente la vista destroy.rhtml en el caso de que la petición sea un GET, pero cuando es un POST se sigue ejecutando el método, realizando el borrado deseado.

Ahora solo falta incluir en la vista un formulario de confirmación para el usuario (aderezar al gusto, esto sólo es un ejemplo).

<% form_tag nil, :method => 'post' do %>
<% submit_tag ‘Eliminar’ %/>
<% end %>

¿Sencillo, no? Pues los desarrolladores de Rails, con lo listos que son, no parece importarles mucho que JavaScript esté desactivado.

Pero aún hay otros puntos en Rails donde JavaScript es “obligatorio” a menos de que proporcionemos el “camino alternativo”. Los métodos implicados son link_to_remote, link_to_funcion y form_remote_tag / form_remote_for.

link_to_remote y link_to_function se arreglan igual. El primero realiza una llamada mediante XMLHttpRequest (aka AJAX) para actualizar la página, mientras que al segundo se le puede proporcionar el código JavaScript a ejecutar. A ambas se les puede proporcionar una opción de “baja tecnología” pasando el parámetro :href con una URL (una cadena o una de las funciones de Rails para generar las URL de la aplicación) que se utilizará cuando el navegador del usuario no ejecute JavaScript.

Para form_remote_tag y form_remote_for el parámetro se llama :html y acepta un hash de las opciones que se pasarían normalmente al método form_for (:action y :method).

El truco está en desarrollar la aplicación como si no existiera JavaScript, y sólo después añadir lo necesario para que JavaScript mejore la experiencia de usuario.

El problema en Rails con arquitectura REST

En Rails 1.2 han introducido (con mucha insistencia) facilidades para crear una arquitectura REST, que en resumen se trata de utilizar los cuatro verbos apuntados al principio (GET, POST, PUT, DELETE) sobre URL que no representan documentos, sino recursos. La ídea es muy útil porque simplifica mucho el desarrollo de API para la utilización de los recursos representados por una aplicación web externamente. La verdad es que yo me explico fatal… pero quizá este relato de como alguién se lo explicó a su mujer ayude.

Como decía la idea es buena, pero los navegadores actuales sólo envian peticiones mediante GET y POST, por lo que hay que buscarse la vida para simular las peticiones DELETE y PUT, y los desarrolladores de Rails lo han hecho utilizando tanto JavaScript en el lado del cliente como un poco de código en el lado del servidor.

Cuando en Rails 1.2 pedimos un link_to utilizando :method => :delete Rails genera un código parecido al que hemos visto antes con el método POST, pero incluye además otro campo oculto en el formulario llamado _method con valor delete. Rails en el servidor hará que parezca que el método ha llegado a través de una petición DELETE en vez de POST.

Como en el anterior caso, los desarrolladores de Rails no han pensado en la gente que no navega con JavaScript activado, y lo que es peor, como en REST las URL representan resursos no hay forma de proporcionar una solución de “baja tecnología” sencillamente.

Digamos que el recurso que queremos eliminar es un “producto” y tiene identificador 1. Según REST su URL sería /products/1. Sobre esta URL se puede actuar con el método GET para recuperar la información del producto, con el método PUT para actualizarla y con el método DELETE para eliminarlo. En el caso de una arquitectura no-REST la URL /products/destroy/1 podía servirnos para mostrar la confirmación de eliminación si el método era GET y borrar el producto si el método era POST.

Por suerte Rails sabe que no sólo de crear, leer, modificar y eliminar vive el hombre, por lo que proporciona un método para definir “acciones” adicionales (pero siempre utilizando los cuatro “verbos” de HTTP). Por ejemplo, cuando con una línea como la siguiente en routes.rb creamos un recurso:

map.resources :products

Rails genera una serie de rutas, entre ellas GET /products/new y GET /products/:id/edit (sí, eso es un punto y coma en Rails 2 ya no se utilizan los punto y coma), que permiten al usuario de la aplicación web ver el formulario donde introducir los datos que posteriormente se enviarán a POST /products (en el caso de querer crear un nuevo recurso) o PUT /products/:id (en el caso de querer editar un recurso existente). Es decir, esas rutas son sólo “para humanos”: muestran un formulario con el que los humanos interactuan.

Siguiendo la metafora si POST tiene /new y PUT tiene /edit, sería lógico que DELETE tuviera /delete /remove. Para ello necesitamos modificar el archivo routes.rb de la siguiente manera:

map.resources :products, :member => { :delete => :get }
map.resources :products, :member => { :remove => :get }

El código de la acción delete remove en el controlador es igual que el de edit:

def delete
def remove
  @product = Product.find(params[:id])
end

Y el código de la vista (delete.rhtml remove.rhtml) muy parecido al anterior:

<% form_tag product_url(@product), :method => :delete do %>
<%= submit_tag ‘Eliminar’ %>
<% end %>

Ahora llegamos al problema. link_to acepta un solo URL, ¿cuál usamos? Si utilizamos delete_product_url(@product) remove_product_url(@product) la gente con JavaScript desactivado obtendría la página de confirmación, pero la gente con JavaScript activado obtendría un RoutingError al no existir una ruta DELETE /products/:id/delete DELETE /products/:id/remove. Si utilizamos product_url(@product) el código JavaScript funcionará pero los usuarios sin JavaScript verán la página asociada al recurso.

A partir de aquí hay dos soluciones, una incluida en Rails y otra “parcheando” Rails. La solución incluida en Rails es utilizar link_to_remote en vez de link_to, ya que el objeto XMLHttpRequest soporta todos los métodos de HTTP y utilizar el parámetro :href para proporcionar la solución de “baja tecnología”.

Lo primero que hay que hacer es incluir la librería de Prototype en la cabecera de nuestra página (posiblemente en algún layout):

<%= javascript_include_tag 'prototype' %>

Luego modificaremos el código de index.rhtml para incluir un id único a cada una de las filas de nuestra tabla (utilizad dom_id, incluido en Rails) y modificaremos el enlace del “Destroy” por nuestro link_to_remote:

<tr id="<%= dom_id(product) %>">
  <!– … –>
  <td><%= link_to_remote ‘Destroy’,
    { :url => product_path(product),
    :method => :delete },
    :href => delete_project_path(project) %>
    :href => remove_project_path(project) %>
  </td>
</tr>

En el controlador habrá que modificar el método destroy para que se parezca a lo siguiente:

def destroy
  @product = Product.find(params[:id])
  @product.destroy

  respond_to do |format|
    format.html { redirect_to products_url }
    format.js # destroy.rjs (linea añadida)
    format.xml { head :ok }
  end
end

Y por último deberemos crear el archivo destroy.rjs que será devuelto al navegador:

page.remove “product-#{@product.id}”
page[@product].remove

Y tenemos un precioso enlace Google-friendly que en un navegador con Javascript hace que la fila destruida desapareza. No está mal para un par de minutos de trabajo.

La otra solución, la que implica “parchear” Rails consiste en descargar link_to_rest_url_patch.rb, dejarlo en el directorio lib de nuestra aplicación y añadir la línea require 'link_to_rest_url_patch' en environment.rb.

El problema actual de link_to es que utiliza la misma URL como destino del enlace y como action del formulario que genera dinámicamente. Como nosotros necesitamos una URL en un caso y otra URL en otra tenemos que sacarla de algún lado.

El parche modifica unos métodos que link_to utiliza para que acepten un parámetro nuevo denominado :rest_url que será la URL que se utilice como action del formulario generado dinámicamente, mientras que la URL del segundo parámetro de link_to se utilizará como destino del enlace. Si no se proporciona el parámetro :rest_url se utilizará la URL del segundo parámetro en ambos casos (que es el funcionamiento sin el parche).

Como podéis ver no es tan difícil proporcionar alternativas a los enlaces que utilicen exclusivamente JavaScript. Además proporcionando alternativas llegamos a un público mucho más amplio mientras que seguimos proporcionando una experiencia satisfactoria a los usuarios tecnológicamente más avanzados.


20 comentarios a “Rails: ¡Mira mamá! ¡Sin JavaScript!”

  1. Gravatar shad dice:

    hola!! amigo una pregunta… kiero usar javascript con ruby!! como hago??

  2. Gravatar JJ dice:

    Hey amigo, no podria ser en el español el enlace, es que el ingles para los ingleses tio. Que estamos en españa joder!!!

  3. Gravatar Daniel dice:

    Pinche wei, tú sos callaís, que vos no leais inglés no significa que nos no podamos.

  4. Gravatar deigote dice:

    Vaya tela con el artículo (del comentador no comento nada). Y luego tienes la poca vergüenza de empezar las charlas de Rails diciendo que no sabes de Rails…

  5. Gravatar Daniel dice:

    Jo, esto es mucho tiempo después de la conferencia, he aprendido algunas cosas desde entonces… :D

  6. Gravatar deigote dice:

    Una cosa a raiz de tu artículo y mis “progresos” en mi proyecto es que me he dado cuenta de otro detalle…

    El scaffold y otras gemas/plugis/como se llame/lanomenclaturaderailstodavíanoladomino usan el tema del GET y el POST para diferenciar casos distintos dentro de una misma acción.

    Por ejemplo: el típico “Crear un usuario” que en el controlador tiene la acción create podría tener algo así:

    def create
    # Aquí sólo me piden el HTML del formulario, por eso es
    # GET, y no necesito hacer nada más: la vista se encarga
    return unless request.post?
    # Aquí le han dado al botón Crear del formulario, por
    # eso es POST, y tengo que crear un usuario nuevo
    @user = User.new
    ...
    end

    El problema es que cuando el enlace a “Crear un usuario” usa link_to_remote el formulario ya no se pide usando un GET, si no con XmlHttpRequest, que quizá sea un POST ya que la condición request.post? da cierto. Esto causa que al pedir el HTML del formulario también se intente crear un nuevo usuario porque no se realiza el return.

    Yo lo he “solucionado” aprovechando que cuando el formulario se pide a la AJAX le paso al controlador una opción para que no pinte el layout, pero no sé si habrá otra manera más elegante de solucionar este “problema” (por ejemplo, dividiendo la acción en dos, una para el formulario y otra para crear el usuario o similar), o simplemente que ese problema no debería ocurrir porque está enfocado a un enfoque clásico.

  7. Gravatar Daniel dice:

    Tienes también request.xhr? (que significa XmlHttpRequest, obviamente), que indica si la petición viene por Ajax.

    Y ahora a por la nota y para contentar a los dioses: como dice la entrada, pedir un el dibujado del formulario es idempotente (no cambia el estado de la aplicación y puedes hacerlo 100 veces sin cambiar nada de nada), por lo que, a pesar de ser una petición Ajax, debería hacerse por GET.

    He pegado el código que propongo en este pastie este pastie.

    Pero de todas formas, en cierto modo me gusta tener acciones separdas para los GET y los POST, tener verify en los controladores y solo lidiar con xhr? en cada acción.

  8. Gravatar deigote dice:

    Muy interesante el pastie (eso sí, tiene una pequeña errata, en vez de usar remote_link_to usas link_to con lo que no hay Ajax ni JS)…

    La verdad es que toda la entrada está muy bien, además ha sido incremental, la primera vez que lo lei no entendía nada y ahora me ha aclarado muchas cosas sobre el funcionamiento de Rails al convertirse a HTML y sobre el propio protocolo HTTP :):

  9. Gravatar Daniel dice:

    He modificado el pastie corrigiendo el error. Muchas grácias.

    El nuevo pastie es el que vale.

  10. Gravatar deigote dice:

    Hola de nuevo,

    He refactorizado tu pastie porque así el código es menos enrevesado y tiene el mismo significado.

    He llegado hasta ese código porque he tenido problemas ya que decidí sacar la condición
    render :layout => false if request.xhr?
    y ponerla al principio del método, pues era válida también para POST.

    Pero resulta (supongo que tú lo sabes pero quizá algún lector no :) ) que después de un render las variables de instancia no llegan a la vista (es lógico ya que render es un método, pero con esa sintaxis tan rara que tiene ruby no lo tenía tan claro hasta ahora, cuando hacía render :layout => false pensaba que simplemente estaba dejando anotada esa condición, pero me acabo de dar cuenta que no, que estaba llamando explícitamente a un método que se llama implícitamente cuando termina un método del controlador).

    Esto hacía que algunas cosas que se hacen en la vista no funcionasen como deberían. Así que me dije, ¿para qué hacer if get return pudiendo hacer simplemente if post haz cosas? y así finalmente llamo al método render sin layout siempre que sea AJAX.

  11. Gravatar Ruby on Rails: link_to_remote sin Javascript (noscript) at Deigote’s Blog dice:

    [...] Edito: Recomiendo encarecidamente que si has llegado hasta aquí usando Google leas esta entrada del blog Ruido Blanco que nos apunta su creador en un comentario. Tanto el contenido de la entrada como las posteriores aclaraciones de los comentarios me han ayudado muchísimo a comprender cómo funciona Rails y incluso el protocolo HTTP [...]

  12. Gravatar Daniel dice:

    Verdaderamente es extraño que después de un render o un redirect_to el código siga ejecutando, en vez de salir del método directamente, por eso muchas veces se ve un render ... and return o similar. Pero como dices, cualquier variable de instancia posterior no se podrá utilizar en una vista renderizada anteriormente

    Lo que quería comentar ahora es que una acción tipo POST no debería hacer un render, generalmente, debería hacer redirect_to, ya que un usuario podría refrescar la página provocando que crees otro usuario nuevo (creo).

  13. Gravatar alberto dice:

    necesito saber y no lo he visto en ninguna de las consulta a diferente paginas web de informacion de rails es como hacer que cuando se hace un sibmit_tag “create” por ejemplo tambien se haga un linK. Lo que quiero decir es que despues de hacer el sumbit_tag me linkee a otra pagina. con el mismo boton.

  14. Gravatar Daniel dice:

    Sin acritud, pero para mí que no has buscado demasiado o mirado las cosas correctamente.

    submit_tag envía el formulario a la acción definida en el form_tag o form_for, y es en el código de esa acción (ya en el controlador correspondiente) donde puedes hacer un render o un redirect_to de la nueva página que tú deseas.

    Cualquier ejemplo básico de Rails (incluso el scaffold auto-generado) realiza esos pasos tan básicos.

  15. Gravatar deigote dice:

    Me quedé con ciertas dudas en la anterior discusión, pero no tenía los conocimientos suficientes para protestar. Ahora que he hecho mis indagaciones, hay dos cosas que no me quedan claras:

    product_path(product),
    :method => :delete },
    :href => delete_project_path(project) %>

    En caso de no tener JS activado, ¿el :href no haría que el enlace actuase como un enlace normal y corriente? Es decir, un enlace que haría un GET y no funcionaría ya que en el controlador estás discriminando para que esa acción sea de tipo POST, o DELETE en el caso del borrado… puede que tenga que ver con que en el controlador tú uses cosas de prototype (creo) y yo lo haga de la otra forma (lo de if request.post?) y demás… el caso es que lo he probado con la acción subscribe que debería se de tipo POST, y al desactivar JS, el enlace no funciona.

    Lo que quería comentar ahora es que una acción tipo POST no debería hacer un render, generalmente, debería hacer redirect_to, ya que un usuario podría refrescar la página provocando que crees otro usuario nuevo (creo).

    Si, esto que dices tiene sentido. Se ve fácilmente si no usas AJAX porque en la URL queda …/subscribe/4 (o delete, lo que sea) a pesar de que el render es el de …/show/4. Lo que no tengo claro es si actualizar la página hace un GET de esa URL o repite la ultima acción del usuario… Supongo que será esto último, por el mensaje que aparece a veces de tipo “… esta petición contiene POSTDATA …”. En mi caso, al desactivar JS para probar que sin AJAX todo va bien, no ocurre nada malo al actualizar, pero es porque estoy usando lo de :href de antes que genera un enlace de tipo GET, como comentaba en mi duda anterior.

  16. Gravatar Daniel dice:

    Ooops. Eso pasa por no probar el código… delete_project_path se crea cuando le pedimos un resource a Rails. Lo que yo quería era “otro” delete_project_path que aceptase GET. En vez de eso necesitaremos un remove_project_path o similar.

    Voy a modificar el código del artículo para corregirlo. Sorry por las molestias. Prometo probar mi código antes de escribirlo la próxima vez.

  17. Gravatar Daniel dice:

    Por cierto, sigo sin probarlo, pero ese debería ser el fallo que te rompe la cabeza.

    Como extra he actualizado un poquillo el artículo para Rails 2 y he incluido un truquito nuevo que he aprendido desde que escribí el artículo.

    Gracias por destacar el error y si encuentras algo más mal… pues yo te recomendaría ponerme a caldo en tu blog, en plan Galli vs. Microsiervos.

  18. Gravatar deigote dice:

    ¡Me pido Galli! :lol: gracias por las ayudas

  19. Gravatar Ruby on Rails: link_to_remote sin Javascript (noscript) at El blog de Deigote dice:

    [...] Recomiendo encarecidamente que si has llegado hasta aquí usando Google leas esta entrada del blog Ruido Blanco que nos apunta su creador en un comentario. Tanto el contenido de la entrada [...]

Deja un comentario

Puedes enterarte de las respuestas a tus comentarios de esta entrada mediante myComments.

XHTML: Puedes utilizar las siguientes etiquetas: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>

Tu servidor sin límites: 20GB de espacio, 1TB de transferencia, 1 dominio gratuito. Por 1.5€ al mes utilizando el código "RUIDOBLANCO" en DreamHost. Más información.