Поиск по этому блогу

понедельник, 16 мая 2011 г.

Embeded forms в symfony

Сегодня хочу поговрить о вложенных формах и фреймворке symfony. А точнее описать способ одновременного добавления обьекта и дочерних обьектов с помощью вложенных форм и небольшого количества джаваскрипта.


Итак начнем, будем считать что у вас уже есть настроенный проект на symfony (я использую symfony 1.4.6). В качестве ORM будем использовать Propel. Итак создадим полигон для наших манипуляций:

1. Создадим frontend приложение
Заходим в консоль и пишем заветную строку

php symfony generate:app frontend

В результате получим структуру папок для нашего полигона.

2. Опишем обьекты которыми собираемся оперировать: 1) author - автор, будет иметь дату рождения(birthday) и имя (name). 
2) book - книга, будет иметь дату публикации(pdate), название (title) и ссылаться на своего автора (author_id)


# config/schema.yml
propel:
  author:
    id:            ~
    name:          { type: varchar(255), required: true }
    birthday:     { type: date, required: true }
  books:
    id:            ~
    title:         { type: varchar(255), required: true }
    pdate:         { type: date, required: true }
    author_id:     { type: integer, foreignTable: author, foreignReference: id, required: true }


Перед следующим шагом, те кто не настроил доступ к своей БД может сделать это командой : 


php ./symfony configure:database 'mysql:host=localhost;dbname=test_dev' myName myPassword


3. Итак, сгенерим классы и добавим в нашу базу таблицы по описанным выше обьектам: 


php ./symfony propel:build-all
4. Создадим модуль в котором и будет потом писать код

php ./symfony propel:generate-module frontend author Author

Теперь продя по ссылке http://localhost/author Вы должны увидеть уже вполне рабочий модуль с базовыми CRUD возможностями. 

http://localhost/author
http://localhost/author/new

Начнем с первого екшена - который будет отображать сохраненные данные. На этом етапе он сделан табличной версткой и показует лишь сохраненных авторов, заставим его отображать, так же, количество книг которые написал автор...

В итоге получим следующий шаблон :

apps/frontend/modules/author/templates/indexSuccess.php

<h1>Authors List</h1>

<table>
  <thead>
    <tr>
      <th>Id</th>
      <th>Name</th>
      <th>Birthday</th>
      <th>Books</th>
    </tr>
  </thead>
  <tbody>
    <?php foreach ($Authors as $Author): ?>
    <tr>
      <td><a href="<?php echo url_for('author/edit?id='.$Author->getId()) ?>"><?php echo $Author->getId() ?></a></td>
      <td><?php echo $Author->getName() ?></td>
      <td><?php echo $Author->getBirthday() ?></td>
      <td><?php echo $Author->countBooks() ?></td>
    </tr>
    <?php endforeach; ?>
  </tbody>
</table>

  <a href="<?php echo url_for('author/new') ?>">New</a>
Две выделенные строки - это дописанный код для отображения количества написанных книг.

