Caso práctico de JavaScript, Ajax y JSF 2 (I)

JSF 2 incorpora por primera vez soporte oficial para Ajax desde la especificación. Eso no significa que la integración con JavaScript y esa tecnología concreta sea sencillo pero desde luego es mucho más fácil que con la versión 1.2 de Java Server Faces.

En este artículo quiero mostrar el trabajo que tuve que realizar para integrar un simple control JavaScript en una pàgina JSF 2 que utiliza Facelets y el soporte Ajax de JSF 2 con la etiqueta <f:ajax>.

El problema

Para simplificar podemos suponer que la página se trata de un formulario JSF para el que queremos cronometrar el tiempo que el usuario tarda en enviar el formulario (en lenguaje JSF, en ejecutar una acción concreta). Además queremos mostrar al usuario un cronómetro en forma de minutos y segundos. Se trata de un juego en el que hay que escoger la opción correcta.

En una primera versión no voy a preocuparme de la seguridad así que me fiaré del dato que pueda enviar el navegador relativo al tiempo empleado sin realizar ninguna comprobación en el servidor.

Un cronometro Javascript

Lo primero es la parte más fácil. Crear un temporizador con Javascript que vaya mostrando al usuario el tiempo transcurrido (MM:SS) y además permita consultarlo mediante Javascript.

var Cronometro = Class.create({  
  initialize: function(id, opciones) {
    this.el = $(id);
    this.opciones = Object.extend({
                                    hiddenField : null
                                  }, opciones || {});
    this.t0 = new Date().getTime();
    this.pintaCrono();
    this.temporizador = setInterval(this.pintaCrono.bind(this), 1000);
  },
  pintaCrono: function() {
    if (this.opciones.hiddenField) Form.Element.setValue(this.opciones.hiddenField,new Date().getTime() - this.t0);
    this.el.update(this.getCronoString(), this.getCronoString());
  },
  getCronoString: function() {
    var t = (new Date().getTime() - this.t0) / 1000;
    var m = Math.floor(t / 60);
    var s = Math.floor(t % 60);
    var mm = m < 10 ? "0" + m.toString() : m.toString();
    var ss = s < 10 ? "0" + s.toString() : s.toString();
    return mm + ":" + ss;
  }
});

Por cierto, como librería Javascript utilizo Prototype. No está tan extendida como JQuery pero hace muchos años que la utilizo y me la conozco mucho mejor que JQuery. Y el proyecto donde encontré este problema ya utiliza esta librería. En todo caso el código Javascript en sí no es relevante para el artículo.La integración en el HTML es sencilla, pero para pasar el dato a un control Javascript necesito asociarlo con una propiedad de un bean. Para ello utilizo la opción del código Javascript hiddenfield. Este es el código HTML resultante para integrar el control en la página.

  <h:form id="form">
    <div style="float:right">
      <p>Tiempo: <span id="tiempo">--:--</span><h:inputHidden id="iTiempo" value="#{juegoArmaduras.tiempo}"/></p>
      <script>new Cronometro('tiempo', {hiddenField:'form:iTiempo'})</script>

El problema de la actualización Ajax de contenidos

Hay dos problemas con este primer intento. Por un lado el setInterval del código Javascript no se desactiva nunca, de manera que al utilizar Ajax como mecanismo para refrescar la página se crea un nuevo cronómetro pero no se desactiva el antiguo. Así que la primera vez ya hay dos temporizadores actualizando el mismo componente. Y así con cada actualización. Se ve el problema, ¿no?

