El tutorial Jobeet

10.3. Formularios de Propel

Normalmente, los valores enviados con el formulario se guardan o serializan en una base de datos. Como Symfony ya dispone de toda la información sobre el modelo de tu base de datos, es capaz de generar automáticamente los formularios a partir de esa información. De hecho, cuando ejecutábamos la tarea propel:build-all durante el tutorial del día 3, Symfony ejecutaba internamente la tarea propel:build-forms:

$ php symfony propel:build-forms

Los formularios que genera la tarea propel:build-forms se guardan en el directorio lib/form/. La forma en la que se organizan estos archivos generados automáticamente es similar a la del directorio lib/model/. Cada clase del modelo dispone de una clase de formulario (la clase JobeetJob dispone por ejemplo de JobeetJobForm). Inicialmente estas clases de formulario están vacías, ya que heredan de una clase base de formularios:

// lib/form/JobeetJobForm.class.php
class JobeetJobForm extends BaseJobeetJobForm
{
  public function configure()
  {
  }
}

Nota Si echas un vistazo a los archivos generados automáticamente en el subdirectorio lib/form/base/, verás muchos buenos ejemplos de cómo utilizar los widgets y validadores incluidos en Symfony.

10.3.1. Personalizando el formulario de las ofertas de trabajo

El formulario de las ofertas de trabajo es un buen ejemplo para aprender a personalizar los formularios. A continuación se muestran todos los pasos necesarios para personalizar este formulario.

En primer lugar, modifica el enlace Post a Job del layout para que puedas probar las modificaciones directamente en el navegador:

<!-- apps/frontend/templates/layout.php -->
<a href="<?php echo url_for('@job_new') ?>">Post a Job</a>

Por defecto los formularios de Propel muestran campos para todas las columnas de la tabla. No obstante, en el formulario para insertar una oferta de trabajo, algunos campos no deben ser editables por los usuarios. Eliminar campos en un formulario es tan sencillo como utilizar la función unset() de PHP:

// lib/form/JobeetJobForm.class.php
class JobeetJobForm extends BaseJobeetJobForm
{
  public function configure()
  {
    unset(
      $this['created_at'], $this['updated_at'],
      $this['expires_at'], $this['is_activated']
    );
  }
}

Eliminar un campo de formulario significa que se eliminan tanto su widget como su validador.

Normalmente, la configuración del formulario debe ser más precisa de lo que se puede determinar a partir del esquema de la base de datos. La columna email por ejemplo es un campo de tipo varchar en el esquema, pero necesitamos que sea validado como si fuera un email. Para ello, modifica el validador sfValidatorString por sfValidatorEmail:

// lib/form/JobeetJobForm.class.php
public function configure()
{
  // ...

  $this->validatorSchema['email'] = new sfValidatorEmail();
}

Sustituir el valor por defecto no siempre es la mejor opción, ya que se pierden las reglas de validación obtenidas a partir del esquema de la base de datos (new sfValidatorString(array('max_length' => 255))). Casi siempre es mejor añadir un nuevo validador a los ya existentes, utilizando para ello el validador especial sfValidatorAnd:

// lib/form/JobeetJobForm.class.php
public function configure()
{
  // ...

  $this->validatorSchema['email'] = new sfValidatorAnd(array(
    $this->validatorSchema['email'],
    new sfValidatorEmail(),
  ));
}

El validador sfValidatorAnd toma como argumento un array de validadores que el valor debe pasar satisfactoriamente para considerarse válido. El truco consiste en utilizar el validador ya existente ($this->validatorSchema['email']) y después añadir el nuevo validador.

Nota Existe otro validador especial llamado sfValidatorOr que se emplea cuando es suficiente con que el valor pase satisfactoriamente al menos uno de los validadores indicados. También es posible combinar los validadores sfValidatorAnd y sfValidatorOr para crear validaciones muy complejas.

Por su parte, aunque la columna type también es de tipo varchar en el esquema de datos, queremos restringir su valor a uno de los tres siguientes valores: full time (jornada completa), part time (jornada parcial) y freelance.

En primer lugar, define los posibles valores en la clase JobeetJobPeer:

// lib/model/JobeetJobPeer.php
class JobeetJobPeer extends BaseJobeetJobPeer
{
  static public $types = array(
    'full-time' => 'Full time',
    'part-time' => 'Part time',
    'freelance' => 'Freelance',
  );

  // ...
}

A continuación, utiliza el widget sfWidgetFormChoice para el campo type:

$this->widgetSchema['type'] = new sfWidgetFormChoice(array(
  'choices'  => JobeetJobPeer::$types,
  'expanded' => true,
));