Теперь можно потестить (посоздавать авторов и подобавлять книги) . Ой, у нас же нету функционала для добавления книг :(. Не беда, создадим модуль для этого, лишь для того что б убедиться что все работает.


php ./symfony propel:generate-module frontend books Book

(Не забудьте определить магический метод __toString() для класса Author )

В моем случае он просто выводит имя автора :

public function __toString() {
    return $this->getName();
} 

Итак, убедившысь что все работает как надо приступим к описанию задания. 

Задание :  разработать функционал для добавления авторов и написанных ними книг через 1 форму.

Настроим контроллер что бы он создавал 2 формы и немного настроим их.

//...
public function executeNew(sfWebRequest $request)
  {
    $this->form = new AuthorForm();
    $this->form->getWidgetSchema()->setFormFormatterName('list');

    $this->book_form = new BookForm();
    $this->book_form->getWidgetSchema()->setFormFormatterName('list');
    $schema = $this->book_form->getWidgetSchema();
    unset($schema['author_id']);
    $schema->setNameFormat($this->form->getName().'['.$this->book_form->getName().'][place_for_index][%s]');
  }
//...

Изначально екшен New создавал только форму для авторов и помещал ее в переменную $this->form которая в последствии будет доступна во вьюхе как $form. Теперь же мы получим кроме переменной $form переменную $book_form (обьект класса BookForm). Я допускаю что подобную операцию можно было бы выполнить и непосредственно во вьюхе, но мы также проводим настройку форм что при использовании в файле шаблона протеворечило бы идеологии MVC.


В строках 12, 15 мы задаем вывод форм в виде елементов списка(по умолчанию поля форма выводятся в виде елементов таблицы ). Далее мы удаляем поле author_id и задаем новый формат имен для полей книжной формы. Обратите внимание на участок place_for_index, мы будем использовать его далее.


В нормальном режиме работы экшн executeNew вызывает шаблон successNew из папки templates своего модуля. При генерации propel модуля мы получили уже готовый шаблон который использует партиал _form, но мы не станем им пользоваться так как он используется и при редактировании. Поетому и для наглядности внесем весь клиентский код в successNew.


<?php use_stylesheets_for_form($form) ?>
<?php use_javascripts_for_form($form) ?>

<h1>New Author</h1>

<script type="text/javascript" >
    $('document').ready(function(){
        $('#main_form').validate();
        var books_count = 0;

        var remover = $('input.remover');

        remover.bind('click', function(){
            $(this).parents('ul.book').remove();
        });

        $('#add_book').bind('click', function(){
            var book_form = $('.book_prototype').clone();

            $(book_form[0].elements).each(function (){
                var field_name = $(this).attr('name');
                $(this).attr('name', field_name.replace('place_for_index', books_count));
            })
            books_count++;
            var form_content = book_form.find('ul.book');
            form_content.append(remover.clone(true));
            form_content.insertBefore(this);
        });

    });
</script>

<form id="main_form" action="<?php echo url_for('author/create') ?>" method="post" >
    
    <ul>
        <?php echo $form->render(); ?>
        <li id="books"> Books:
            <input type="button" id="add_book" value="Add Book" />
        </li>
    </ul>

    <a href="<?php echo url_for('author/index') ?>">Back to list</a>
    <input type="submit" value="Save" />
</form>

<div style="display:none;">
    <form class="book_prototype" action="">
        <ul class="book">
            <?php echo $book_form?$book_form->render():''; ?>
        </ul>
    </form>
    <input type="button" class="remover" value="Remove Book" />
</div>



Первая и вторая строки подгружают на страницу файлы CSS и JS указанные в классе формы (методы: getJavaScripts() и getStylesheets() должны вернуть списки файлов для подключения). В моем случае подгружаются файлы jquery.min.js, jquery.validate.pack.js которые я предварительно положил в папку web/js проекта. Для облегчения клиентского джаваскрипта валидацию натравим на классы - указав их в при конфигурировании форм: 


// lib/form/AuthorForm.class.php
class AuthorForm extends BaseAuthorForm
{
    public function configure()
    {
        $this->validatorSchema->addOption('allow_extra_fields', true);

        $this->widgetSchema['name']->setAttribute('class', 'required');
        $this->widgetSchema['birthday']->setAttribute('class', 'required');
    }
    public function getJavaScripts() {
        return array('jquery.min.js', 'jquery.validate.pack.js');
    }
//...
// lib/form/BookForm.class.php
class BookForm extends BaseBookForm
{
  public function configure()
  {
      $this->validatorSchema->addOption('allow_extra_fields', true);

      $this->widgetSchema['title']->setAttribute('class', 'required');
      $this->widgetSchema['pdate']->setAttribute('class', 'required');
  }
}
//...

Добавление параметра allow_extra_fields равного истине позволит добавить кастомные поля в форму и не напоротся на унылую морду нерпойденного валидатора.

Вернемся к файлу шаблона:
  • строки с 33 по 44 - отображение главной формы (формы авторов), контейнер для форм книжек (37-39) с кнопокой добавления книг;
  • строки с 46 по 53 - контейнер для прототипа форм книжек с кнопкой их удаления;
  • строки с 6 по 31 - немного магии на джава-скрипте
Подробнее остановимся на джава-скрипте

<script type="text/javascript">
    $('document').ready(function(){
        $('#main_form').validate();
        var books_count = 0;

        var remover = $('input.remover');

        remover.bind('click', function(){
            $(this).parents('ul.book').remove();
        });

        $('#add_book').bind('click', function(){
            var book_form = $('.book_prototype').clone();

            $(book_form[0].elements).each(function (){
                var field_name = $(this).attr('name');
                $(this).attr('name', field_name.replace('place_for_index', books_count));
            })
            books_count++;
            var form_content = book_form.find('ul.book');
            form_content.append(remover.clone(true));
            form_content.insertBefore(this);
        });

    });
</script>

  • строка 8 - вешаем валидацию формы;
  • строка 9 - устанавливаем начальное значение счетчика книг;
  • строки с 11 по 15 - вешаем удаление формы книги при нажатии на ее кнопу (кнопку удаления);
  • строки с 17 по 28 - вешаем добавление форм при клике на кнопку "Add book".
При добавлении новой книжной формы мы клонируем "прототип", используем коунтер для выставления имен полям (помните в экшене мы задавали новый шаблон имени для полей книжной формы?) что служит для формирования правильных массивов при отправке форм. Также клонируем кнопку удаления формы (значение true - заставляет клонировать не только обьект но и навешанные на него ивенты).

Теперь серверная часть.
Созданый для нас метод executeCreate() вполне нас устраивает, а вот вызываемый им метод processForm() - мы перепишем:

// apps/frontend/modules/author/actions/actions.class.php 
protected function processForm(sfWebRequest $request, sfForm $form)
  {
    $params = $request->getParameter($form->getName());

    $FormForConfiguration = new BookForm();
    if(count($params[$FormForConfiguration->getName()])){
        $form->embedFormForEach($FormForConfiguration->getName(),
                                $FormForConfiguration,
                                count($params[$FormForConfiguration->getName()])
                               );
        sort($params[$FormForConfiguration->getName()]);
    }

    $form->bind($params, $request->getFiles($form->getName()));

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

      $this->redirect('author/edit?id='.$Author->getId());
    }
  }
//...

В строках с 6  по 13 мы узнаем количество добавленныех книжных форм и пачкой клеим их к нашей форме. Функцию sort используем что бы реиндексить массив на случай если значения в нем будут идти с пропусками (к примеру :  1,2,3,5,7). В строке 15 парсим в форму значения, потом проверяем ее и сохраняем.

Попробуйте и у вас не сработает ;)
Остался последний штрих - класс формы.