El segundo problema también está relacionado. El formulario dispone de una validación en el lado del servidor que está funcionando correctamente pero, al ser una actualización de la página, la vuelta crea un nuevo cronómetro y se inicia de cero de nuevo.Ambos problemas están relacionados con la actualización de la sección de la página que crea el cronometro. Para entender mejor el problema veamos un fragmento más general del formulario (sólo las partes que interesan).

  <h:form id="form">
    <div style="float:right">
   ...
      <p>Tiempo: <span id="tiempo">--:--</span><h:inputHidden id="iTiempo" value="#{juego.tiempo}"/></p>
      <script>new Cronometro('tiempo', {hiddenField:'form:iTiempo'})</script>
      <h:messages styleClass="mensajes"/>
    </div>

    <h:panelGroup id="respuestas">
      <h:panelGroup rendered="#{!juego.respuestaCorrecta}">
    <ui:repeat value="#{juego.respuestas}" var="respuesta">
   <h:commandLink action="#{juego.jugar(respuesta)}">
     ...
     <f:ajax execute="@form" render="@form"/>
   </h:commandLink>
    </ui:repeat>
        ...
      </h:panelGroup>
      <h:panelGroup rendered="#{juego.respuestaCorrecta}">
        ...
      </h:panelGroup>
    </h:panelGroup>
  </h:form>

Recordemos que la etiqueta <f:ajax> acepta dos atributos que controlan que parte de la página se envía al servidor y que parte se actualiza de vuelta (execute y render respectivamente). Vemos que el <h:commandLink> que envía el formulario dispone de un <f:ajax> que actualmente envía y actualiza todo el formulario. Cabe comentar que inicialmente el atributo execute se había omitido ya que por defecto sólo se envía el componente que realiza la acción que para la validación de la respuesta efectua ya es suficiente.

La idea es modificar esto para enviar y actualizar sólo lo que necesitamos. En este caso sólo queremos enviar el propio <h:commandLink> con la respuesta elegida y el tiempo del temporizador. Y actualizar sólo todo el <h:panelGroup> de las respuestas para no generar un nuevo cronómetro. El primer intento es, por tanto, obvio:

<f:ajax execute="iTiempo" render="respuestas"/>  

Lamentablemente esto no funciona.

javax.faces.FacesException: <f:ajax> contains an unknown id 'iTiempo' - cannot locate it in the context of the component j_idt31  

Parece que no le gusta la referencia al con el tiempo. Tampoco funciona incluyendo la "ruta" completa:

<f:ajax execute="form:iTiempo" render="respuestas">  
El contexto de los identificadores JSF

Para resolver el problema hay que entender como funciona el espacio de nombres de los identificadores y como se resuelve un identificador incluido en una etiqueta como <f:ajax>. La especificación JSF indica explícitamente que los identificadores incluidos en los atributos execute y render se resuelven mediante el mecanismo documentado en el método findComponent de UIComponent.

Antes repasemos el modelo de componentes y sus identificadores según la especificación JSF 2 (capítulo 3). Básicamente la idea es que cada componente puede tener un identificador explícito (si le añadimos un atributo id en la etiqueta) o se le genera uno automáticamente. Por otro lado, para evitar colisiones en los nombres y mantener nombres únicos, existe una jerarquía en forma de árbol de manera que ciertos componentes (los que son UINamingContainer) prefijan a todos los que tienen por debajo. Un ejemplo de componente de este estilo es el habitual <h:form>.

Así pues al definir el id de un componente podemos utilizar cualquier nombre que no coincida con uno ya existente en el mismo contenedor de nombres. Fuera de este se le prefija con el identificador del contenedor separados por el carácter separador que por defecto es el carácter de dos puntos. De ahí la prueba anterior de form:iTiempo.

Volviendo a cómo se resuelven los identificadores que hemos indicado en los atributos execute y render leemos la descripción del método findComponent de UIComponent que también hace referencia a invokeOnComponent. Este último método describe como se busca el identificador cuando se indica de manera sencilla (sólo el id, sin separador en ningún punto). En ese caso sólo se hace una búsqueda desde el componente en el que estamos hacia "abajo" del arbol (o hacía dentro, depende de como quiera verse). Es obvio que en este caso no hay nada debajo del componente que genera el evento y tiene el que es el . Así que necesitamos utilizar una ruta más absoluta. ¿Por qué no funciona entonces form:iTiempo?

Debería. En el próximo artículo intentaremos averiguar por qué no funciona pero sí lo hace una ruta totalmente absoluta (:form:iTiempo). También abordaremos dos problemas adicionales: como parar el cronómetro una vez enviada la acción al servidor y como reiniciar el cronómetro si seleccionamos otra acción diferente (un especie de reset del formulario).