El widget sfWidgetFormChoice no tiene un equivalente directo en forma de etiqueta HTML, ya que se muestra de forma diferente en función del valor de sus opciones de configuración expanded y multiple:

  • Lista desplegable (<select>): array('multiple' => false, 'expanded' => false)
  • Lista desplegable que permite seleccionar varios valores (<select multiple="multiple">): array('multiple' => true, 'expanded' => false)
  • Lista de radio buttons: array('multiple' => false, 'expanded' => true)
  • Lista de checkboxes: array('multiple' => true, 'expanded' => true)

Nota Si quieres que uno de los radio button se muestre seleccionado inicialmente (full-time por ejemplo), puedes modificar su valor por defecto en el esquema de datos.

Restringir los posibles valores de un campo de formulario no evita que usuarios malintencionados con conocimientos avanzados puedan manipular sus valores con herramientas como curl o la extensión Web Developer Toolbar de Firefox. Por este motivo, vamos a modificar también el validador para restringir los posibles valores a elegir:

$this->validatorSchema['type'] = new sfValidatorChoice(array(
  'choices' => array_keys(JobeetJobPeer::$types),
));

Por otra parte, la columna logo almacena el nombre del archivo que contiene el logotipo asociado con la oferta de trabajo, por lo que debemos cambiar su widget para que muestre un campo de formulario para elegir un archivo:

$this->widgetSchema['logo'] = new sfWidgetFormInputFile(array(
  'label' => 'Company logo',
));

Symfony también genera para cada campo una etiqueta o título que se muestra en la etiqueta <label>. La etiqueta generada se puede modificar con la opción label. También es posible modificar varias etiquetas a la vez utilizando el método setLabels() del array de widgets:

$this->widgetSchema->setLabels(array(
  'category_id'    => 'Category',
  'is_public'      => 'Public?',
  'how_to_apply'   => 'How to apply?',
));

Además, debemos modificar el validador por defecto del campo logo:

$this->validatorSchema['logo'] = new sfValidatorFile(array(
  'required'   => false,
  'path'       => sfConfig::get('sf_upload_dir').'/jobs',
  'mime_types' => 'web_images',
));

El validador sfValidatorFile es muy interesante porque realiza varias tareas:

  • Valida que el archivo subido sea una imagen en un formato adecuado para las páginas web (gracias a la opción mime_types)
  • Cambia el nombre del archivo por un valor único
  • Guarda el archivo en la ruta indicada con la opción path
  • Actualiza el valor de la columna logo con el nombre generado anteriormente

Nota No te olvides de crear el directorio para guardar los logotipos (web/uploads/jobs/) y asegúrate que el servidor web tenga permisos de escritura sobre ese directorio.

Como el validador sólo guarda en la base de datos la ruta relativa hasta la imagen, modifica la ruta utilizada en la plantilla showSuccess:

// apps/frontend/modules/job/templates/showSuccess.php
<img src="/uploads/jobs/<?php echo $job->getLogo() ?>" alt="<?php echo $job->getCompany() ?> logo" />

Nota Si en el modelo existe un método llamado generateLogoFilename(), el validador utiliza este método para generar automáticamente el nombre del archivo subido. Al método anterior se le pasa como argumento el objeto sfValidatedFile.

Además de poder redefinir el valor de las etiquetas generadas para los campos del formulario, también puedes establecer un mensaje de ayuda. Vamos a añadir un mensaje de ayuda para explicar mejor la finalidad del campo is_public:

$this->widgetSchema->setHelp('is_public', 'Whether the job can also be published on affiliate websites or not.');

Combinando todo lo que hemos hecho en esta sección, la clase JobeetJobForm definitiva contiene el siguiente código:

// lib/form/JobeetJobForm.class.php
class JobeetJobForm extends BaseJobeetJobForm
{
  public function configure()
  {
    unset(
      $this['created_at'], $this['updated_at'],
      $this['expires_at'], $this['is_activated']
    );

    $this->validatorSchema['email'] = new sfValidatorAnd(array(
      $this->validatorSchema['email'],
      new sfValidatorEmail(),
    ));

    $this->widgetSchema['type'] = new sfWidgetFormChoice(array(
      'choices' => JobeetJobPeer::$types,
      'expanded' => true,
    ));
    $this->validatorSchema['type'] = new sfValidatorChoice(array(
      'choices' => array_keys(JobeetJobPeer::$types),
    ));

    $this->widgetSchema['logo'] = new sfWidgetFormInputFile(array(
      'label' => 'Company logo',
    ));

    $this->widgetSchema->setLabels(array(
      'category_id'    => 'Category',
      'is_public'      => 'Public?',
      'how_to_apply'   => 'How to apply?',
    ));

    $this->validatorSchema['logo'] = new sfValidatorFile(array(
      'required'   => false,
      'path'       => sfConfig::get('sf_upload_dir').'/jobs',
      'mime_types' => 'web_images',
    ));

    $this->widgetSchema->setHelp('is_public', 'Whether the job can also be published on affiliate websites or not.');
  }
}

