Symfony 2 Joboard : Изменяем страницу с категориями

Маршрут категорий

Для начала добавим маршрут для URL категорий. Добавьте его в начале файла src/App/JoboardBundle/Resources/config/routing.yml:

Заметка: slug- это уникальный идентификатор для записи, используется вместо id, необходим для читабельности в url, а так же в целях безопасности, чтобы не раскрывать айдишники (id) из базы данных.

# ...
AppJoboardBundle_category:
    pattern:   /category/{slug}/
    defaults: { _controller: AppJoboardBundle:Category:show }

Чтобы получить slug категории нам надо добавить метод getSlug() в класс модели Category (src/App/JoboardBundle/Entity/Category.php):

<?php
# src/App/JoboardBundle/Entity/Category.php
use App\JoboardBundle\Utils\Joboard as Joboard;
class Category
{
    // ...
    public function getSlug()
    {
        return Joboard::slugify($this->getName());
    }
}

Ссылка на категорию

Теперь изменим файл src/App/JoboardBundle/Resources/views/Job/index.html.twig, замените содержимое блока content следующим кодом:

<div id="jobs">
        {% for category in categories %}
            <div>
                <div class="category">
                    <div class="feed">
                        <a href="">Feed</a>
                    </div>
                    <h1><a href="{{ path('AppJoboardBundle_category', {'slug': category.slug}) }}">{{ category.name }}</a></h1>
                </div>
                <table class="jobs">
                    {% for entity in category.activejobs %}
                        <tr class="{{ cycle(['even', 'odd'], loop.index) }}">
                            <td class="location">{{ entity.location }}</td>
                            <td class="position">
                                <a href="{{ path('app_job_show', { 'id': entity.id, 'company': entity.companyslug, 'location': entity.locationslug, 'position': entity.positionslug }) }}">
                                    {{ entity.position }}
                                </a>
                            </td>
                            <td class="company">{{ entity.company }}</td>
                        </tr>
                    {% endfor %}
                </table>
                {% if category.morejobs %}
                    <div class="more-jobs text-right">
                        и <a href="{{ path('AppJoboardBundle_category', {'slug': category.slug}) }}">{{ category.morejobs }}</a>
                        ещё вакансии...
                    </div>
                {% endif %}
            </div>
        {% endfor %}
</div>

В этом шаблоне мы использовали category.morejobs (это метод модели Category), поэтому давайте опишем необходимые методы:

<?php
# src/App/JoboardBundle/Entity/Category.php
class Category
{
    // ...
    private $moreJobs;
    // ...
    public function setMoreJobs($jobs)
    {
        $this->moreJobs = $jobs >=  0 ? $jobs : 0;
    }
    public function getMoreJobs()
    {
        return $this->moreJobs;
    }
}

Свойство moreJobs хранит количество активных вакансий минус число вакансий на домашней странице. Теперь в JobController следует установить значение moreJobs для каждой категории:

<?php
# src/App/JoboardBundle/Controller/JobController.php
// ...
    public function indexAction()
    {
        $em = $this->getDoctrine()->getManager();
        $categories = $em->getRepository('AppJoboardBundle:Category')->getWithJobs();
        foreach($categories as $category) {
            $category->setActiveJobs($em->getRepository('AppJoboardBundle:Job')->getActiveJobs(
                $category->getId(),
                $this->container->getParameter('max_jobs_on_homepage'))
            );
            $activeJobsCount = $em->getRepository('AppJoboardBundle:Job')->countActiveJobs($category->getId());
            if ($activeJobsCount >= $this->container->getParameter('max_jobs_on_homepage')) {
                $activeJobsCount -= $this->container->getParameter('max_jobs_on_homepage');
                $category->setMoreJobs($activeJobsCount);
            }
        }
        return $this->render('AppJoboardBundle:Job:index.html.twig', array(
            'categories' => $categories
        ));
    }
// ...

Функцию countActiveJobs следует добавить в JobRepository:

<?php
# src/App/JoboardBundle/Repository/JobRepository.php
// ...
    public function countActiveJobs($categoryId = null)
    {
        $qb = $this->createQueryBuilder('j')
            ->select('count(j.id)')
            ->where('j.expires_at > :date')
            ->setParameter('date', date('Y-m-d H:i:s', time()));
        if($categoryId)
        {
            $qb->andWhere('j.category = :category_id')
                ->setParameter('category_id', $categoryId);
        }
        $query = $qb->getQuery();
        return $query->getSingleScalarResult();
    }
// ...

Теперь в браузере можно просмотреть результат:

Ещё вакансии

Создание контроллера категорий

Настало время создать контроллер для категорий. Создайте файл CategoryController.php в каталоге Controller:

<?php
# src/App/JoboardBundle/Controller/CategoryController.php
namespace App\JoboardBundle\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use App\JoboardBundle\Entity\Category;
/**
 * Category controller
 *
 */
class CategoryController extends Controller
{
}

Мы могли бы воспользоваться командой doctrine:generate:crud, как в случае с контроллером для вакансий, но 90% из созданного кода, нам не надо, поэтому мы можем просто создать новый контроллер с нуля.