<?php
class AuthorForm extends BaseAuthorForm
{
    public function configure()
    {
        $this->validatorSchema->addOption('allow_extra_fields', true);

        $this->widgetSchema['name']->setAttribute('class', 'required');
        $this->widgetSchema['birthday']->setAttribute('class', 'required');
    }
    public function getJavaScripts() {
        return array('jquery.min.js', 'jquery.validate.pack.js');
    }
    protected function setAuthorIdForEmbeddedForms($eForms)
    {
        if(!is_array($eForms))
            return false;

        foreach ($eForms as $Form)
        {
            if(method_exists($Form, 'getObject'))
            {
                $obj = $Form->getObject();
                if(method_exists($obj, 'setAuthorId'))
                    $obj->setAuthorId($this->getObject()->getId());
            }

            if($Form->getEmbeddedForms())
                $this->setAuthorIdForEmbeddedForms ($Form->getEmbeddedForms());
        }

        return true;
    }
    public function  saveEmbeddedForms($con = null, $forms = null)
    {
        $this->setAuthorIdForEmbeddedForms($this->getEmbeddedForms());

        parent::saveEmbeddedForms($con, $forms);
    }

    public function  embedForm($name, sfForm $form, $decorator = null)
    {
        $form->getWidgetSchema()->setNameFormat($this->getName().'['.$name.'][%s]');
        parent::embedForm($name, $form, $decorator);

        $this->validatorSchema[$name]['author_id'] = new sfValidatorPass();
    }

