Giorno 7: Giocare con la pagina delle categorie

Ieri avete ampliato le conoscenze su symfony in diverse aree: oggetti di ##ORM##, fixture, routing, debug e configurazioni personalizzate. Abbiamo finito con una piccola sfida per oggi.

Spero abbiate lavorato sulla pagina delle categorie di Jobeet in modo che il tutorial di oggi abbia quanto più valore possibile per voi.

Pronti? Parliamo di una possibile implementazione.

La rotta per la categoria

Prima di tutto abbiamo bisogno di aggiungere una rotta per definire un URL carino per la pagina delle categorie. Aggiungere questo all'inizio del file che si occupa del routing:

#

Ogni volta che si parte a implementare una nuova feature, è buona norma pensare prima all'argomento ~URL~, creando la rotta associata. Ed è obbligatorio, se le regole predefinite di ~routing~ sono state rimosse.

Una rotta può usare una qualsiasi colonna del suo oggetto correlato come parametro. Può anche usare un altro valore, se c'è un metodo di accesso collegato definito nella classe dell'oggetto. Siccome slug non ha una colonna corrispondente nella tabella category, abbiamo bisogno di aggiungere un accesso virtuale a JobeetCategory per rendere la rotta funzionante:

 
 

// lib/model/JobeetCategory.php // lib/model/doctrine/JobeetCategory.class.php public function getSlug() { return Jobeet::slugify($this->getName()); }

Il link alla categoria

Ora editiamo il template indexSuccess.php del modulo job per aggiungere il link alla pagina delle categorie:

<!-- some HTML code -->
 
        <h1>
          <?php echo link_to($category, 'category', $category) ?>
        </h1>
 
<!-- some HTML code -->
 
      </table>
 
      <?php if (($count = $category->countActiveJobs() -
          ➥ sfConfig::get('app_max_jobs_on_homepage')) > 0): ?>
        <div class="more_jobs">
          and <?php echo link_to($count, 'category', $category) ?>
          more...
        </div>
      <?php endif; ?>
    </div>
  <?php endforeach; ?>
</div>
 

Aggiungiamo il link solamente se ci sono più di 10 offerte di lavoro da visualizzare per la categoria corrente. Il link contiene il numero di offerte non visualizzate. Per rendere il template funzionante abbiamo bisogno di aggiungere il metodo countActiveJobs() a JobeetCategory:

[php] // lib/model/JobeetCategory.php public function countActiveJobs() { $criteria = new Criteria(); $criteria->add(JobeetJobPeer::CATEGORY_ID, $this->getId());

  return JobeetJobPeer::countActiveJobs($criteria);
}

Il metodo countActiveJobs() usa un metodo countActiveJobs() che non esiste ancora in JobeetJobPeer: sostituiamo il contenuto del file JobeetJobPeer.php con il codice seguente: [php] // lib/model/doctrine/JobeetCategory.class.php public function countActiveJobs() { $q = Doctrine_Query::create() ->from('JobeetJob j') ->where('j.category_id = ?', $this->getId());

  return Doctrine_Core::getTable('JobeetJob')->countActiveJobs($q);
}

Il metodo countActiveJobs() usa un metodo countActiveJobs() che non esiste ancora in JobeetJobTable.

[php] // lib/model/JobeetJobPeer.php class JobeetJobPeer extends BaseJobeetJobPeer { static public function getActiveJobs(Criteria $criteria = null) { return self::doSelect(self::addActiveJobsCriteria($criteria)); }

  static public function countActiveJobs(Criteria $criteria = null)
  {
    return self::doCount(self::addActiveJobsCriteria($criteria));
  }

  static public function addActiveJobsCriteria(Criteria $criteria = null)
  {
    if (is_null($criteria))
    {
      $criteria = new Criteria();
    }

    $criteria->add(self::EXPIRES_AT, time(), Criteria::GREATER_THAN);
    $criteria->addDescendingOrderByColumn(self::CREATED_AT);

    return $criteria;
  }

  static public function doSelectActive(Criteria $criteria)
  {
    return self::doSelectOne(self::addActiveJobsCriteria($criteria));
  }
}

Come si può vedere, abbiamo eseguito la rifattorizzazione di tutto il codice di JobeetJobPeer per introdurre un nuovo metodo condiviso addActiveJobsCriteria() per rendere il codice più ~DRY~ (Don't Repeat Yourself). [php] // lib/model/doctrine/JobeetJobTable.class.php class JobeetJobTable extends Doctrine_Table { public function retrieveActiveJob(Doctrine_Query $q) { return $this->addActiveJobsQuery($q)->fetchOne(); }

  public function getActiveJobs(Doctrine_Query $q = null)
  {
    return $this->addActiveJobsQuery($q)->execute();
  }

  public function countActiveJobs(Doctrine_Query $q = null)
  {
    return $this->addActiveJobsQuery($q)->count();
  }

  public function addActiveJobsQuery(Doctrine_Query $q = null)
  {
    if (is_null($q))
    {
      $q = Doctrine_Query::create()
        ->from('JobeetJob j');
    }

    $alias = $q->getRootAlias();

    $q->andWhere($alias . '.expires_at > ?', date('Y-m-d H:i:s', time()))
      ->addOrderBy($alias . '.expires_at DESC');

    return $q;
  }
}