Обновление базы данных

Нам необходимо добавить поле slug для таблицы категорий и lifecycle функции для его обновления:

# src\App\JoboardBundle\Resources\config\doctrine\Category.orm.yml
App\JoboardBundle\Entity\Category:
    type: entity
    repositoryClass: App\JoboardBundle\Repository\CategoryRepository
    table: category
    id:
        id:
            type: integer
            generator: { strategy: AUTO }
    fields:
        name:
            type: string
            length: 255
            unique: true
        slug:
            type: string
            length: 255
            unique: true
    oneToMany:
        jobs:
            targetEntity: Job
            mappedBy: category
    manyToMany:
        affiliates:
            targetEntity: Affiliate
            mappedBy: categories
    lifecycleCallbacks:
        prePersist: [ setSlugValue ]
        preUpdate: [ setSlugValue ]

Удалите метод getSlug() из сущности Category и выполните команду Doctrine для обновления классов сущностей:

php app/console doctrine:generate:entities AppJoboardBundle

Теперь вы должны увидеть следующие изменения в файле Category.php:

<?php
// ...    
    /**
     * @var string
     */
    private $slug;
    /**
     * Set slug
     *
     * @param string $slug
     * @return Category
     */
    public function setSlug($slug)
    {
        $this->slug = $slug;
        return $this;
    }
    /**
     * Get slug
     *
     * @return string
     */
    public function getSlug()
    {
        return $this->slug;
    }

Измените функцию setSlugValue():

<?php
// ...
class Category
{
    // ...
    public function setSlugValue()
    {
        $this->slug = Joboard::slugify($this->getName());
    }
}

Теперь надо удалить БД и создать её заново. После создания базы необходимо загрузить фикстуры, для этого выполните команды в консоли:

php app/console doctrine:database:drop --force
php app/console doctrine:database:create
php app/console doctrine:schema:update --force
php app/console doctrine:fixtures:load

Страница категории

Мы подготовили всё необходимое для создания метода showAction(). Добавьте следующий код в файл CategoryController.php:

<?php
# src/App/JoboardBundle/Controller/CategoryController.php
// ...
public function showAction($slug)
{
    $em = $this->getDoctrine()->getManager();
    $category = $em->getRepository('AppJoboardBundle:Category')->findOneBySlug($slug);
        if (!$category) {
            throw $this->createNotFoundException('Такая категория не найдена.');
        }
        $category->setActiveJobs($em->getRepository('AppJoboardBundle:Job')->getActiveJobs($category->getId()));
        return $this->render('AppJoboardBundle:Category:show.html.twig', array(
            'category' => $category,
        ));
}
// ...

Последний шаг — создание шаблона show.html.twig (src/App/JoboardBundle/Resources/views/Category/show.html.twig):

{% extends '::base.html.twig' %}
{% block title %}
    Вакансий в категории {{ category.name }}
{% endblock %}
{% block stylesheets %}
    {{ parent() }}
    <link rel="stylesheet" href="{{ asset('bundles/appjoboard/css/jobs.css') }}" type="text/css" media="all" />
{% endblock %}
{% block content %}
    <div class="one-category">
        <div class="feed">
            <a href="">Feed</a>
        </div>
        <h1>{{ category.name }}</h1>
    </div>
    <table class="jobs">
        {% for entity in category.activejobs %}
            <tr class="{{ cycle(['even', 'odd'], loop.index) }}">
                <td class="location">{{ entity.location }}</td>
                <td class="position">
                    <a href="{{ path('app_job_show', { 'id': entity.id, 'company': entity.companyslug, 'location': entity.locationslug, 'position': entity.positionslug }) }}">
                        {{ entity.position }}
                    </a>
                </td>
                <td class="company">{{ entity.company }}</td>
            </tr>
        {% endfor %}
    </table>
{% endblock %}

Подключение других шаблонов Twig

Обратите внимание, мы скопировали содержимое тэга <table class="jobs"> (из шаблона index.html.twig), который отображает список вакансий. Так делать не стоит. Когда вам требуется заново использовать какую-либо часть шаблона, следует создать новый шаблон с этим html кодом и подключить его при необходимости. Создайте файл list.html.twig (src/App/JoboardBundle/Resources/views/Job/list.html.twig):

<table class="jobs">
    {% for entity in jobs %}
        <tr class="{{ cycle(['even', 'odd'], loop.index) }}">
            <td class="location">{{ entity.location }}</td>
            <td class="position">
                <a href="{{ path('app_job_show', { 'id': entity.id, 'company': entity.companyslug, 'location': entity.locationslug, 'position': entity.positionslug }) }}">
                    {{ entity.position }}
                </a>
            </td>
            <td class="company">{{ entity.company }}</td>
        </tr>
    {% endfor %}
</table>

Подключить шаблон вы можете при помощи twig функции include. Замените HTML код таблицы в обоих шаблонах на вышеуказанную функцию:

# src/App/JoboardBundle/Resources/views/Job/index.html.twig
{{ include ('AppJoboardBundle:Job:list.html.twig', {'jobs': category.activejobs}) }}
# src/App/JoboardBundle/Resources/views/Category/show.html.twig
{{ include ('AppJoboardBundle:Job:list.html.twig', {'jobs': category.activejobs}) }}

