7

Dzień 7 z 18

Strona kategorii

Opublikowany

Budujemy stronę kategorii zgodnie z historią F2: dodamy slug generowany automatycznie, czytelne adresy /category/{slug}, wydzielimy współdzielony fragment tabeli i wdrożymy paginację (Pagerfanta) po 20 ofert na stronę.

Czego się nauczysz

  • Slug kategorii przez cocur/slugify (lifecycle callback)
  • Migracja pola slug i przeładowanie danych
  • Kontroler kategorii i wczytywanie encji po slug-u
  • Współdzielony fragment tabeli (include with/only)
  • Linki do kategorii na stronie głównej
  • Paginacja z Pagerfanta i QueryAdapter

Co zmieniło się w Symfony 8 vs 4.2

  • cocur/slugify w lifecycle callback zamiast StofDoctrineExtensionsBundle
  • Pagerfanta (jak w blogu) zamiast KnpPaginatorBundle
  • Wczytywanie encji po slug-u przez EntityValueResolver

Dzień 7: Strona kategorii

Zbudujemy stronę kategorii zgodnie z historią F2 z Dnia 2: „użytkownik widzi listę wszystkich ofert z kategorii, posortowaną według daty i podzieloną na strony po 20 ofert na stronę”. Po drodze dodamy slug do kategorii (czytelne URL-e), wydzielimy współdzielony fragment szablonu i wdrożymy paginację.

Slug generujemy biblioteką cocur/slugify (jak w module bloga), a paginację realizujemy przez Pagerfanta z QueryAdapter — zgodnie ze standardem projektu.


Slug kategorii

Chcemy adresów typu /category/programming zamiast /category/2. Potrzebujemy pola slug w encji Category, generowanego automatycznie z nazwy. Dodaj pole i lifecycle callback w src/Entity/Category.php:

use Cocur\Slugify\Slugify;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: CategoryRepository::class)]
#[ORM\Table(name: 'categories')]
#[ORM\HasLifecycleCallbacks]
class Category
{
    // ...

    #[ORM\Column(length: 128, unique: true)]
    private ?string $slug = null;

    public function getSlug(): ?string
    {
        return $this->slug;
    }

    #[ORM\PrePersist]
    public function computeSlug(): void
    {
        if (null !== $this->name) {
            $this->slug = (new Slugify())->slugify($this->name);
        }
    }
}

Dzięki callbackowi #[ORM\PrePersist] slug tworzy się automatycznie przy zapisie — nie musimy nigdzie wołać setSlug(). Fixtures z Dnia 3 nie wymagają zmian: przy flush() slug policzy się sam.

Zmiana vs Symfony 4.2: oryginał instalował StofDoctrineExtensionsBundle i używał adnotacji @Gedmo\Slug. My korzystamy z lekkiej biblioteki cocur/slugify (już obecnej w projekcie) w lifecycle callbacku — bez dodatkowego bundla i konfiguracji. W panelu admina slug ustawiamy analogicznie przy zapisie (jak w module bloga).


Migracja i przeładowanie danych

Wygeneruj migrację dodającą kolumnę slug:

docker compose exec app php bin/console make:migration

Istniejące kategorie nie mają jeszcze slugów, a kolumna jest NOT NULL i unique. W środowisku deweloperskim najprościej odtworzyć bazę od zera i wczytać dane ponownie (slug policzy się automatycznie):

docker compose exec app php bin/console doctrine:database:drop --force --if-exists
docker compose exec app php bin/console doctrine:database:create
docker compose exec app php bin/console doctrine:migrations:migrate --no-interaction
docker compose exec app php bin/console doctrine:fixtures:load --no-interaction

⚠️ To usuwa wszystkie dane — rób tak tylko w dev. Sprawdź wynik: docker compose exec app php bin/console dbal:run-sql 'SELECT id, name, slug FROM categories'.


Kontroler kategorii

Utwórz src/Controller/CategoryController.php. Trasa zawiera slug, a Symfony samo wczyta kategorię (EntityValueResolver dopasuje {slug} do pola slug):

