Ocurre muchas veces que a la hora de escribir el modelo de datos para nuestro proyecto necesitamos relaciones de n:n entre las distintas entidades. Un ejemplo simple de esto nos lo podemos encontrar en el plugin sfDoctrineGuardPlugin, donde entre los permisos (sfGuardPermission) y los grupos (sfGuardGroup) se establece una relación de este estilo (fGuardGroupPermission). El archivo schema.yml sería éste (copiado directamente del plugin):

sfGuardGroup:
  actAs: [Timestampable]
  columns:
    name:
      type: string(255)
      unique: true
    description: string(1000)
  relations:
    Users:
      class: sfGuardUser
      refClass: sfGuardUserGroup
      local: group_id
      foreign: user_id
      foreignAlias: Groups
    Permissions:
      class: sfGuardPermission
      local: group_id
      foreign: permission_id
      refClass: sfGuardGroupPermission
      foreignAlias: Groups
 
sfGuardPermission:
  actAs: [Timestampable]
  columns:
    name:
      type: string(255)
      unique: true
    description: string(1000)
 
sfGuardGroupPermission:
  options:
    symfony:
      form:   false
      filter: false
  actAs: [Timestampable]
  columns:
    group_id:
      type: integer
      primary: true
    permission_id:
      type: integer
      primary: true
  relations:
    Group:
      class: sfGuardGroup
      local: group_id
      onDelete: CASCADE
    Permission:
      class: sfGuardPermission
      local: permission_id
      onDelete: CASCADE

Si nos fijamos, podemos ver que a la hora de definir la tabla de la relación (sfGuardGroupPermission) aparece un línea options muy interesante:

sfGuardGroupPermission:
  options:
    symfony:
      form:  false
      filter: false

Con esto evitamos que a la hora de ejecutar la tarea symfony doctrine:build --all se generen las clases de los formularios y los filtros para esa tabla. Fijaros que sigue siendo necesario tener las clases del modelo ya que al trabajar con una ORM (Doctrine) necesitamos mapear a objetos todas las tablas de nuestro modelo.

Después de casi 3 años trabajando con symfony me resulta curioso ver como no pasa el día en el que symfony me sorprenda con cosas de este estilo.

A continuación comparto con vosotros una línea de código que me llevo más de 2 horas sacarla.

$view = sfContext::getInstance()->get('view_instance');

¿Pero qué es esto? Concretamente con esta línea obtenemos acceso al objeto sfView encargado de renderizar la vista. Aunque en principio pueda parece que no tenga mucha utilidad seguro que hay algún momento en tu vida como desarrollador symfony en la que te va a hacer falta, quizás no así, sino en su versión más útil

$post = sfContext::getInstance()->get('view_instance')->getAttribute('post');

qué es completamente equivalente a $post en la vista (template) habiendo definido previamente $this->post en la acción.

En mi caso esto lo estamos utilizando para un pequeño CMS propio que estamos desarrollando en el que la acción pasa “bajo cuerda” una serie de variables a la vista y un helper (el helper no tiene idea de lo que es el contexto, es sólo una función) necesita conocer el valor de éstas. Creédme, con esto hemos conseguido que, de cara al programador, esto ocurra como por arte de magia y le quite la sobrecarga de tener que arrastrar variables que no sabe para lo que sirven de un sitio a otro.

Al desarrollar un panel de administración con el Admin Generator de symfony, en ocasiones nos ocurre que desearíamos poder incrustar código PHP en el archivo generator.yml para cumplir nuestros objetivos. Por ejemplo, supongamos que quisiéramos mostrar un listado de elementos distintos en función de los permisos del usuario que está logueado. Nos encantaría poder hacer algo como esto

generator:
  config:
    list:
      <?php if(sfContext::getInstance()->getUser()->hasCredentials('manager')): ?>
      table_method: "doSelectForManagers"
      <?php else: ?>
      table_method: "doSelect"
      <?php endif; ?>

El código escrito no daría ningún error y funcionaría … aunque sólo la primera vez ya que una vez compilado este archivo (convertido en PHP) siempre se ejecutaría el método del “table_method” elegido la primera vez.

Una solución no muy buena
Una posible solución para logar nuestro objetivo sería llevar a cabo la lógica para comprobar los credenciales de usuario dentro del propio módelo, algo así:

// lib/model/doctrine/item.class.php
class Item extends BaseItem
{
  public function doSelect()
  {
    $query = Doctrine_Query()::create();
    if(sfContext::getInstance()->getUser()->hasCredentials('manager'))
    {
      // Añadir una seria de condiciones a la query
    }else{
     // Añadir otras condiciones a la query
    }
    return $query
  }	
}

El inconveniente de esta solución es que estaríamos acoplando muy fuertemente nuestro modelo de datos con el usuario de la sesión lo cual nos haría muy difícil hacer pruebas unitarias más adelante, por lo tanto no es muy aconsejable.

Mi solución
La solución que propongo pasa por hacer lo que se hizo al principio sólo que en lugar de sobre el archivo generator.yml sobre el auténtico objeto PHP en el que se convierte este archivo una vez compilado, la clase xxxGeneratorConfiguration, que es precisamente uno de los dos archivos generados bajo la carpeta “lib” en el módulo (si no sabías para que servían estos archivos, ya puedes hacerte una idea, ;) ).

De esta manera el código quedaría de la siguiente manera

// lib/xxxGeneratorConfiguration.class.php
class xxxGeneratorConfiguration extends BaseXxxGeneratorConfiguration
{
  public function getTableMethod()
  {
    $user = sfContext::getInstance()->getUser();
    if($user->hasCredentials('manager')){
      return 'doSelectForManagers';
    }else{
      return 'doSelect';
    }
  }
}

Si quieres conocer algo más en profundidad sobre el admin generator y cómo funciona, a lo mejor te ayude algo la charla que dí en Castellón sobre esto mismo, http://vimeo.com/13325576

Una situación muy típica a la hora de desarrollar una aplicación web es ofrecer al usuario la posibilidad de subir una imagen y, de manera automática, generar un thumbnail de ésta.

Si estamos intentando implementar esta funcionalidad con symfony nos encontramos con la agradable sorpresa de que ya existe un plugin que nos facilita la tarea, sfThumbnailPlugin, pero sólo se encarga de generar el propio thumbnail, la pregunta que surge es: ¿dónde integrar la lógica para que se genere el thumbnail cada vez que se añade una imagen a un formulario?

La respuesta a la última pregunta me la dió David Vega en su post sfThumbnailPlugin y AdminGenerator: redimensionar imágenes el cual recomiendo que leáis.

Reconozco que la solución de David Vega me dejó con la boca abierta por lo bien que “encuadró” el código dentro del framework (en symfony uno no sabe muchas veces dónde poner el código), aunque en mi opinión no aprovecha bien la herencia de la clase padre, así que esta es mi versión de su sfResizedFile, con su permiso, ;) :

class sfResizedFile extends sfValidatedFile
{
	public function save($file = null, $fileMode = 0666, $create = true, $dirMode = 0777)
	{
		$file = parent::save($file, $fileMode, $create, $dirMode);
 
                $thumbFile = $this->path.DIRECTORY_SEPARATOR.'thumb_'.$file;
		$thumbnail = new sfThumbnail(100, 100, true, true, 85);
		$thumbnail->loadFile($this->getTempName());
		$thumbnail->save($thumbFile, 'image/jpeg');
 
                chmod($thumbFile, $fileMode);
 
                return $file;
	}
 
}

Consideremos que tenemos un modelo de datos en el que existen relaciones entre nuestras entidades, como siempre, un ejempo: dentro de nuestra aplicación tendremos noticias, perteneciendo cada noticia a un autor.

Ahora supongamos que generamos el panel de administración de esta aplicación mediante el admingenerator de symfony. Tal como está escrito el código del AdminGenerator nos encontraremos con que, cada vez que el usuario intente eliminar un autor al que se le haya asignado alguna noticia se producirá un error debido a una violación de la restricción de clave externa.

El usuario que utilice la aplicación no debe, ni tiene, porque saber esto, y es más que posible que intente borrar un autor que lo cumpla. Al ver la pantalla con un error 500 (a él no le aparecerá el error detallado que nosotros sí podemos ver en el entorno de desarrollo) nos llamará asustado y querrá una solución ya!

La mala solución

Una forma de proceder es mediante la famosísima solución conocida como muerto-el-perro-se-acabó-la-rabia. Si quito el botón de eliminar, el usuario no podrá borrar el registro, y si no puede borrar el registro no aparecerá el error. Fin del problema.

