Инструменты пользователя

Инструменты сайта


limb3_2007_3:ru:tutorials:shop:step3

Шаг3. Создание и отображение списка товаров для администраторов

Часто используемые шаблоны

Пожалуй, мы начнем с создания шаблонов, которые будут использоваться на многих других шаблонах в панели управления и на фронтальной части. Это шаблоны:

  • pager.html - пейджер для постраничного вывода
  • form_errors.html - список ошибок валидации форм

Исходники этих файлов мы практически без изменений взяли из первого примера.

Файл shop/template/pager.html:

<pager:NAVIGATOR id="pager" items="5">
 
<table width='690' border='0' cellspacing='0' cellpadding='0' class='pager'>
<tr>
  <td width='50%' align='left'>
    <pager:FIRST><a href="{$href}" title="First Page">&#60;&#60;&nbsp;</a></pager:FIRST>
 
    <pager:PREV><a href="{$href}" title="Previous Page">&#60;&nbsp;</a></pager:PREV>
 
    <pager:LIST>
 
    <pager:CURRENT>&nbsp;<b>[&nbsp;{$number}&nbsp;]</b>&nbsp;</pager:CURRENT>
    <pager:NUMBER><a href="{$href}">&nbsp;{$number}&nbsp;</a></pager:NUMBER>
 
    </pager:LIST>
 
    &nbsp;
    <pager:NEXT><a href="{$href}" title="Next Page">&#62;</a></pager:NEXT>
    &nbsp;
    <pager:LAST><a href="{$href}" title="Last Page">&#62;&#62;</a></pager:LAST>
  </td>
  <td align='right'>
    Shown: from <b>{$BeginItemNumber}</b> to <b>{$EndItemNumber}</b>
    Total: <b>{$TotalItems}</b><br />
  </td>
</tr>
</table>
 
</pager:NAVIGATOR>

Подробнее об используемых здесь тегах в разделе "Постраничный вывод данных в WACT-шаблонах".

Файл shop/template/form_errors.html:

<form:errors target='errors'/>
<list:list id='errors'>
<span class='title_error'>Form was not saved since form fields have the following errors:</span>
  <ul class='list_errors'>
    <list:ITEM>
      <li>{$message}</li>
    </list:ITEM>
  </ul>
<p><strong>Please fix these errors and submit the form once again</strong></p>
<div class="hr"></div>
</list:list>

Напомним, что тег <form:errors> используется для отображения списка ошибок валидации формы.

Класс Product

Создадим класс Product. Будем считать, что поля title, description и price являются обязательными.

Файл shop/src/model/Product.class.php:

class Product extends lmbActiveRecord
{
  protected $_default_sort_params = array('title' => 'ASC');
 
  protected function _createValidator()
  {
    $validator = new lmbValidator();
    $validator->addRequiredRule('title');
    $validator->addRequiredRule('description');
    $validator->addRequiredRule('price');
    return $validator;
  }
 
  function getImagePath()
  {
    if(($image_name = $this->getImageName()) && file_exists(PRODUCT_IMAGES_DIR.$image_name)) {
      return '/product_images/' . $image_name;
    } else {
      return '/images/no_image.gif';
    }
  }
}

Мы перекрыли в классе фабричный метод _createValidator(). Этот метод возвращает валидатор, который используется для проверки данных внутри объектов Product при сохранении. Подробнее о валидации данных в классах ACTIVE_RECORD в разделе "Валидация данных в объектах".

Мы также установили сортировку продуктов по-умолчанию при помощи атрибута $_default_sort_params. Теперь при выборках продукты будут сортироваться по заголовку в алфавитном порядке. Подробнее о сортировке по-умолчанию можно узнать в разделе "Поиск и сортировка объектов".

Мы также создали метод getImagePath(). Этот метод по сути вводит новое виртуальное поле $image_path, которое можно использовать в шаблонах (пример будет показан ниже). Поле $image_path мы ввели для того, чтобы не выносить знания о папке product_images/ в шаблоны, и при желании эту папку можно будет легко сменить. Если товар не имеет изображения, тогда будет выводиться /images/no_image.gif

Создание, редактирование и удаление товаров

Шаблон admin_product/display.html

Файл shop/template/admin_product/display.html:

<core:set title='Products'/>
<core:WRAP file="admin_page.html" as="content">
 
<route_url params="action:create">Create new product</route_url><p/>
 
<active_record:fetch using='src/model/Product' target="products" navigator='pager'/>
 
<core:include file='pager.html'/>
 
