Rails: Plugin Multiple Select Helper

Read this page in english.

Multiple Select Helper screenshot

Importante: La versiones 20070407 y mayores del plugin no son “compatibles” con el código de las versiones anteriores. Si quieres seguir utilizando tú código sin modificar deberás mantenerte en la versión 20060918. Más información abajo.

Seleccionar múltiples elementos en una lista es muchas veces algo difícil. Puedes acabar pinchando sin darte cuenta en uno de los elementos de la lista y perder todos tus elementos seleccionados anteriormente. Por fuerza tienes que utilizar la tecla Ctrl (o Command) y pinchar para seleccionar más de un elemento.

Multiple Select Helper te permite crear listas fáciles de utilizar para selecciones múltiples desde arrays, colecciones o arboles. La lista está formada de checkboxes por lo que puedes pinchar fácilmente y no se perderán los elementos que hubieras pinchado antes. Como desventaja se pierde la posibilidad de utilizar el teclado como si fuera realmente una “lista”.

Estas selecciones múltiples son muy útiles en las relaciones muchos a muchos (has_and_belongs_to_many o has_many :through sin campos obligatorios) donde una solución “añadir y borrar” sería incomoda.

La última versión del plugin ha sido comprobada en Rails 2.2 (RC1, y es de esperar que en la versión final). La última versión no es compatible con versiones de Rails anteriores a la versión 2.2. La versión 20080608 es compatible y ha sido comprobada con Rails 2.1.0, 2.0.2 y 1.2.6. La única diferencia entre las versiones es la compatibilidad con Rails 2.2. Versiones anteriores del plugin funcionaron en Rails 1.1, pero desconozco si las versiones modernas siguen funcionando.

Instalación

Se puede descargar el plugin desde:

Puedes utilizar script/plugin install url para instalarlo en tu proyecto de Rails.

Sobre las versiones

Comencé a desarrollar el plugin para Rails 1.1, que no disponía del método #collection_singular_ids en las asociaciones has_many y has_and_belongs_to_many, por lo que no se podía utilizar collection_singular_ids tal y como se utilizan otros métodos de ActiveRecord en los helpers de ActionView::Helpers::FormHelpers, ya que esos helpers necesitan tanto el lector del atributo como el escritor del atributo (attribute reader y attribute writer). Por lo que la versión principal del plugin funciona más como los helpers de ActionView::Helpers::FormTagHelpers.