<?php

namespace App\Controller;

use App\Entity\Category;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class CategoryController extends AbstractController
{
    #[Route('/category/{slug}', name: 'category_show', methods: ['GET'])]
    public function show(Category $category): Response
    {
        return $this->render('category/show.html.twig', [
            'category' => $category,
        ]);
    }
}

Współdzielony szablon tabeli

Tabela ofert na stronie głównej i na stronie kategorii jest taka sama — nie duplikujmy jej. Wydziel fragment do templates/job/_table.html.twig:

<table class="table table-hover text-center align-middle">
    <thead>
        <tr>
            <th class="text-center">Lokalizacja</th>
            <th class="text-center">Stanowisko</th>
            <th class="text-center">Firma</th>
        </tr>
    </thead>
    <tbody>
        {% for job in jobs %}
            <tr>
                <td>{{ job.location }}</td>
                <td><a href="{{ path('job_show', { id: job.id }) }}">{{ job.position }}</a></td>
                <td>{{ job.company }}</td>
            </tr>
        {% else %}
            <tr><td colspan="3" class="text-muted">Brak ofert.</td></tr>
        {% endfor %}
    </tbody>
</table>

Fragment iteruje po zmiennej jobs. Dołączamy go instrukcją include ze słowami kluczowymi with i only (przekazujemy tylko zmienną jobs).

Szablon templates/category/show.html.twig:

{% extends 'base.html.twig' %}

{% block title %}Oferty w kategorii {{ category.name }}{% endblock %}

{% block body %}
    <h1 class="h4 mb-3">{{ category.name }}</h1>

    {% include 'job/_table.html.twig' with { jobs: category.activeJobs } only %}
{% endblock %}

Zaktualizuj też stronę główną templates/job/list.html.twig, aby korzystała z fragmentu i linkowała do kategorii:

{% extends 'base.html.twig' %}

{% block title %}Jobeet — oferty pracy{% endblock %}

{% block body %}
    {% for category in categories %}
        <h2 class="h4 mt-4">
            <a href="{{ path('category_show', { slug: category.slug }) }}">{{ category.name }}</a>
        </h2>

        {% include 'job/_table.html.twig' with {
            jobs: category.activeJobs|slice(0, max_jobs_on_homepage)
        } only %}
    {% endfor %}
{% endblock %}

Teraz z listy kategorii na stronie głównej można przejść do konkretnej kategorii.

Zmiana vs Symfony 4.2: to samo podejście co w oryginale (include + with/only), tylko nazwy tras są zgodne z naszą konwencją (job_show, category_show), a szablon używa Bootstrap 5.


Paginacja (Pagerfanta)

Historia F2 wymaga podziału na strony po 20 ofert. Użyjemy Pagerfanta — biblioteki paginacji, którą projekt stosuje też w blogu. Paginator przyjmuje zapytanie (obiekt Query), a nie gotowy wynik — dzięki temu pobiera z bazy tylko rekordy bieżącej strony.

Dodaj metodę zwracającą zapytanie do src/Repository/JobRepository.php:

use App\Entity\Category;
use Doctrine\ORM\Query;

public function getActiveJobsByCategoryQuery(Category $category): Query
{
    return $this->createQueryBuilder('j')
        ->andWhere('j.category = :category')
        ->andWhere('j.expiresAt > :now')
        ->setParameter('category', $category)
        ->setParameter('now', new \DateTimeImmutable())
        ->orderBy('j.expiresAt', 'DESC')
        ->getQuery();
}

Zaktualizuj CategoryController — dodaj drugą trasę ze stroną, zbuduj Pagerfanta z QueryAdapter i obsłuż wyjście poza zakres stron (404):

<?php

namespace App\Controller;

use App\Entity\Category;
use App\Repository\JobRepository;
use Pagerfanta\Doctrine\ORM\QueryAdapter;
use Pagerfanta\Exception\OutOfRangeCurrentPageException;
use Pagerfanta\Pagerfanta;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;

