Calendário do advento symfony dia cinco: formulários e pager

Previamente no symfony

Durante o longo dia dia 4, você começou usado a refactoring de sua aplicação movendo pedaços do código para outros arquivos relacionados a sua natureza. Você aprendeu também, como modificar o modelo de modo que os métodos comuns relacionados aos dados pudessem ser feitos fora do código da action.

O desenvolvimento é limpo, mas o numero de funcionalidades continua pobre. É hora de permitir um pouco de interatividade entre o askeet e seus usuários. E a raiz da interatividade HTMTL - além dos hiperlinks - são os formúlarios.

Os objetivos para hoje são permitir um usuário fazer o login e paginar a lista de questão na pagina inicial. Isto será um desenvolvimento rápido, mas permitirá que você se recupere de ontem.

Formulário de login

Há usuários de teste na base de dados, mas nenhuma maneira para que a aplicação reconheça um. Vamos dar acesso para o formulário de todas as paginas da aplicação. Abra o leiaute global askeet/apps/frontend/templates/layout.php e adicione a seguinte linha, antes do link para about:

[php]
<li><?php echo link_to('sign in', 'user/login') ?></li>

Nota: O leiaute atual posiciona este link atraz da barra de debug. Para vê-la, recolha a barra clicando no ícone 'Sf'.

É hora de criar o modulo user. Enquanto o modulo question foi gerado durante o dia dois, foi criado o esqueleto do modulo, e nós vamos escrever o código.

$ symfony init-module frontend user

Nota: O esqueleto contém a action padrão index e o template indexSuccess.php. Livre-se de ambos, não necessitaremos deles.

Crie a action user/login

No arquivo user/actions/action.class.php (dentro do novo diretorio askeet/apps/frontend/modules/), adicione a seguinte action login:

[php]
public function executeLogin()
{
  $this->getRequest()->setAttribute('referer', $this->getRequest()->getReferer());


return sfView::SUCCESS; }

A ação conserva o referrer em um atributo do pedido. Estará então disponível ao template posto em um campo escondido, de modo que a ação do alvo do formulário possa dirigir de novo ao referer original após um início de uma sessão bem sucedida.

O return sfView::SUCCESS passa o resultado para o template loginSuccess.php. Esta indicação é implicada nas ações que não contêm uma indicação de retorno, isto porque o template padrão de uma ação é chamado actionnameSuccess.php.

Antes de trabalhar mais no action, vamos ver o molde.

Crie o tmeplate loginSuccess.php

Muitas interações homem-computador na web usam formularios, e o symfony facilita a criação e o gerenciamento de formulários provendo uma coleção de helpers de formulários.

No diretório askeet/apps/frontend/modules/user/templates/, crie o template loginSuccess.php:

[php]
<?php echo form_tag('user/login') ?>

  <fieldset>

  <div class="form-row">
    <label for="nickname">nickname:</label>
    <?php echo input_tag('nickname', $sf_params->get('nickname')) ?>
  </div>

  <div class="form-row">
    <label for="password">password:</label>
    <?php echo input_password_tag('password') ?>
  </div>

  </fieldset>

  <?php echo input_hidden_tag('referer', $sf_request->getAttribute('referer')) ?>
  <?php echo submit_tag('sign in') ?>

</form>

Este template é sua primeria introdução aos helpers de fomulários. Estas funções do symfony, ajuda a automatizar a escrita de tags de fomularios. O helper form_tag() abre um formulario com o tipo POST por padrão, e o alvo é dado como argumento. O helper input_tag() produz um tag <input> (isto é uma surpresa) e automaticamente adiciona um atributi id baseado no nome dado como primeiro argumento; o valor padrão é o segundo argumento. Você pode procurar mais sobre os helpers e o código gerado no capitulo relacionado do livro Symfony.

O essencial aqui é o action chamado quando o form é enviado (o argumento do form_tag()) é a mesma action login usada para mostra-la. Assim vamos voltar para a action.

Pegue a submissão do formulário do início de uma sessão

Substitua a action login que escrevemos por este codigo:

[php]
public function executeLogin()
{
  if ($this->getRequest()->getMethod() != sfRequest::POST)
  {
    // display the form
    $this->getRequest()->setAttribute('referer', $this->getRequest()->getReferer());
  }
  else
  {
    // handle the form submission
    $nickname = $this->getRequestParameter('nickname');


$c = new Criteria(); $c->add(UserPeer::NICKNAME, $nickname); $user = UserPeer::doSelectOne($c);

    // nickname exists?
    if ($user)
    {
      // password is OK?
      if (true)
      {
        $this->getUser()->setAuthenticated(true);
        $this->getUser()->addCredential('subscriber');

        $this->getUser()->setAttribute('subscriber_id', $user->getId(), 'subscriber');
        $this->getUser()->setAttribute('nickname', $user->getNickname(), 'subscriber');


// redirect to last page return $this->redirect($this->getRequestParameter('referer', '@homepage')); } } } }

A action login será usada para mostrar o formulário e processá-lo. Em conseqüência, tem que saber em que contexto é chamado. Se a action não é chamada em modo POST, é porque é requisitado de um link: é o caso que falamos antes. Se a requisição é em modo POST, a action foi chamada de um formulário e é hora segurá-lo.

A action pega o valor do campo nickname do parâmetro de request, e requer uma tabela User para ver se o usuário existe na base de dados.

Então haverá, no futuro próximo, um controle da senha que concederá credenciais ao usuário. Por agora, a única coisa que esta action faz é armazenar em um atributo da sessão o id e o nickname do usuário. Eventualmente, a action redireciona para o referer, usando o campo escondido referer no formulário, passado como um parâmetro do request. Se este campo está vazio, o valor padrão (@homepage, qual é o nome da régua do roteamento para quetion/list) é usado.

Observar a diferença entre os dois tipos de atributos neste exemplo: O atributos do request ($this->getRequest()->setAttribute()) são capturados pelo template e esquecidos assim que a resposta é enviada ao referrer. O atributo de sessão ($this->getUser()->setAttribute()) são guardados durante a vida da sessão do usuário, e outras actions vão acessa-los novamente no futuro. Se você conhecer mais sobre sobre atributos, veja capítulo do suporte do parâmetro do livro symfony.

Privilégios

Uma coisa boa que os usuários possam logar-se ao Web site do askeet, mas não o farão apenas para o divertimento. O início de uma sessão será requerido para afixar uma pergunta nova, para declarar o interesse sobre uma pergunta, e para avaliar um comentário. Todos as outras ações serão abertas para usuários não logados.

Para marcar um usuário como autenticado, você precisa chamar o método ->setAuthenticated() do objeto sfUser. Este objeto fornece também um mecanismo dos credenciais (->addCredential()), para refinar a restrição de acesso de acordo com os profiles. O capitulo credencial de usuários do livro symfony explana tudo em detalhes.

Esta é a finalidade das duas linhas:

[php]
$this->getContext()->getUser()->setAuthenticated(true);
$this->getContext()->getUser()->addCredential('subscriber');

Quando um nickname é reconhecido, não somente os dados do usuário irão para os atributos da sessão, mas o usuário será autorizado a acessar partes restritas do site. Veremos amanha como restringir o acesso a algumas partes da aplicação para usuários autenticaso.

Adicione a action user/logout

Há um último truque sobre o método ->setAttribute(): O último argumento (subscriber no exemplo acima) define o namespace onde o atributo será armazenado. Não somente um namespace permite um nome que existe já em um outro namespace a ser dado a um atributo, permite também a remoção rápida de todos seus atributos com um único comando:

[php]
public function executeLogout()
{
  $this->getUser()->setAuthenticated(false);
  $this->getUser()->clearCredentials();

  $this->getUser()->getAttributeHolder()->removeNamespace('subscriber');

  $this->redirect('@homepage');
}

Usar namespaces salvou-nos de remover os dois atributos um por um: Isto é uma linha a menos. Conversa sobre o laziness!

Atualizando o leiaute

O leiaute mostra ainda um link de 'login' mesmo se o usuario está logado. Vamos arrumar isto rapidamente. No askeet/apps/frontend/templates/layout.php, mude a linha que nós adicionamos apenas no começo do tutorial de hoje com:

[php]
<?php if ($sf_user->isAuthenticated()): ?>
  <li><?php echo link_to('sign out', 'user/logout') ?></li>
  <li><?php echo link_to($sf_user->getAttribute('nickname', '', 'subscriber').' profile', 'user/profile') ?></li>
<?php else: ?>
  <li><?php echo link_to('sign in/register', 'user/login') ?></li>
<?php endif ?>

É hora de testar tudo isto mostrando em qualquer pagina da aplicação, clicando no link 'login', entrando um nickname valido ('anonymous' deve ser um truque) e validando isto. Se o link 'login' no topo mudar para 'sign out', você fez tudo certo. Eventualmente, tente fazer logout para checar se o link 'login' aparece novamente.

logged

Você encontrará mais informações sobre a manipulação de atributos de sessão de usuários em capítulo de sessão de usuários do livro do symfony.

Paginador de questões

Como milhares de entusiasta se apressarão ao site do askeet, é muito provável que a lista de questões mostrada na pagina inicial será muito longa. Para evitar pedidos lentos e scrolling longo, é necessarios paginar a lista de questão.

O Symfony prove para este proposito: O sfPropelPager. Ele encapsula o pedido para a base de dados de modo que somente os registros requeridos sejam mostrados na página atual. Por exemplo, se uma paginação é inicializada para mostrar 10 registros por pagina, o pedido para a base de dados será limitado a 10 resultados.

Modifique a action question/list

Durante o dia 3, nós vimos que a action list do modulo question era completamente sucinto:

[php]
public function executeList ()
{
  $this->questions = QuestionPeer::doSelect(new Criteria());
}

Vamos agora modificar esta action para passar um objeto sfPropelPager para o template uma array. E ao mesmo tempo estamos requisitando perguntas pelo numero do interresse:

[php]
public function executeList ()
{
  $pager = new sfPropelPager('Question', 2);
  $c = new Criteria();
  $c->addDescendingOrderByColumn(QuestionPeer::INTERESTED_USERS);
  $pager->setCriteria($c);
  $pager->setPage($this->getRequestParameter('page', 1));
  $pager->setPeerMethod('doSelectJoinUser');
  $pager->init();


$this->question_pager = $pager; }

The initialization of the sfPropelPager object specifies which class of object it will contain, and the maximum number of objects that can be put in a page (two in this example). The ->setPage() method uses a request parameter to set the current page. For instance, if this page parameter has a value of 2, the sfPropelPager will return the results 3 to 5. The default value of the page request parameter being 1, this pager will return the results 1 to 2 by default. You will find more information about the sfPropelPager object and its methods in the pager chapter of the symfony book.

Use a custom parameter

It is always a good idea to put the constants that you use in configuration files. For instance, the number of results per page (2 in this example) could be replaced by a parameter, defined in your custom application configuration. Change the new sfPropelPager line above by:

[php]
...
  $pager = new sfPropelPager('Question', sfConfig::get('app_pager_homepage_max'));

Open the custom application configuration file (askeet/apps/frontend/config/app.yml) and add in:

all:
  pager:
    homepage_max: 2

The pager key here is used as a namespace, that's why it also appears in the parameter name. You will find more about custom configuration and the rules to name custom parameters in the configuration chapter of the symfony book.

Modify the listSuccess.php template

In the listSuccess.php template, just replace the line

[php]
<?php foreach($questions as $question): ?>

by

[php]
<?php foreach($question_pager->getResults() as $question): ?>

so that the page displays the list of results stored in the pager.

Add page navigation

There is one more thing to add to this template: The page navigation. For now, all that the template does is display the first two questions, but we should add the ability to go to the next page, and then to go back to the previous page. To do that, append at the end of the template:

[php]
<div id="question_pager">
<?php if ($question_pager->haveToPaginate()): ?>
  <?php echo link_to('&laquo;', 'question/list?page=1') ?>
  <?php echo link_to('&lt;', 'question/list?page='.$question_pager->getPreviousPage()) ?>

  <?php foreach ($question_pager->getLinks() as $page): ?>
    <?php echo link_to_unless($page == $question_pager->getPage(), $page, 'question/list?page='.$page) ?>
    <?php echo ($page != $question_pager->getCurrentMaxLink()) ? '-' : '' ?>
  <?php endforeach; ?>

  <?php echo link_to('&gt;', 'question/list?page='.$question_pager->getNextPage()) ?>
  <?php echo link_to('&raquo;', 'question/list?page='.$question_pager->getLastPage()) ?>
<?php endif; ?>
</div>

This code takes advantage of the numerous methods of the sfPropelPager object, among which ->haveToPaginate(), which returns true only if the number of results to the request exceeds the page size; ->getPreviousPage(), ->getNextPage() and ->getLastPage(), which have obvious meanings; ->getLinks(), which provides an array of page numbers; and ->getCurrentMaxLink(), which returns the last page number.

This example also shows one handy symfony link helper: link_to_unless() will output a regular link_to() if the test given as the first argument is false, otherwise the text will be output without a link, enclosed in a simple <span>.

Did you test the pager? You should. The modification isn't over until you validate it with your own eyes. To do that, just open the test data file created during day three, and add a few questions for the page navigation to appear. Relaunch the import data batch and request the homepage again. Voila.

paginated list

Add a routing rule for the subsequent pages

By default, the urls of the pages will look like:

http://askeet/frontend_dev.php/question/list/page/XX

Let's take advantage of the routing rules to have those pages understand:

http://askeet/frontend_dev.php/index/XX

Just open the apps/frontend/config/routing.yml file and add at the top:

popular_questions: 
  url:   /index/:page 
  param: { module: question, action: list }

While we are at it, add another routing rule for the login page:

login: 
  url:   /login 
  param: { module: user, action: login }

Refactoring

Model

The question/list action executes code that is closely related to the model, that's why we will move this code to the model. Replace the question/list action by:

[php]
public function executeList () 
{ 
  $this->question_pager = QuestionPeer::getHomepagePager($this->getRequestParameter('page', 1)); 
}

...and add the following method to the QuestionPeer.php class in lib/model:

[php] 
public static function getHomepagePager($page)
{
  $pager = new sfPropelPager('Question', sfConfig::get('app_pager_homepage_max'));
  $c = new Criteria();
  $c->addDescendingOrderByColumn(self::INTERESTED_USERS);
  $pager->setCriteria($c);
  $pager->setPage($page);
  $pager->setPeerMethod('doSelectJoinUser');
  $pager->init();


return $pager; }

The same idea applies to the question/show action, written yesterday: The use of Propel objects to retrieve a question from its stripped title should belong to the model. So change the question/show action by:

[php]
public function executeShow()
{
  $this->question = QuestionPeer::getQuestionFromTitle($this->getRequestParameter('stripped_title'));


$this->forward404Unless($this->question); }

Add to QuestionPeer.php:

[php]
public static function getQuestionFromTitle($title)
{
  $c = new Criteria();
  $c->add(QuestionPeer::STRIPPED_TITLE, $title);

  return self::doSelectOne($c); 
}

Templates

The list of question displayed in question/templates/listSuccess.php will be reused somewhere else in the future. So we will put the template code to display a list of question in a _list.php fragment and replace the listSuccess.php content by a simple:

[php]
<h1>popular questions</h1> 

<?php echo include_partial('list', array('question_pager' => $question_pager)) ?>

The content of the _list.php fragment can be seen in the askeet SVN repository.

See you Tomorrow

Login forms and list pagers are used in almost all web applications nowadays. You saw today that they are quite easy to develop with symfony.

Once again, our day finished by some refactoring. That's the price to pay when you build an application little by little, without designing the big picture first.

Tomorrow, we will continue to work on the login process, by restricting the access of some parts of the site to registered users, and we will do some form validation to avoid incorrect submissions.