Bueno, no tan rápido. Qué ocurre si el usuario descubre que jugando con las URLs puede seguir borrando el registro. No pasa nada, basta con darle credenciales especiales a esa acción, por ejemplo en security.yml.

Bueno, vale, y si se ha equivocado al crear el autor y quiere borrarlo justo después de haberlo creado. En ese caso no habría problemas para eliminarlo ya que al no tener asociado ninguna noticia no surgiría tal error. Parece pues, que esta solución “hace aguas”.

La buena solución

Desde mi punto de vista la solución óptima pasa por permitirle borrar autores al usuario pero indicarle cuando no pueda. Para ello lo que hacemos es sobreescribir el método executeDelete de la acción. Quedaría algo como esto:

<?php
 
    // app/backend/modules/author/actions/actions.php
 
    public function executeDelete(sfWebRequest $request)
    {
        $request->checkCSRFProtection();
 
        $this->dispatcher->notify(new sfEvent($this, 'admin.delete_object', array('object' => $this->getRoute()->getObject())));
 
        try {
            $this->getRoute()->getObject()->delete();
            $this->getUser()->setFlash('notice', 'The item was deleted successfully.');
        }catch (Exception $e){
            $this->getUser()->setFlash('error', 'The item can not be deleted.');
        }
 
        $this->redirect('@author');
    }

Creo que debo una explicación

Creo que siempre he tenido un don (si es que se le puede llamar así) para poner nombres “rimbonbantes” a las cosas. El título de este post comienza con “… and if you just try”. Creo que el chiste (no comment) se entiende sólo si sabemos que el método executeDelete de la acción en baseAuthorActions es:

  // /cache/modules/author/actions/actions.class.php
 
  public function executeDelete(sfWebRequest $request)
  {
    $request->checkCSRFProtection();
 
    $this->dispatcher->notify(new sfEvent($this, 'admin.delete_object', array('object' => $this->getRoute()->getObject())));
 
    if ($this->getRoute()->getObject()->delete())
    {
      $this->getUser()->setFlash('notice', 'The item was deleted successfully.');
    }
 
    $this->redirect('@author');
  }

Para lo más lentos, obsérvese el cambio del “if” por el “try”. Es obvio que para entenderlo se requiere un mínimo de inglés, ;) .

La otra solución

Casi se me olvidaba! Aunque la solución expuesta aquí es ORM-independente, existe una solución para aquellos que utilizan Doctrine basada en la utilización del behaviour SoftDelete, aunque eso es ya otra historia.

Hay veces en que las reglas de validación de un formulario pueden cambiar según el valor de otros campos del mismo formulario. ¿Un ejemplo? El caso del formulario de reserva para un billete de tren, si es un viaje de ida y vuelta, la fecha de regreso será obligatoria y además deberá ser mayor que la fecha de ida, si es un viaje de ida ésta no será obligatoria.

La clase del formulario

Como siempre, trabajaremos con un ejemplo concreto, el del formulario de reserva de billetes (con una versión simplificada, claro). Para ello construimos el formulario teniendo en cuenta las reglas de validación menos restrictivas. Quedaría así:

<?php
 
class PurchaseForm extends BaseForm{
 
 
    public function configure()
    {
 
      $types = array(1 => 'ida', 2 => 'vuelta' );
      $this->setWidgets(array(
          'type'         => new sfWidgetFormChoice(array('choices' => $types),
          'go_date'      => new sfWidgetFormDate();
          'return_date'  => new sfWidgetFormDate();
      ));
 
      $this->setValidators(array(
        'type_id'       => new sfValidatorChoice(array('choices' => array_keys($types))),
        'go_date'     => new sfValidatorDate(array('min' => date('Y-m-d'))),
        'return_date'       => new sfValidatorDate(array('min' => date('Y-m-d', strtotime("tomorrow")), 'required' => false)),
      ));
 
    }
 
}
?>

Para el que no tenga ganas de pararse a leer el código, se han considerado tres campos: type (tipo de billete), go_date (fecha de ida), return_date (fecha de vuelta), con las siguientes reglas de validación:

  • type: puede valer sólo 1 ó 2 (es decir, es de ida o vuelta)
  • go_date: es una fecha obligatoria y además mayor que hoy, ya que no se puede comprar un billete de ayer
  • return_date: debe ser una fecha mayor que mañana (se ha supuesto que no se puede ir y volver en el día, aunque eso siempre es debatible)
  • Si quieres echarle un vistazo en mayor detalle a todas las posibilidades que ofrecen los widget y validators de symfony relacionados con las fechas puedes ver mi otro post, “Fechas, fechas y más fechas: Symfony, sfWidgetFormDate y sfValidatorDate“.