Paralélamente a la versión principal existía otra versión (a la que llamaba rama de “parámetros objeto-método”) que implementaba collection_singular_ids y helpers como los de ActionView::Helpers::FormHelper, pero debido a que los desarrolladores de Rails no les gustaba tal método por entonces (ticket #2917 y ticket #5780) no quise hacer a esa versión la versión por defecto del plugin.

Parece ser que alguién convenció a los desarrolladores de Rails de que la simetría era algo bueno y Rails 1.2 tiene tanto collection_singular_ids como collection_singular_ids= implementadas para las asociaciones has_many y has_and_belongs_to_many así que la versión “alternativa” del plugin puede mezclarse con la versión principal.

La mezcla, sin embargo, no está exhenta de problemas, ya que tanto la versión alternativa como la principal utilizaban los mismo nombres para los métodos, por lo que tenía que romper la compatibilidad hacia atrás de alguna de ellas. La solución lógica habría sido romper la compatibilidad de la versión alternativa, pero he decidido romper la compatibilidad de la versión principal para que el nombre de los métodos sigan los patrones de nombres de los helpers oficiales de Rails. Siento las molestias.

Utilizando el plugin Multiple Select Helper

Este es un enfoque parta humanos del plugin, para la documentación formal se puede acudir a Multiple Select Helper plugin RDoc documentation (en inglés).

Existen tres pares de funciones que puede utilizar dependiendo de la fuente de tus datos:

  • multiple_select (y multiple_select_tag y checkboxes_for_multiple_select): Con arrays, hashes o tus propias clases (implementando first and last en ellas).
  • collection_multiple_select (y collection_multiple_select_tag y checkboxes_from_collection_for_multiple_select): Con arrays de clases utilizando text_method y value_method para acceder a los datos de cada instancia.
  • tree_multiple_select (y tree_multiple_select_tag y checkboxes_from_tree_for_multiple_select): Con estructuras arbóreas (como actsastree de ActiveRecord) utilizando text_method y value_method para acceder a los datos de cada instancia.

Todas las posibilidades soportan las siguientes opciones en el hash options:

  • outer_class: Especifica la clase del div que envuelve a todas las checkboxes.
  • selected_items: Especifica un array de elementos que deben ser seleccionados cuando la lista se representa (sólo para los tres métodos principales, los otros tres métodos utilizan el parámetro selected_items). El array :selected_items debería ser un array de valores a comparar con aquellos valores devueltos por value_method.
  • inner_class: Especifica la clase del div que envuelve a cada uno de los checkboxes.
  • position: Determina en que lado se situará la etiqueta respecto del checkbox. El valor de esta opción debe ser :right o :left. Por defecto es :right.
  • alternate: Determina si la clase de cada una de las checkboxes debe alternar. Por defecto la clase no es alternante.
  • alternate_class: Especifica la clase alternativa que será utilizada si la opción de alternar es usada. La clase alternativa será añadida a la clase inner_class si también es especificada. La clase por defecto alternativa es “alt”.
  • initial_alternate: Determina si el primer elemento de la lista debe ser alternativo o no. Por defecto el primer elemento no es alternativo.
  • disabled: Si disabled es true todas las checkboxes estarán desactivados. Si disabled es un array sólo las checkboxes cuyo valor esté en el array estarán desactivados. Si disabled es false ningún checkbox estará desactivado. El valor por defecto es false.

A parte de las opciones arriba descritas los dos métodos que tratan con arboles soportan también las siguientes opciones:

  • depth: Profundidad máxima a la que se visitará el arbol. Una profundidad de 0 solo mostrará los nodos del primer nivel. Por defecto se visitará todo el arbol.
  • child_method: El método que se enviará al nodo para obtener sus hijos. Este método debe devolver sólo un array de los hijos directos del nodo. Por defecto es “children” (valido para acts_as_tree. Para acts_as_nested_set se deberá utilizar “direct_children”).
  • level_class: Especifica el prefijo de la clase que se utilizará en cada uno de los divs que envuelve a cada checkbox. A la clase level_class se le añadirá un número incremental de acuerdo con el nivel del checkbox actual. Esta clase será añadida a las clases inner_class y alternative_class si han sido especificadas.
  • initial_level: Especifica el primer nivel que será utilizado como sufijo para level_class. Por defecto es 0.

Desde la versión 20060917 se puede establece la opción por defecto para algunos de los parámetros más utilizados (outer_class, inner_class, level_class, alternate, alternate_class y position). Para establecer nuevos valores por defecto de estas variables se puede escribir en environment.rb:

FightTheMelons::Helpers::FormMultipleSelectHelperConfiguration.outer_class = 'myclass'

Y cualquier llamada a cualquier método del módulo utilizará un outer_class por defecto de myclass (siempre se puede utilizar un nuevo valor en el hash de opciones que será utilizado en vez del valor por defecto).

Existe una variable adicional que no se puede establecer utilizando el hash de opciones de los métodos pero que se puede establecer utilizando las variables el módulo. Esta variable se llama list_tags y es un vector de dos cadenas que serán utilizadas para rodear a la lista y los elementos individuales de la lista, respectivamente. Por lo que si se quiere que los checkbox estén contenidos en divs como en las versiones pre-20060917 se puede establecer la variable de la siguiente forma:

FightTheMelons::Helpers::FormMultipleSelectHelperConfiguration.list_tags = ['div', 'div']

Hay que tener en cuenta que esta línea NO produce los mismo resultado exactos que las versiones pre-20060917 del plugin en los métodos para árboles (los métodos para coleciones producen los mismo resultados, hasta donde yo se).

Cuando quieres almacenar la lista de opciones pinchadas en una relación “habtm” deberás hacer algo como lo siguiente:

# En el modelo
class Person < ActiveRecord::Base
  has_and_belongs_to_many :fruits
end

# En la vista
<%=
  collection_multiple_select(
    'person', 'fruit_ids', Fruit.find(:all), :id, :name
  )
%>

# En el controlador
def new # o edit
  # Recuperar una instancia @person
end

def create # o update
  # ...
  @person.fruit_ids = params[:person][:fruit_ids]
  # ...
end

Y tendrás todas las frutas que has seleccionado enlazadas con la persona que estás editando.

Para obtener los resultados de la captura de pantalla de arriba se ha utilizado el siguiente CSS:

.multiple_select {
  border: 1px solid #aaa;
  height: 100px;
  width: 200px;
  padding: 2px;
  margin: 1em;
  overflow: auto;
}

.multiple_select, .multiple_select ul {
  list-style-type: none;
}

.multiple_select_checkbox {
  background: #fff;
}

.alt {
  background: #eef;
}

Utilizando “multiple_select” como outer_class y “multiple_select_checkbox” como inner_class.

Como capturar eventos en las checkboxes

El plugin no ofrece manera fácil de capturar los eventos en ninguno de los elementos que el plugin crea, pero se puede hacer con un poco de Javascript.

<div id="mi-id">
<%= multiple_select 'mi-nombre', [1, 2, 3, 4, 5, 6] %>
</div>
<%= javascript_tag %q{
function miFuncionOnClick(evento) {
  var elemento = Event.element(evento);
  alert('¡' + elemento.value + " ha sido pulsado!");
}

var lista = $('mi-id');
var checkboxes = list.getElementsByTagName('input');
for (var i = 0; i < checkboxes.length; i++) {
  Event.observe(checkboxes[i], 'click', miFuncionOnClick);
}
} %>

Lo primero que hay que hacer es envolver a la lista de checkboxes en un div con id, para que posteriormente podamos utilizar la función dólar de Prototype con él. Entonces podemos utilizar getElementsByTagName sobre este elemento para obtener las etiquetas input (que serán los checkboxes). Añadiremos la función miFuncionOnClick a todos los eventos click de los checkboxes (utilizando Event.observe de Prototype). En miFuncionOnClick podemos obtener el elemento que ha disparado el evento mirando las propiedades de la estructura que recibimos como parámetro.

Para que todo esto funcione hay que incluir prototype.js en la cabecera.

Cómo validar que al menos un elemento de la colección ha sido seleccionado

Rails trata diferentes a los campos y a las asociaciones de un modelo. Mientras que los campos no son salvados directamente en la base de datos (sólo cuando un método como save es invocado), en algunas situaciones las asociaciones son salvadas directamente en la base de datos sin ni siquiera llamar a algunos métodos de callback del padre. Una de esas situaciones para ser utilizar el método collection_singular_ids=. Trataré de resumir los pasos necesarios para validad tus colecciones cuando se utiliza el plugin Multiple Select Helper.

Lo primero es el código que realiza la validación del modelo, que estará el clase de tu modelo:

# app/models/person.rb
class Person < ActiveRecord::Base
  has_and_belongs_to_many :fruits

protected
  def validate
    if self.fruits.count == 0
      errors.add(:fruits_ids, "debe ser seleccionado.")
      return false;
    end

    return true
  end
end

Lo siguiente es disparar el código de la validación con los nuevos datos, pero sin que los nuevos datos sean salvados a la base de datos si algo no pasa la validación. Para que suceda tal cosa necesitamos utilizar transacciones.

# app/controllers/people_controller.rb
# ...
def update
  @person = Person.find(params[:id])
  Person.transaction do
    @person.fruit_ids = params[:person][:fruit_ids]
    @person.save! # Es importante utilizar la version ! de save
  end
  redirect_to :action => 'list'
rescue ActiveRecord::RecordNotSaved, ActiveRecord::RecordInvalid, ActiveRecord::RecordNotFound
  flash[:notice] = "No se puede actualizar la persona"
  render :action => 'edit'
end
# ...

De esa forma un registro inválido no salvará los nuevos datos de la asociacion, ya que son enviados en una transacción de la que se hará rollback cuando la validación falle.

Cómo utilizar asociaciones has_many :through

Nota: Esta sección únicamente se aplica a versiones de Rails anteriores a la 2.1. Si utilizas algo más nuevo puedes parar de leer.

Las asociaciones has_many :through de Rails no general los métodos collection_singular_ids o collection_singular_ids=, por lo que no puedes utilizar el plugin Multiple Select Helper en ese tipo de asociaciones.

La razón de que no tengan esos métodos es que las asociaciones has_many :through normalmente tienen datos adicionales sobre la asociación, y tales datos se perderían (o deberían crearse nulos) cuando tales métodos se utilizaran.

Si tu asociación has_many :through no tiene datos adicionales o si los datos adicionales puede ser nulos se puede recrear ambos métodos en tu modelo fácilmente.:

# app/models/person.rb
class Person < ActiveRecord::Base
  has_many :authorships
  has_many :books, :through => :authorships

  def book_ids
    self.books.map(&:id)
  end

  def book_ids=(new_ids)
    new_ids = (new_ids || []).reject(&:blank?)
    old_ids = self.book_ids

    self.transaction do
      Authorships.destroy_all({ :book_id => old_ids - new_ids, :person_id => self.id })
      (new_ids - old_ids).each do |book_id|
        self.authorships.create!(:book_id => book_id)
      end
    end
  end
end

(No he comprobado el código en profundidad, pero creo que debería funcionar correctamente. Gracias a Brian Warren por arreglar un fallo).

Autores

Daniel Rodríguez Troitiño (drodrigueztroitino@yahoo.es): programador principal.
Michael Schuerig (michael@schuering.de): ideas para la opción de disabled, opción del campo oculto y uso de las etiquetas <ul>, <li>.

Licencia

Este trabajo está licenciado bajo la Licencia MIT.

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.