10.3.2. La plantilla del formulario

Después de personalizar los campos del formulario, el siguiente paso consiste en mostrarlos. La plantilla del formulario es la misma para el formulario de insertar una oferta de trabajo y para el formulario de modificar los datos de una oferta existente. De hecho, tanto la plantilla newSuccess.php como la plantilla editSuccess.php son muy similares:

<!-- apps/frontend/modules/job/templates/newSuccess.php -->
<?php use_stylesheet('job.css') ?>

<h1>Post a Job</h1>

<?php include_partial('form', array('form' => $form)) ?>

Nota Si todavía no has añadido la hoja de estilos job, debes añadirla en las dos plantillas mediante la instrucción <?php use_stylesheet('job.css') ?>

El formulario se muestra a través de un elemento parcial llamado _form. Reemplaza el contenido de ese elemento parcial _form por el siguiente código:

<!-- apps/frontend/modules/job/templates/_form.php -->
<?php include_stylesheets_for_form($form) ?>
<?php include_javascripts_for_form($form) ?>

<?php echo form_tag_for($form, '@job') ?>
  <table id="job_form">
    <tfoot>
      <tr>
        <td colspan="2">
          <input type="submit" value="Preview your job" />
        </td>
      </tr>
    </tfoot>
    <tbody>
      <?php echo $form ?>
    </tbody>
  </table>
</form>

Los helpers include_javascripts_for_form() y include_stylesheets_for_form() incluyen respectivamente los archivos JavaScript y CSS que utilizan los widgets del formulario.

Nota Aunque el formulario para insertar una nueva oferta de trabajo no utiliza ningún archivo JavaScript o CSS, te recomendamos que dejes la llamada a estos helpers "por si acaso". Estas llamadas pueden venir muy bien posteriormente cuando decidas insertar algún widget que requiere JavaScript o CSS.

El helper form_tag_for() genera una etiqueta <form> a partir del formulario y ruta indicados y modifica el método HTTP a POST o PUT dependiendo de si el objeto es nuevo o no. Este helper también tiene en cuenta si es necesario añadir el atributo enctype en caso de que el formulario permite adjuntar archivos.

Por último, la instrucción <?php echo $form ?> se encarga de generar el código HTML de los widgets del formulario.

10.3.3. La acción del formulario

Ahora que ya tenemos la clase del formulario y la plantilla que lo muestra, vamos a utilizarlo en algunas acciones. El formulario de las ofertas de trabajo lo utilizan los siguientes cinco métodos del módulo job:

  • new: muestra un formulario vacío para insertar una nueva oferta de trabajo.
  • edit: muestra un formulario para modificar los datos almacenados de una oferta de trabajo.
  • create: crea una nueva oferta de trabajo a partir de los datos enviados por el usuario con el formulario.
  • update: actualiza los datos de una oferta de trabajo existente a partir de los datos enviados por el usuario con el formulario.
  • processForm: este método lo utilizan los métodos create y update para procesar el formulario (validación, volver a mostrar los datos del formulario y guardado o serialización en la base de datos).

El flujo de trabajo de todos los formularios se muestra en la siguiente imagen:

Flujo de trabajo de los formularios

Figura 10.1 Flujo de trabajo de los formularios

Como en un tutorial pasado creamos una colección de rutas de Propel para el módulo job, podemos simplificar el código de los métodos que gestionan el formulario:

// apps/frontend/modules/job/actions/actions.class.php
public function executeNew(sfWebRequest $request)
{
  $this->form = new JobeetJobForm();
}

public function executeCreate(sfWebRequest $request)
{
  $this->form = new JobeetJobForm();
  $this->processForm($request, $this->form);
  $this->setTemplate('new');
}

public function executeEdit(sfWebRequest $request)
{
  $this->form = new JobeetJobForm($this->getRoute()->getObject());
}

public function executeUpdate(sfWebRequest $request)
{
  $this->form = new JobeetJobForm($this->getRoute()->getObject());
  $this->processForm($request, $this->form);
  $this->setTemplate('edit');
}

public function executeDelete(sfWebRequest $request)
{
  $request->checkCSRFProtection();

  $job = $this->getRoute()->getObject();
  $job->delete();

  $this->redirect('job/index');
}

protected function processForm(sfWebRequest $request, sfForm $form)
{
  $form->bind(
    $request->getParameter($form->getName()),
    $request->getFiles($form->getName())
  );

  if ($form->isValid())
  {
    $job = $form->save();

    $this->redirect($this->generateUrl('job_show', $job));
  }
}