Come si può vedere, abbiamo eseguito la rifattorizzazione di tutto il codice di JobeetJobTable per introdurre un nuovo metodo condiviso addActiveJobsQuery(), per rendere il codice più ~DRY~ (Don't Repeat Yourself).

La prima volta che una porzione di codice viene riutilizzata, copiare il codice può essere sufficiente. Ma se scopre un nuovo modo per utilizzarlo è sempre meglio eseguire la rifattorizzazione per tutti gli usi verso un metodo od una funzione condivisa, come abbiamo fatto qui.

Nel metodo countActiveJobs(), invece di usare doSelect() e poi ~contare~ il numero dei risultati, abbiamo utilizzato il metodo doCount(), che è più veloce. Nel metodo countActiveJobs(), invece di usare execute() e poi ~contare~ il numero dei risultati, abbiamo utilizzato il metodo count(), che è più veloce.

Abbiamo apportato modifiche a molti file solamente per una feature molto semplice. Ma ogni volta che abbiamo aggiunto del codice, abbiamo provato a metterlo nello strato corretto dell'applicazione, abbiamo inoltre fatto in modo di creare codice riusabile. Durante questo processo abbiamo anche fatto la rifattorizzazione di parti di codice esistente. Questo è il flusso tipico quando si lavora su un progetto symfony. Nella schermata seguente, per questioni di spazio, si vedono solo 5 lavori. In realtà dovrebbero vedersene 10 (secondo le impostazioni max_jobs_on_homepage):

Homepage

Creazione del modulo delle categorie

È il momento di creare il ~modulo~ category:

$ php symfony generate:module frontend category

Se avete creato un modulo, avrete probabilmente utilizzato il comando propel:generate-module. Questo va bene, ma visto che il 90% del codice generato in questo caso non ci serve, meglio usare il comando generate:module, che crea un modulo vuoto.

Perché non aggiungere un'azione category al modulo job? Potremmo, ma visto che il soggetto principale della pagina delle categorie è una categoria, sembra più naturale creare un modulo category dedicato.

Accedendo alla pagina delle categorie, la rotta category dovrà trovare la categoria associata alla variabile slug presente nella richiesta. Ma siccome lo ~slug~ non è memorizzato nel database e siccome non possiamo dedurre la categoria dallo slug, non c'è modo di individuare la categoria associata allo slug.

Aggiornare il database

Dobbiamo aggiungere una colonna slug alla tabella category:

[yml] # config/schema.yml propel: jobeet_category: id: ~ name: { type: varchar(255), required: true } slug: { type: varchar(255), required: true, index: unique } La colonna slug può occuparsi di un comportamento di Doctrine chiamato Sluggable. Basta abilitare i comportamenti (behavior) nel modello JobeetCategory ed esso si occuperà di tutto.

---
# config/doctrine/schema.yml
JobeetCategory:
  actAs:
    Timestampable:
    Sluggable:
      fields: [name]
  columns:
    name:
      type: string(255)
      notnull:  true

Ora che slug è una vera colonna, dobbiamo rimuovere il metodo getSlug() da JobeetCategory.

Ogni volta che il nome di category cambia, dobbiamo calcolare e cambiare anche slug. Ridefiniamo il metodo setName():

// lib/model/JobeetCategory.php
public function setName($name)
{
  parent::setName($name);
 
  $this->setSlug(Jobeet::slugify($name));
}
 

L'impostazione della colonna slug viene presa in considerazione automaticamente quando si salva una riga. Lo slug è costruito usando il valore del campo name e impostato nell'oggetto.

usiamo il task propel:build --all --and-load per aggiornare le tabelle del database e ripopoliamo il database con le nostre fixture:

$ php symfony propel:build --all --and-load --no-confirmation

Ora è tutto pronto per creare il metodo executeShow(). Sostituiamo il contenuto del file delle azioni category con il codice seguente:

// apps/frontend/modules/category/actions/actions.class.php
class categoryActions extends sfActions
{
  public function executeShow(sfWebRequest $request)
  {
    $this->category = $this->getRoute()->getObject();
  }
}
 

Siccome abbiamo rimosso il metodo generato executeIndex(), possiamo rimuovere anche il template generato automaticamente indexSuccess.php (apps/frontend/modules/category/templates/indexSuccess.php).

L'ultimo passo è creare il template showSuccess.php:

// apps/frontend/modules/category/templates/showSuccess.php
<?php use_stylesheet('jobs.css') ?>
 
<?php slot('title', sprintf('Jobs in the %s category', $category->getName())) ?>
 
<div class="category">
  <div class="feed">
    <a href="">Feed</a>
  </div>
  <h1><?php echo $category ?></h1>
</div>
 
<table class="jobs">
  <?php foreach ($category->getActiveJobs() as $i => $job): ?>
    <tr class="<?php echo fmod($i, 2) ? 'even' : 'odd' ?>">
      <td class="location">
        <?php echo $job->getLocation() ?>
      </td>
      <td class="position">
        <?php echo link_to($job->getPosition(), 'job_show_user', $job) ?>
      </td>
      <td class="company">
        <?php echo $job->getCompany() ?>
      </td>
    </tr>
  <?php endforeach; ?>
</table>
 

I partial

Notate che abbiamo copiato e incollato il tag <table> per creare una lista di lavori per il template indexSuccess.php. Non va bene. È ora di imparare un nuovo trucco. Quando si ha bisogno di riutilizzare alcune parti di un template, occorre creare un ~partial~. Un partial è un pezzo di codice di un ~template~ che può essere condiviso con altri template. Un partial è semplicemente un altro template che inizia con un trattino basso (_):

Creaiamo il file _list.php:

// apps/frontend/modules/job/templates/_list.php
<table class="jobs">
  <?php foreach ($jobs as $i => $job): ?>
    <tr class="<?php echo fmod($i, 2) ? 'even' : 'odd' ?>">
      <td class="location">
        <?php echo $job->getLocation() ?>
      </td>
      <td class="position">
        <?php echo link_to($job->getPosition(), 'job_show_user', $job) ?>
      </td>
      <td class="company">
        <?php echo $job->getCompany() ?>
      </td>
    </tr>
  <?php endforeach; ?>
</table>
 

Si possono includere i partial usando l'helper ~include_partial~():

<?php include_partial('job/list', array('jobs' => $jobs)) ?>
 

Il primo parametro di include_partial() è il nome del partial (fatto dal nome del modulo, una barra / e il nome del partial senza il _ iniziale). Il secondo parametro è un array di variabili da passare al partial.

Perché non usare il costrutto include() di PHP al posto dell'helper include_partial()? La differenza principale tra i due è il supporto per la cache incluso nell'helper include_partial().

Sostituiamo il codice HTML delle <table> in entrambi in template con una chiamata a include_partial():

// in apps/frontend/modules/job/templates/indexSuccess.php
<?php include_partial('job/list', array('jobs' => $category->getActiveJobs(sfConfig::get('app_max_jobs_on_homepage')))) ?>
 
// in apps/frontend/modules/category/templates/showSuccess.php
<?php include_partial('job/list', array('jobs' => $category->getActiveJobs())) ?>
 

~Impaginazione~ delle liste

Dai requisiti del giorno 2:

"La lista è impaginata con 20 lavori per pagina."

Per impaginare una lista di oggetti ##ORM##, symfony fornisce una classe dedicata:sfPropelPager. Nell'azione category, invece di passare gli oggetti dei lavori al template, si passa un paginatore:

// apps/frontend/job/modules/category/actions/actions.class.php
public function executeShow(sfWebRequest $request)
{
  $this->category = $this->getRoute()->getObject();
 
  $this->pager = new sfPropelPager(
    'JobeetJob',
    sfConfig::get('app_max_jobs_on_category')
  );
 

$this->pager->setCriteria($this->category->getActiveJobsCriteria()); $this->pager->setQuery($this->category->getActiveJobsQuery()); $this->pager->setPage($request->getParameter('page', 1)); $this->pager->init(); }

Il metodo sfRequest::getParameter() ha un valore di default nel secondo parametro. Nell'azione vista sopra, il parametro della richiesta page non esiste, quindi getParameter() restituirà 1.

Il costruttore di sfPropelPager prende una classe di modello e il numero massimo di unità da restituire per ogni pagina. Aggiungiamo il secondo valore al file di configurazione:

---
# apps/frontend/config/app.yml
all:
  active_days:          30
  max_jobs_on_homepage: 10
  max_jobs_on_category: 20

Il metodo sfPropelPager::setCriteria() accetta un oggetto Criteria da usare quando seleziona le unità dal database. Il metodo sfDoctrinePager::setQuery() accetta un oggetto Doctrine_Query da usare quando seleziona le unità dal database.

Aggiungiamo il metodo getActiveJobsCriteria(): Aggiungiamo il metodo getActiveJobsQuery():

[php] // lib/model/JobeetCategory.php public function getActiveJobsCriteria() { $criteria = new Criteria(); $criteria->add(JobeetJobPeer::CATEGORY_ID, $this->getId());

  return JobeetJobPeer::addActiveJobsCriteria($criteria);
}