final class CategoryController extends AbstractController
{
    private const int PER_PAGE = 20;

    #[Route('/category/{slug}', name: 'category_show', methods: ['GET'])]
    #[Route('/category/{slug}/page/{page}', name: 'category_show_paginated', methods: ['GET'], requirements: ['page' => '\d+'])]
    public function show(Category $category, JobRepository $jobs, int $page = 1): Response
    {
        $pager = new Pagerfanta(new QueryAdapter($jobs->getActiveJobsByCategoryQuery($category)));
        $pager->setMaxPerPage(self::PER_PAGE);

        try {
            $pager->setCurrentPage($page);
        } catch (OutOfRangeCurrentPageException) {
            throw new NotFoundHttpException();
        }

        return $this->render('category/show.html.twig', [
            'category' => $category,
            'pager' => $pager,
        ]);
    }
}

Pagerfanta jest iterowalny — w szablonie przekazujemy go jak zwykłą kolekcję ofert do fragmentu tabeli, a pod spodem renderujemy nawigację (Bootstrap 5). Zmień templates/category/show.html.twig:

{% extends 'base.html.twig' %}

{% block title %}Oferty w kategorii {{ category.name }}{% endblock %}

{% block body %}
    <h1 class="h4 mb-3">{{ category.name }}</h1>

    {% include 'job/_table.html.twig' with { jobs: pager } only %}

    {% if pager.haveToPaginate %}
        <nav class="d-flex justify-content-center mt-4">
            <ul class="pagination">
                <li class="page-item {{ not pager.hasPreviousPage ? 'disabled' }}">
                    <a class="page-link" href="{{ pager.hasPreviousPage
                        ? path('category_show_paginated', { slug: category.slug, page: pager.previousPage }) : '#' }}">
                        « Poprzednia
                    </a>
                </li>

                {% for p in range(1, pager.nbPages) %}
                    <li class="page-item {{ p == pager.currentPage ? 'active' }}">
                        <a class="page-link" href="{{ path('category_show_paginated', { slug: category.slug, page: p }) }}">{{ p }}</a>
                    </li>
                {% endfor %}

                <li class="page-item {{ not pager.hasNextPage ? 'disabled' }}">
                    <a class="page-link" href="{{ pager.hasNextPage
                        ? path('category_show_paginated', { slug: category.slug, page: pager.nextPage }) : '#' }}">
                        Następna »
                    </a>
                </li>
            </ul>
        </nav>
    {% endif %}
{% endblock %}

Otwórz kategorię z wieloma ofertami (np. „Programming”) — zobaczysz 20 ofert na stronę i działającą nawigację:

Lista ofert z paginacją

Zmiana vs Symfony 4.2: oryginał używał KnpPaginatorBundle. My stosujemy Pagerfanta (babdev/pagerfanta-bundle + pagerfanta/doctrine-orm-adapter) — zgodnie ze standardem projektu. Metody hasPreviousPage, nbPages, currentPage itd. pozwalają wyrenderować nawigację w stylu Bootstrap 5, a wyjście poza zakres stron zwraca 404.


Podsumowanie

Strona kategorii jest gotowa zgodnie z historią F2:

  • ✅ pole slug w Category generowane automatycznie przez cocur/slugify (lifecycle callback),
  • ✅ czytelne URL-e /category/{slug} z automatycznym wczytywaniem encji,
  • ✅ współdzielony fragment job/_table.html.twig (include + with/only) — bez duplikacji,
  • ✅ linki z listy kategorii na stronie głównej,
  • ✅ paginacja (Pagerfanta + QueryAdapter), 20 ofert na stronę, nawigacja Bootstrap 5, 404 poza zakresem.

W Dniu 8 zbudujemy formularze — dodawanie, edycję, publikację i usuwanie ofert wraz z walidacją i uploadem logo.

Narzędzia / paczki

Pagerfanta cocur/slugify

Spis treści