Cada vez que se accede a la página /job/new, se crea una nueva instancia de un formulario y se pasa a la plantilla en la acción new.

Cuando el usuario envía el formulario (acción create), se asocia (mediante el método bind()) con los valores enviados por el usuario y se ejecuta la validación de los datos.

Cuando el formulario está asociado, ya se puede comprobar su validez con el método isValid(). Si el formulario es válido (el método isValid() devuelve true), la oferta de trabajo se guarda en la base de datos ($form->save()) y se redirige al usuario a la página que previsualiza la oferta. Si el formulario no es válido, se vuelve a mostrar la plantilla newSuccess.php con los mismos datos que envió el usuario y con todos los mensajes de error asociados.

Nota El método setTemplate() modifica la plantilla utilizada por la acción. Si el formulario enviado no es válido, los métodos create y update utilizan la misma plantilla para volver a mostrar en las acciones new y edit el formulario con los mensajes de error asociados.

La modificación de una oferta de trabajo existente es un proceso muy similar. La única diferencia entre la acción new y la acción edit es que en el segundo caso, se pasa como primer argumento del constructor del formulario el objeto que representa la oferta de trabajo que se va a modificar. Este objeto se emplea para establecer los valores iniciales de los widgets de la plantilla (en los formularios de Propel los valores iniciales forman un objeto, pero en los formularios sencillos se indican en forma de array simple).

El formulario para insertar una nueva oferta de trabajo también puede mostrar unos determinados valores iniciales. Una forma sencilla de conseguirlo es declarar esos valores iniciales en el esquema de la base de datos. Otra forma consiste en pasar un objeto modificado de tipo Job al constructor del formulario.

Modifica el método executeNew() para establecer el valor full-time como valor por defecto de la columna type:

// apps/frontend/modules/job/actions/actions.class.php
public function executeNew(sfWebRequest $request)
{
  $job = new JobeetJob();
  $job->setType('full-time');

  $this->form = new JobeetJobForm($job);
}

Nota Cuando el formulario se asocia a los datos del usuario, los valores iniciales se reemplazan por los valores enviados por el usuario. Estos valores se utilizan cuando el formulario debe volver a mostrar los datos introducidos por el usuario después de que la validación no haya sido satisfactoria.

10.3.4. Protegiendo el formulario de las ofertas de trabajo con un token

Ahora mismo el formulario funciona correctamente y el usuario debe indicar el token de la oferta de trabajo. No obstante, el token asociado con la oferta de trabajo se debe generar automáticamente cada vez que se crea una oferta de trabajo, ya que no queremos que sean los usuarios los que tengan que indicar un token único.

Para ello, modifica el método save() de JobeetJob para añadir la lógica que genera el token antes de guardar la oferta de trabajo:

// lib/model/JobeetJob.php
public function save(PropelPDO $con = null)
{
  // ...

  if (!$this->getToken())
  {
    $this->setToken(sha1($this->getEmail().rand(11111, 99999)));
  }

  return parent::save($con);
}

Ahora ya puedes eliminar el campo token del formulario:

// lib/form/JobeetJobForm.class.php
class JobeetJobForm extends BaseJobeetJobForm
{
  public function configure()
  {
    unset(
      $this['created_at'], $this['updated_at'],
      $this['expires_at'], $this['is_activated'],
      $this['token']
    );

    // ...
  }

  // ...
}

Si recuerdas los escenarios que describimos durante el tutorial del día 2, una oferta de trabajo sólo se puede editar si el usuario conoce su token asociado. Ahora mismo es muy sencillo modificar o borrar cualquier oferta de trabajo adivinando su URL. El motivo es que la URL de la acción de modificar la oferta de trabajo siempre es /job/ID/edit, donde ID es la clave primaria de la oferta de trabajo.

Las rutas de tipo sfPropelRouteCollection generan por defecto URL que contienen el valor de la clave primaria, pero se puede modificar por cualquier otra columna cuyo valor sea único indicándolo en la opción column:

# apps/frontend/config/routing.yml
job:
  class:        sfPropelRouteCollection
  options:      { model: JobeetJob, column: token }
  requirements: { token: \w+ }

En la configuración de la ruta anterior también hemos modificado la opción requirements para la columna del token, ya que el requisito por defecto de Symfony para una clave primaria es \d+

Ahora, todas las rutas relacionadas con las ofertas de trabajo salvo job_show_user, incluyen el token y no la clave primaria. La ruta para editar una oferta de trabajo por ejemplo tiene el siguiente aspecto:

http://jobeet.localhost/job/TOKEN/edit

No te olvides de modificar también el enlace de la plantilla showSuccess:

<!-- apps/frontend/modules/job/templates/showSuccess.php -->
<a href="<?php echo url_for('job_edit', $job) ?>">Edit</a>