<list:list id="products">
<table cellpadding="0" cellspacing="0" class='list' width='100%'>
  <thead>
  <tr>
    <th>Title/Image/Price/Description</th>
    <th>Actions</th>
  </tr>
  </thead>
  <list:item>
  <tr>
   <td>
      <dl>
        <dt>
          <b>{$title}</b><br />
          Price:<b>{$price}</b><br/>
         </dt>
         <dd>
            <img src='{$image_path}' class='img'/>
            {$description|nl2br|raw}
         </dd>
      </dl>
   </td>
   <td>
     <route_url params="action:edit,id:{$id}">Edit</route_url>&nbsp;
     <route_url params="action:delete,id:{$id}">Delete</route_url>
   </td>
  </tr>
  </list:item>
</table>
</list:list>
</core:wrap>

Поясним некоторые моменты:

  • При помощи <active_record:fetch> мы осуществляем запрос к базе данных и получаем итератор с продуктами. Этот итератор передается в тег с идентификатором, соответствующим аттрибуту target.
  • При помощи атрибута navigator тега <active_record:fetch> мы связали pager с нашим итератором продуктов. Таким образом мы разбили список продуктов на несколько страниц.
  • Сам пейджер мы подключили при помощи тега <core:include>.
  • При помощи тега <list:list> и <list:item> выводятся списковые данные.
  • Фильтр raw в выражении {$description|nl2br|raw} применяется для того, чтобы отменить действие фильтра html, который применяет фунцию htmlspecialchars для значения выводимой переменной. Фильтр nl2br используется для применения функции nl2br к выводимой переменной $description.
  • Поля $image_path в таблице product нет. Это виртуальное поле и для него мы создали метод Product :: getImagePath(), который будет возвращать путь до изображения.

Дополнительная информация о том, как данные из ActiveRecord-ов попадает в шаблоны доступна в разделе Использование ACTIVE_RECORD в шаблонах WACT. Предупреждение: некоторые вещи пока вам могут быть незнакомыми!

Шаблоны admin_product/create.html и admin_product/edit.html

Файл shop/template/admin_product/create.html:

<core:set title='Create new product'/>
<core:wrap file="admin_page.html" as="content">
 
<form id='product_form' name='product_form' method='post' runat='server' enctype="multipart/form-data">
 
  <core:include file="form_errors.html"/>
 
  <core:include file="admin_product/form_fields.html"/>
 
  <input id='create' type='submit' value='Create' runat='client'/>
 
</form>
 
</core:wrap>

Файл shop/template/admin_product/edit.html:

<core:set title='Edit product'/>
<core:wrap file="admin_page.html" as="content">
 
<form id='product_form' name='product_form' method='post' runat='server' enctype="multipart/form-data">
 
  <core:include file="form_errors.html"/>
 
  <core:include file="admin_product/form_fields.html"/>
 
  <input id='edit' type='submit' value='Edit' runat='client'/>
 
</form>
 
</core:wrap>

Напомним, что runat указывает компилятору шаблонов, должен ли тегу соответствовать активный компонент. Элементы формы по-умолчанию наследуют значение этого атрибута, поэтому для кнопок create и edit мы использовали runat='client' чтобы отменить наследование. Подробнее об этом в разделе "Теги форм с активными компонентами или без".

Так как поля для обоих форм одинаковые, мы решили вынести их в общий шаблон form_fields.html

Файл shop/template/admin_product/form_fields.html:

<div class="field">
  <label for="title">Title:</label>
  <input name='title' type='text' title='Title' size='50' class='input'/><br/>
</div>
<div class="field">
  <label for="price">Price:</label>
  <input name='price' type='text' title='Price' size='20' class='input'/><br/>
</div>
<div class="field">
  <label for="is_available">Available at the moment?:</label>
  <js_checkbox name='is_available' class='input'/><br/>
</div>
<div class="field">
  <label for="description">Description:</label>
  <textarea name='description' rows='2' title='Description' cols='40' rows='8' class='input'></textarea><br/>
</div>
<div class="field">
  <label for="image">Image:</label>
  <input name="image" type="file" size="40" class='input'/><br/>
</div>
<div class="field">
  <core:optional for='image_path'>
    <img src='{$image_path}' class='img' />
  </core:optional>
  Specify image file path to upload new image.
</div>
<div class="hr"></div>

Тег <js_checkbox> используется для формирования специального checkbox-а, который всегда присылает свое значение, даже флаг не установлен. Это по сути избавит нас от одной лишней проверки в контроллере.

Если мы будем редактировать уже существующий продукт с изображением - оно будет выводиться перед полем для замены этого изображения новым. При создании нового продукта будет выводиться только поле ввода файла изображения.

Контроллер AdminProductController