[php] // lib/model/doctrine/JobeetCategory.class.php public function getActiveJobsQuery() { $q = Doctrine_Query::create() ->from('JobeetJob j') ->where('j.category_id = ?', $this->getId());

  return Doctrine_Core::getTable('JobeetJob')->addActiveJobsQuery($q);
}

Ora che abbiamo definito il metodo getActiveJobsCriteria(), possiamo rifattorizzare gli altri metodi JobeetCategory per utilizzarlo: Ora che abbiamo definito il metodo getActiveJobsQuery(), possiamo rifattorizzare gli altri metodi JobeetCategory per utilizzarlo:

[php] // lib/model/JobeetCategory.php public function getActiveJobs($max = 10) { $criteria = $this->getActiveJobsCriteria(); $criteria->setLimit($max);

  return JobeetJobPeer::doSelect($criteria);
}

public function countActiveJobs()
{
  $criteria = $this->getActiveJobsCriteria();

  return JobeetJobPeer::doCount($criteria);
}

[php] // lib/model/doctrine/JobeetCategory.class.php public function getActiveJobs($max = 10) { $q = $this->getActiveJobsQuery() ->limit($max);

  return $q->execute();
}

public function countActiveJobs()
{
  return $this->getActiveJobsQuery()->count();
}

Infine, aggiorniamo il template:

<!-- apps/frontend/modules/category/templates/showSuccess.php -->
<?php use_stylesheet('jobs.css') ?>
 
<?php slot('title', sprintf('Jobs in the %s category', $category->getName())) ?>
 
<div class="category">
  <div class="feed">
    <a href="">RSS Feed</a>
  </div>
  <h1><?php echo $category ?></h1>
</div>
 
<?php include_partial('job/list', array('jobs' => $pager->getResults())) ?>
 
<?php if ($pager->haveToPaginate()): ?>
  <div class="pagination">
    <a href="<?php echo url_for('category', $category) ?>?page=1">
      <img src="/images/first.png" alt="First page" title="First page" />
    </a>
 
    <a href="<?php echo url_for('category', $category) ?>?page=<?php echo $pager->getPreviousPage() ?>">
      <img src="/images/previous.png" alt="Previous page" title="Previous page" />
    </a>
 
    <?php foreach ($pager->getLinks() as $page): ?>
      <?php if ($page == $pager->getPage()): ?>
        <?php echo $page ?>
      <?php else: ?>
        <a href="<?php echo url_for('category', $category) ?>?page=<?php echo $page ?>"><?php echo $page ?></a>
      <?php endif; ?>
    <?php endforeach; ?>
 
    <a href="<?php echo url_for('category', $category) ?>?page=<?php echo $pager->getNextPage() ?>">
      <img src="/images/next.png" alt="Next page" title="Next page" />
    </a>
 
    <a href="<?php echo url_for('category', $category) ?>?page=<?php echo $pager->getLastPage() ?>">
      <img src="/images/last.png" alt="Last page" title="Last page" />
    </a>
  </div>
<?php endif; ?>
 
<div class="pagination_desc">
  <strong><?php echo count($pager) ?></strong> jobs in this category
 
  <?php if ($pager->haveToPaginate()): ?>
    - page <strong><?php echo $pager->getPage() ?>/<?php echo $pager->getLastPage() ?></strong>
  <?php endif; ?>
</div>
 

La maggior parte di questo codice ha a che fare con link ad altre pagine. Ecco una lista di metodi sfPropelPager usati in questo template:

  • getResults(): Restituisce un array di oggetti ##ORM## per la pagina corrente
  • getNbResults(): Restituisce il numero totale di risultati
  • haveToPaginate(): Restituisce true se c'è più di una pagina
  • getLinks(): Restituisce una lista di link alle pagine da mostrare
  • getPage(): Restituisce il numero della pagina corrente
  • getPreviousPage(): Restituisce il numero della pagina precedente
  • getNextPage(): Restituisce il numero della pagina successiva
  • getLastPage(): Restituisce il numero dell'ultima pagina

Poiché sfPropelPager implementa anche le interfaccie Iterator e Countable, si può usare la funzione count() per ottenere il numero dei risultati, invece del metodo getNbResults().

Paginazione

A domani

Se ieri avete lavorato sulla vostra implementazione e pensate di non aver imparato molto oggi, vuol dire che vi state abituando alla filosofia di symfony. Il processo di aggiungere una nuove feature a un sito symfony è sempre lo stesso: pensa agli URL, crea alcune azioni, aggiorna il modello, e scrivi qualche template. E se si riescono ad applicare alcune buone pratiche di sviluppo, si padroneggerà symfony in poco tempo.

Domani inizierà una nuova settimana per Jobeet. Per celebrarla, parleremo di un nuovo argomento: i test.

ORM