    Las reglas de validación con palabras

    Antes de programar entendamos un poco más qué queremos hacer. Lo que buscamos es que, en el momento en el que recibamos los valores de los campos, dependiendo del valor que tome el campo type, deberemos exigirle al campo return_date que sea obligatorio y además que sea mayor que el campo go_date

    La solución

    Quizás tan importante como la solución (que no es otra cosa sino añadir 10 líneas de código) sea entender qué me ha llevado a hacerlo así. En primer lugar, en el epígrafe anterior hemos visto que necesitamos conocer el valor del campo, esto hace que sea imposible imponer esta validación en el método configure ya que a estas alturas no se tiene conocimiento del valor de los campos. Si pensamos un poco, no es hasta que ejecutamos el método bind() del formulario en el que éste es consciente del valor de sus campos. De todas formas, no es buena idea sobreescribir este método ya que no está pensado para ello.

    En general, los desarrolladores de symfony implementan métodos del tipo doXXXX() que son los que les gustaría que sobreescribiésemos, para nuestra suerte sfForm implementa el método doBind().

    Ahora sí, la solución

    <?php
    class PurchaseForm extends BaseForm{
     
        // resto de código
     
        public function  doBind(array $values)
        {
            if($values['type']==2)
            {
                $this->validatorSchema['date_to']->setOption('required', true);
                $this->validatorSchema->setPostValidator(
                    new sfValidatorSchemaCompare('date_from', '<', 'date_to', array('throw_global_error' => true), array('invalid' => 'La fecha de comienzo debe ser menor que la de fin'))
                );
            }
     
            return parent::doBind($values);
        }

Si alguna vez os habéis parado a ver el código generado por el modelo, podréis observar que la clase Base es más o menos así:

<?php 
abstract class BaseItem extends sfDoctrineRecord
{
    public function setTableDefinition()
    {
       // Aquí el código de definición de la tabla
    }
 
    public function setUp()
    {
       // Aquí las relaciones y los behaviours
    }
}
?>

Algo que siempre me había llamado la atención de este código es, ¿por qué la clase base extiende de sfDoctrineRecord en lugar de hacerlo directamente de Doctrine_Record?

Esta pregunta no tiene una única respuesta, ya que son varias funcionalidades las que añade este wrapper:

  • Funcionalidades específicas para objetos I18n. Añade un proxy al objeto Translate de manera que si al acceder a un atributo del objeto no se encuentra, intentará buscarlo entre los de su objeto Translate asociado (para la cultura definida en ese momento) antes de lanzar una exception (algo parecido a esto)
  • Getter y Setter para las relaciones de objeto, de manera que podamos tratar “igual” tanto a las relaciones como los atributos de las tablas, una funcionalidad al más estilo Propel, :)
  • Hidratación específica para atributos Date y Timestamp. sfDoctrineRecord añade un método, sfDoctrineRecord::getDateTimeObject, que permite obtener los campos de fecha y timestamp no como una cadena de texto, sino como un objeto DateTime. De manera análoga, sfDoctrineRecord::setDateTimeObject() permite fijar el valor de un campo date o timestamp a partir de un campo DateTime en lugar de una cadena de texto correctamente formateada, algo muy útil en aplicaciones internacionalizadas donde las fechas se pueden insertar de diferentes maneras y requieren un trato previo en función de la cultura del usuario.

Pongamonos en situación, tenemos un listado generado por el AdminGenerator. Decidimos añadir una nueva columna a la lista, algo tremendamente fácil con sólo añadir un nuevo getter al objeto del modelo pero …. (siempre hay un pero) no podemos ordenar por ese nuevo campo, :( .

Bueno, esa situación era hasta hoy, ya que intentaré exponer lo tremendamente sencillo que es conseguir ordenar por una columna virtual.

En primer lugar, y para trabajar sobre un ejemplo “real” consideremos el archi-conocido caso en el que tenemos un modelo de datos formado por usuarios (sfGuardUser) y sus correspondientes perfiles (Profile) guardando una relación de 1:1 y teniendo la tabla Profile la siguiente estructura:

Profile:
  columns:
    full_name: { type: string(255) }
    email:     { type: string(255) }

Cómo era la situación hasta hoy

Consideremos el caso hipotético de que para el desarrollo de una aplicación tenemos que mostrar un listado con todos los usuarios con las siguientes columnas: nombre de usuario, nombre real y email. Hasta hoy, el proceso a seguir era,

1) creamos dos nuevos getter en el objeto sfGuardUser para las dos nuevas columnas virtuales:

// lib/model/sfGuardUser.class.php
<?
class sfGuardUser extends BasesfGuardUser{
 