Пагинация списка

На данный момент Symfony2 не предлагает встроенного функционала пагинации, поэтому для решения этой проблемы мы используем классический метод. Сначала, добавим параметр page в маршрут AppJoboardBundle_category. По умолчанию значение этого параметра должно быть 1.

# src/App/JoboardBundle/Resources/config/routing.yml
AppJoboardBundle_category:
    pattern: /category/{slug}/{page}
    defaults: { _controller: AppJoboardBundle:Category:show, page: 1 }
# …

Очистите кэш:

php app/console cache:clear --env=dev
php app/console cache:clear --env=prod

Количество вакансий на странице будет задано параметром в файле app/config/parameters.yml:

# ...
parameters:
    max_jobs_on_homepage: 10
    max_jobs_on_category: 20

Измените метод getActiveJobs из класса JobRepository, чтобы он использовал параметр $offset при получении вакансий из БД:

<?php
# src/App/JoboardBundle/Repository/JobRepository.php
// ...
    public function getActiveJobs($categoryId = null, $max = null, $offset = null)
    {
        $qb = $this->createQueryBuilder('j')
            ->where('j.expires_at > :date')
            ->setParameter('date', date('Y-m-d H:i:s', time()))
            ->orderBy('j.expires_at', 'DESC');
        if($max) {
            $qb->setMaxResults($max);
        }
        if($offset) {
            $qb->setFirstResult($offset);
        }
        if($categoryId) {
            $qb->andWhere('j.category = :category_id')
                ->setParameter('category_id', $categoryId);
        }
        $query = $qb->getQuery();
        return $query->getResult();
    }
// ...

Измените метод showAction в CategoryController на следующее:

<?php
# src/App/JoboardBundle/Controller/CategoryController.php
// ...
    public function showAction($slug, $page)
    {
        $em = $this->getDoctrine()->getManager();
        $category = $em->getRepository('AppJoboardBundle:Category')->findOneBySlug($slug);
        if (!$category) {
            throw $this->createNotFoundException('Такая категория не найдена.');
        }
        $totalJobs    = $em->getRepository('AppJoboardBundle:Job')->countActiveJobs($category->getId());
        $jobsPerPage  = $this->container->getParameter('max_jobs_on_category');
        $lastPage     = ceil($totalJobs / $jobsPerPage);
        $previousPage = $page > 1 ? $page - 1 : 1;
        $nextPage     = $page < $lastPage ? $page + 1 : $lastPage;
        $activeJobs   = $em->getRepository('AppJoboardBundle:Job')
            ->getActiveJobs($category->getId(), $jobsPerPage, ($page - 1) * $jobsPerPage);
        $category->setActiveJobs($activeJobs);
        return $this->render('AppJoboardBundle:Category:show.html.twig', array(
            'category'     => $category,
            'lastPage'     => $lastPage,
            'previousPage' => $previousPage,
            'currentPage'  => $page,
            'nextPage'     => $nextPage,
            'totalJobs'    => $totalJobs
        ));
    }
// ...

Наконец, обновим шаблон src/App/JoboardBundle/Resources/views/Category/show.html.twig

{% extends '::base.html.twig' %}
{% block title %}
    Вакансии в категории {{ category.name }}
{% endblock %}
{% block stylesheets %}
    {{ parent() }}
    <link rel="stylesheet" href="{{ asset('bundles/appjoboard/css/jobs.css') }}" type="text/css" media="all" />
{% endblock %}
{% block content %}
    <div class="category">
        <div class="feed">
            <a href="">Feed</a>
        </div>
        <h1>{{ category.name }}</h1>
    </div>
    {{ include ('AppJoboardBundle:Job:list.html.twig', {'jobs': category.activejobs}) }}
    {% if lastPage > 1 %}
        <div class="pagination">
            <a href="{{ path('AppJoboardBundle_category', {'slug': category.slug, 'page': 1}) }}">
                В начало
            </a>
            <a href="{{ path('AppJoboardBundle_category', {'slug': category.slug, 'page': previousPage }) }}">
                &lt;&lt;
            </a>
            {% for page in 1..lastPage %}
                {% if page == currentPage %}
                    {{ page }}
                {% else %}
                    <a href="{{ path('AppJoboardBundle_category', {'slug': category.slug, 'page': page}) }}">{{ page }}</a>
                {% endif %}
            {% endfor %}
            <a href="{{ path('AppJoboardBundle_category', {'slug': category.slug, 'page': nextPage}) }}">
                &gt;&gt;
            </a>
            <a href="{{ path('AppJoboardBundle_category', {'slug': category.slug, 'page': lastPage}) }}">
                В конец
            </a>
        </div>
    {% endif %}
    <div class="pagination_desc">
        <strong>{{ totalJobs }}</strong> вакансии в категории
        {% if lastPage > 1 %}
            - страница <strong>{{ currentPage }}/{{ lastPage }}</strong>
        {% endif %}
    </div>
{% endblock %}

Вот и всё страница категории с пагинацией готова. Увидимся в следующей части!