Создадим контроллер AdminProductController. Все контроллеры панели управления будут иметь префикс Admin - это нам поможет в дальнейшем, когда мы будем ограничивать доступ к панели управления незарегистрированным пользователям.

Для ускорения первых этапов нашей работы мы приведем сразу код для действий create и edit:

Файл shop/src/controller/AdminProductController.class.php:

<?php
lmb_require('src/model/Product.class.php');
 
class AdminProductController extends lmbController
{
  function doCreate()
  {
    $this->_performCreateOrEdit();
  }
 
  function doEdit()
  {
    $this->_performCreateOrEdit();
  }
 
  protected function _performCreateOrEdit()
  {
    if(!$id = $this->request->getInteger('id'))
      $id = null;
 
    $item = new Product($id);
    $this->useForm('product_form');
    $this->setFormDatasource($item);
 
    if($this->request->hasPost())
    {
      $item->import($this->request);
      $this->_uploadImage($item);
 
      if($item->trySave($this->error_list))
        $this->redirect(array('controller' => 'admin_product'));
    }
  }
 
  function _uploadImage($item)
  {
    if(!$uploaded_image = $this->request->get('image'))
      return;
 
    if(!$uploaded_image['name'] || !$uploaded_image['tmp_name'])
      return;
 
    $file_name = $uploaded_image['name'];
    $file_path = $uploaded_image['tmp_name'];
 
    lmb_require('limb/util/system/lmbFs.class.php');
 
    $dest_path = PRODUCT_IMAGES_DIR . $file_name;
    lmbFs :: cp($file_path, $dest_path);
 
    unlink($file_path);
 
    $item->setImageName($file_name);
  }
 
  function doDelete()
  {
    $item = new Product($this->request->getInteger('id')); 
    $item->destroy();
    $this->redirect();
  }
}  
?>

Процедуры создания и редактирования товара здесь ничем не отличаются, за исключением того, что при редактировании необходимо инициализировать форму данными из прежде сохраненного товара. Поэтому мы выделили защищенный метод _performCreateOrEdit().

Напомним наиболее важные моменты:

  • Объекты $request, $response, $error_list, $view доступны в контроллере как атрибуты класса lmbController.
  • Если конструктор класса lmbActiveRecord получает в качестве аргумента численное значение - он пытается загрузить сразу же этот объект.
  • Метод lmbController :: useForm($form_id) необходим чтобы передать список ошибок валидации ($error_list) в активный компонент формы в WACT-шаблоне. В шаблонах admin_product/create.html и admin_product/edit.html мы использовали тег <form runat='server' name='product_form'>, который будет преобразован WACT-ом в активный компонент времени исполнения.
  • Метод lmbController :: setFormDatasource($datasource) передает в выбранную форму контейнер с данными. Это позволяет не терять данные в полях при ошибках валидации.
  • Метод lmbActiveRecord :: trySave($error_list) - проверяет данные и сохраняет объект, если все данные введены верно. Возращает true, если все прошло успешно. Если некоторые поля содержали ошибки, то ошибки будут помешены в объект $error_list.

Загрузка изображения производится в защищенном методе _uploadImage(). Переменная $_FILES обрабатывается в классе lmbHttpRequest, поэтому мы можем получить данные по изображению через объект $request. Загруженное изображение сохраняется в папке, определенной константой PRODUCT_IMAGES_DIR, о которой мы говорили в шаге 2.

Первый результат

Попробуйте теперь зайти на страницу /admin_product и «поиграть» с ней, например, создать с десяток товаров, отредактировать и удалить некоторых из них.

Если у вас нет желания добавлять товары самостоятельно, то можете скопировать содержимое shop_example_dir/www/product_images к себе в проект и залить db.mysql из shop_example_dir/init/ к себе в базу данных проекта. Мы позволили себе немного позаимствовать описания книг с Amazon.com ;-)

Страница списка товаров в панели управления должны выглядеть следующим образом:

}

Смена статуса доступности товара

Иногда владельцу магазина необходимо временно снять с продажи сразу несколько товаров. Снимать галочки в форме редактирования для каждого товара - занятие довольно утомительное, поэтому мы реализуем возможность смены статуса доступности товаров сразу для нескольких товаров прямо из списка товаров.

Для этого нам необходимо будет немного модифицировать шаблон shop/template/admin_product/display.html:

[...]
<form name='list_form' method='post'>
<list:list id="products">
<table cellpadding="0" cellspacing="0" class='list' width='100%'>
  <thead>
  <tr>
    <th>Select</th>
    <th>Title/Image/Price/Description</th>
    <th>Actions</th>
  </tr>
  </thead>
  <list:item>
  <tr>
    <td>
    <input type='checkbox' name='ids[]' value='{$id}' class='input'/>
    <core:optional for='is_available'>
      <img src='/images/available.gif' class=''/>
    </core:optional>
    <core:default for='is_available'>
      <img src='/images/not_available.gif'/>
    </core:default>
   </td>
   <td>
      <dl>
        <dt>
          <b>{$title}</b><br />
          Price:<b>{$price}</b><br/>
         </dt>
         <dd>
            <img src='{$image_path}' class='img'/>
            {$description|nl2br|raw}
         </dd>
      </dl>
   </td>
   <td>
     <route_url params="action:edit,id:{$id}">Edit</route_url>&nbsp;
     <route_url params="action:delete,id:{$id}">Delete</route_url>
   </td>
  </tr>
  </list:item>
</table>
</list:list>
<input type='hidden' name='action' value='change_availability'/>
<input type='submit' name='set_available' value='Set available'/>
<input type='submit' name='set_not_available' value='Set not available'/>
</form>
[...]

Мы добавили форму вокруг нашего списка, поле с checkbox-ом для каждого товара, скрытое поле hidden для указания действия, которое будет отвечать за смену статуса доступности, а также 2 кнопки для отсылки формы.

Мы могли бы не вводить поле action и обрабатывать смену статуса действием display, где бы мы добавили анализ, какая кнопка была нажата. Или же мы могли бы реализовать кнопки для отправки формы так, чтобы каждая из них имела свое уникальное действие. Выбор того или иного способа зависит от ситуации. Мы пока выбрали самое простое решение.

Итак, теперь нужно добавить новое действие в контроллер AdminProductController:

class AdminProductController extends lmbController
{
  [...]
  function doChangeAvailability()
  {
    if(!$ids = $this->request->getArray('ids'))
    {
      $this->redirect();
      return;
    }
 
    $available = false;
    if($this->request->get('set_available'))
      $available = true;
 
    $products = lmbActiveRecord :: findByIds('Product', $ids);
    foreach($products as $product)
    {
      $product->setIsAvailable($available);
      $product->save();
    }
 
    $this->redirect();
  }
}

Здесь мы воспользовались find()-методом класса lmbActiveRecord :: findByIds($class_name, $ids, $params = array()), который позволяет загрузить сразу несколько объектов по их идентификаторам.

Справедливости ради отметим, что в данном случае, возможно, можно было обойтись простым UPDATE запросом к базе данных, чем сначала загружать объекты в память и изменять их по одному. Да, в простых случаях так действовать можно для оптимизации быстродействия, однако если с изменением статусов связаны какие-либо бизнес-правила, мы рекомендуем все же использовать API класса lmbActiveRecord.

Покажем как бы выглядел код данного метода, если бы мы гнались за скоростью выполнения:

[...]
  function doChangeAvailability()
  {
    if(!$ids = $this->request->getArray('ids'))
    {
      $this->redirect();
      return;
    }
 
    $available = 0;
    if($this->request->get('set_available'))
      $available = 1;
 
    $sql = 'UPDATE product SET is_available = ' . $available. 
           ' WHERE id IN (' . implode(',', array_map('intval', $ids)) . ')';
    $this->toolkit->getDefaultDbConnection()->execute($sql);
 
    $this->redirect();
  }
[...]

Для выполнения запроса к базе данных напрямую нам потребовался явный доступ к объекту $connection, который можно получить из toolkit-а при помощи метода getDefaultDbConnection().

Для безопасности мы также пропустили каждый элемент массива $ids через метод intval.

О toolkit будет рассказано чуть позже. Для особо нетерпеливых можно порекомендовать ознакомиться с описанием пакета TOOLKIT (хотя мы не рекомендуем пока этого делать).

Далее

Следующий шаг - "Шаг4. Защита панели управления от несанкционированного доступа":

  1. Мы введем возможность создавать новых пользователей - администраторов.
  2. Далее введем процедуру аутентификации на сайте.
  3. После этого создадим новый фильтр, который будет проверять наличие достаточных прав у пользователя и поместим его в цепочку фильтров приложения.

Вы узнаете:

  • как организована работа приложения на базе Limb,
  • как работать с сессией,
  • что такое toolkit,
  • как передавать данных из контроллера в шаблон.

Обсуждение

Ваш комментарий. Вики-синтаксис разрешён:
   __   _      __   ____  ____  ____
  / /  | | /| / /  / __/ /_  / /_  /
 / /__ | |/ |/ /  / _/    / /_  / /_
/____/ |__/|__/  /_/     /___/ /___/
 
limb3_2007_3/ru/tutorials/shop/step3.txt · Последние изменения: 2010/11/10 10:02 (внешнее изменение)