    public function  embedFormForEach($name, sfForm $form, $n, $decorator = null, $innerDecorator = null, $options = array(), $attributes = array(), $labels = array())
    {
       if (true === $this->isBound() || true === $form->isBound())
        {
          throw new LogicException('A bound form cannot be embedded');
        }

        $this->embeddedForms[$name] = new sfForm();

        $form = clone $form;
        unset($form[self::$CSRFFieldName]);

        $widgetSchema = $form->getWidgetSchema();

        $class = $form->getModelName().'Form';// Get name of embeded form class
        // generate default values
        $defaults = array();
        for ($i = 0; $i < $n; $i++)
        {
          $defaults[$i] = $form->getDefaults();

          $this->embeddedForms[$name]->embedForm($i, new $class());// Use new instance of form instead of clonned one - cause clonned forms has links to single object
        }

        $this->setDefault($name, $defaults);

        $decorator = null === $decorator ? $widgetSchema->getFormFormatter()->getDecoratorFormat() : $decorator;
        $innerDecorator = null === $innerDecorator ? $widgetSchema->getFormFormatter()->getDecoratorFormat() : $innerDecorator;

        $this->widgetSchema[$name] = new sfWidgetFormSchemaDecorator(new sfWidgetFormSchemaForEach(new sfWidgetFormSchemaDecorator($widgetSchema, $innerDecorator), $n, $options, $attributes), $decorator);
        $this->validatorSchema[$name] = new sfValidatorSchemaForEach($form->getValidatorSchema(), $n);

        // generate labels
        for ($i = 0; $i < $n; $i++)
        {
          if (!isset($labels[$i]))
          {
            $labels[$i] = sprintf('%s (%s)', $this->widgetSchema->getFormFormatter()->generateLabelName($name), $i);
          }
        }

        $this->widgetSchema[$name]->setLabels($labels);

        $this->resetFormFields();

        //Now we need to configure validators for passing non-existing id (we didn't have Author object for passing id)
        for($i = 0; $i < $n; $i++)
        {
            $this->validatorSchema[$name][$i]['author_id'] = new sfValidatorPass();
        }
    }
}

Итак последнее разьяснение. 
Первые 2 метода мы уже рассматривали, идем дальше:
  • setAuthorIdForEmbeddedForms - мотод для установления значения id сохраненного автора в прикрепленные к нему формы (формы книг);
  • saveEmbeddedForms - собственно метот в котором мы используем предидущий метод;
  • embedForm - метод добавления 1 вложенной формы, в него мы добавили генерации принятого формата имени и удаления валидации поля author_id которое мы просто не можем получить не сохранив обьект автора;
  • embedFormForEach - не пугайтесь что он такой большой - необходимо было внести правки в середину кода метода и потому пришлось его полностью скопировать. Строки 63, 70 - Узанем имя класса для формы и используем новый обьект для вставки (в оригинале форма клонировалась и использовались клоны, но при этом возникала проблема что после сохранения в базу попадали значения лиш одной формы продублированые несколько раз, было это из-за того что форма хранит обьект класса который после и будет сохранен, а при клонировании обьект (в нашем случае обьект отдельной книжки) не клонировался как форма, а передавался по ссылке, PHP5 - однако ). И в последних строчках мы проходимся по добавленным формам и удаляем валидацию для поля author_id.
На этом все манипуляции закончены и можете приступить к тесту своих форм. 


Комментариев нет:

Отправить комментарий