  public function getFullName()
  {
    $this->Profile->full_name;
  }
 
  public function getEmail()
  {
    $this->Profile->email;
  }
?>

Te podías haber ahorrado definir estos getters si en lugar de esto hubieses creado directamente un proxy del objeto Profile, aunque eso es otra historia

2) Modificamos el archivo generator.yml para que muestre estos dos nuevas columnas virtuales:

      list:
        title:   Listado de Usuarios
        display: [=username, full_name, email]

3) El
Listado de Usuarios con columnas virtuales
pero no podríamos ordenar ni por full_name ni email … hasta hoy! Vamos allá!

Desvirtualizando los campos virtuales

Lo primero que debemos hacer es desvirtualizar los campos que eran virtuales, para ello cambiamos el método que utiliza el AdminGenerator para recoger el listado de elementos:

  list:
    title:   Listado de Usuarios
    display: [=username, full_name, email]
    table_method: doSelectAdmin

debiendo de crear este método en el objeto de la tabla correspondiente

// lib/model/sfGuardUserTable.class.php
<?php
class sfGuardUserTable extends Doctrine_Table{
 
  public static function doSelectAdmin()
  {
    return Doctrine_Query::create()
      ->select('u.username as username, p.full_name as full_name, p.email as email')
      ->from('sfGuardUser u')
      ->leftJoin('u.Profile p');
  }
}
?>

Notemos que el nombre de los campos de la sentencia select coincide con el nombre de los campos en list.display.

Después de esto tendríamos el mismo resultado que en el caso anterior, sin poder seguir ordenando por estas dos nuevas columnas.

Ya no es virtual, ahora es real

Para que symfony sustituya el texto de la cabecera de la tabla por un enlace debemos de indicarle de manera explícita que estos campos son reales y no virtuales. Esto se lo indicamos en el generator.yml:

#generator.yml
fields:
  full_name: { is_real: true }
  email:       { is_real: true }

Resulta curioso que este parámetro no venga en ningún sitio en la documentación de symfony (o al menos, yo no lo he sabido encontrar).

Con esto conseguiremos que el texto de la cabecera (i.e: “Email”, “Nombre”) sea ahora un enlace, aunque si haces click verás que no ocurre nada, esto se debe a que symfony pone una traba más que hay que sortear.

Sí, mis campos son reales y además son “ordenables”

Finalmente hay que indicarle a symfony que sí que queremos poder ordenar por esa columna, por lo que será necesario reescribir un método:

<?php
// /modules/sfGuardUser/actions/actions.class.php
 
class sfGuardUserActions extendes autosfGuardUserActions{
 
    protected function isValidSortColumn($column)
    {
      return in_array($column, array('username', 'full_name', 'email');
    }
}
?>

A lo largo de las dos últimas semanas, por necesidades de un proyecto, me he visto obligado a trabajar con formularios en los que había bastantes campos de fecha.

Lo que pretendo hoy aquí es dar una serie de recetas rápidas que puedan solucionar esas preguntas “fáciles” que puedan surgir.

Cambiando el formato

Por defecto el formato es “año/mes/día”. En España el formato usual es “día/mes/año” así que para cambiarlo:

$format = '%day%/%month%/%year%';
 
$this->widgetSchema['date_field'] 
  = new sfWidgetFormDate(array('format' => $format));

Mostrando una fecha por defecto

Supongamos ahora que queremos una determinada fecha por defecto.

$default = array( 
                 'year'  => 2010, 
                 'month' => 2, 
                 'day'   => 12
              );
 
$this->widgetSchema['date_field'] 
  = new sfWidgetFormDate(array('default' => $default));

Mostrando hoy como la fecha por defecto

Si lo que queremos es que la fecha a mostrar por defecto sea el día actual

$today = array(
                'year'  => date('Y'),       
                'month' => date('n'),
                'day'   => date('j')
);
 
$this->widgetSchema['date_field'] = 
     new sfWidgetFormDate(array('default' => $today));

Eligiendo sólo unos determinados años

Supongamos que sólo nos interesa que muestre como posibles opciones de años, éste y los dos siguientes

$range  = range(date('Y'), date('Y')+2);
$years = array_combine($range,$range);
 
$this->widgetSchema['date_field'] = 
     new sfWidgetFormDate(array('years' => $years));

Validando que una fecha no es pasada

Si lo que nos interesa es que el usuario no pueda introducir una fecha pasada, por ejemplo para hacer una reserva.

$this->validatorSchema['date_field'] =
    new sfValidatorDate(array('min' => date('Y-m-d'));

Validando que una fecha no es futura

O bien nos interesa que la fecha no pueda ser futura, porque se trata de que esté diciendo cuando tuvo lugar un hecho.

$this->validatorSchema['date_field'] =
    new sfValidatorDate(array('max' => date('Y-m-d'));

Validando que una fecha es mayor que otra

Si tenemos dos campos de fecha, nos puede interesar que uno sea mayor que otro, por ejemplo la fecha de salida y llegada para una reserva de hotel.

      $this->validatorSchema->setPostValidator(
          new sfValidatorSchemaCompare('date_starts', '<', 'date_ends')
      );

No hay duda de que una de las mejores características de symfony es el Admin Generator, gracias a él podemos tener un panel de administración listo para usar en cuestión de minutos y además, podemos personalizarlo todo lo que queramos.

Hoy veremos como añadir nuevas acciones sobre objetos (object_actions) a la vista del listado y cómo personalizarlas desde el archivo generator.yml

Las object_actions por defecto
Por defecto, symfony incluye dos acciones para cada elemento del listado, eliminar y editar. Estas dos acciones son un tanto especiales en el sentido de que están ya están “personalizadas”, mostrando un icono distinto e incluso saltando javascript al hacer click (el caso del botón eliminar). En el archivo generator.yml:

generator:
  param:
    config:
      list:
        object_actions:
          _delete: ~
          _edit:   ~

Si no queremos mostrar una de las acciones en el listado bastará con borrarla.

Añadiendo una object_action
Como siempre es más sencillo hablar con ejemplos concretos, supongamos que el listado que estamos mostrado es de noticias y queremos añadir una acción que sea “publicar” de manera que al hacer click sobre ella se publique la noticia. Lo primero que habría que hacer sería añadir esta acción al listado, así:

generator:
  param:
    config:
      list:
        object_actions:
          _delete: ~
          _edit:   ~
          publish: ~

De esta forma, aparecerá un nuevo enlace a “modulo/:id/ListPublish” y por tanto hará falta definir la acción “ListPublish” dentro del archivo actions.class.php

// /module/moduleName/actions/actions.class.php
public function executeListPublish(sfWebRequest $request)
{
  $this->getRoute()->getObject()->publish();
  $this->redirect('@moduleName');
}

Con esto estaría el trabajo hecho, pero siempre puede quedar algo más “bonito”, ;-) .

Personalizando el texto
¿Qué ocurre si queremos que el texto que aparezca sea “Publicar Noticia” en lugar de “Publish”?, muy sencillo:

generator:
  param:
    config:
      list:
        object_actions:
          publish: { label: "Publicar Texto" }

Personalizando el HTML generado
¿Y si queremos añadirle una clase de CSS o un ID al anchor tag? Más fácil:

generator:
  param:
    config:
      list:
        object_actions:
          publish: 
            label: "Publicar Texto"
            params: { class: 'myClass',  id: 'publish-button' }

¿Y para que salte un javascript como en el caso del botón eliminar?

generator:
  param:
    config:
      list:
        object_actions:
          publish: 
            label: "Publicar Texto"
            params:
              class: 'myClass'
              id: 'publish-button'
              onclick: 'return confirm('¿Estás seguro?');'

Notar que para cambiar la imagen del icono debemos de hacerlo a través de la hoja de estilos admin.css modificando el atributo background de

#sf_admin_container ul li.sf_admin_action_publish a

Personalizando la acción que se ejecuta
Por último, si por cualquier motivo queremos que se ejecute la acción “publish” en lugar de “ListPublish” bastará con

generator:
  param:
    config:
      list:
        object_actions:
          publish: 
            action: 'publish'