andanteproject / page-filter-form-bundle
A Symfony Bundle to simplify the handling of page filters for lists/tables in admin panels.
Installs: 64 165
Dependents: 0
Suggesters: 0
Security: 0
Stars: 9
Watchers: 2
Forks: 3
Open Issues: 0
Type:symfony-bundle
pkg:composer/andanteproject/page-filter-form-bundle
Requires
- php: ^7.4 || ^8.0
- symfony/form: ^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0
Requires (Dev)
- ext-json: *
- friendsofphp/php-cs-fixer: ^3.58
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^1.11
- phpstan/phpstan-phpunit: ^1.4
- phpstan/phpstan-symfony: ^1.4
- phpunit/phpunit: ^9.6
- roave/security-advisories: dev-master
- symfony/framework-bundle: ^4.0 | ^5.0 | ^6.0 | ^7.0 | ^8.0
README
Page Filter Form Bundle
Symfony Bundle - AndanteProject
A Symfony Bundle to simplify the handling of page filters for lists/tables in admin panels. ๐งช
Requirements
Symfony 4.x-7.x and PHP 7.4-8.0.
Features
- Use Symfony Form;
- Keep your URL parameters clean as
?search=value&otherFilterName=anotherValueby default; - Form will work even if you render form elements outside the form tag, around the web page, exactly where you need, avoiding nested form conflicts.
- Super easy to implement and maintain;
- Works like magic โจ.
How to install
After installation, make sure you have the bundle registered in your Symfony bundles list (config/bundles.php):
return [ /// bundles... Andante\PageFilterFormBundle\AndantePageFilterFormBundle::class => ['all' => true], /// bundles... ];
This should have been done automatically if you are using Symfony Flex. Otherwise, register it yourself.
The problem
Let's suppose you have this common admin panel controller with a page listing some Employee entities.
<?php namespace App\Controller; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use App\Repository\EmployeeRepository; use Knp\Component\Pager\PaginatorInterface; class EmployeeController extends AbstractController{ public function index(Request $request, EmployeeRepository $employeeRepository, PaginatorInterface $paginator){ /** @var Doctrine\ORM\QueryBuilder $qb */ $qb = $employeeRepository->getFancyQueryBuilderLogic('employee'); $employees = $paginator->paginate($qb, $request); return $this->render('admin/employee/index.html.twig', [ 'employees' => $employees, ]); } }
To add filters to this page, let's create a Symfony form.
<?php namespace App\Form\Admin; use Symfony\Component\Form\Extension\Core\Type; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; class EmployeeFilterType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options): void { $builder->add('search', Type\SearchType::class); $builder->add('senior', Type\CheckboxType::class); $builder->add('orderBy', Type\ChoiceType::class, [ 'choices' => [ 'name' => 'name', 'age' => 'birthday' ], ]); } }
Let's add this Form to our controller page:
<?php namespace App\Controller; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use App\Repository\EmployeeRepository; use Knp\Component\Pager\PaginatorInterface; use App\Form\Admin\EmployeeFilterType; class EmployeeController extends AbstractController{ public function index(Request $request, EmployeeRepository $employeeRepository, PaginatorInterface $paginator){ /** @var Doctrine\ORM\QueryBuilder $qb */ $qb = $employeeRepository->getFancyQueryBuilderLogic('employee'); $form = $this->createForm(EmployeeFilterType::class); $form->handleRequest($request); if($form->isSubmitted() && $form->isValid()){ $qb->expr()->like('employee.name',':name'); $qb->setParameter('name', $form->get('search')->getData()); $qb->expr()->like('employee.senior',':senior'); $qb->setParameter('senior', $form->get('senior')->getData()); $qb->orderBy('employee.'. $form->get('orderBy')->getData(), 'asc'); // Don't you see the problem here? } $employees = $paginator->paginate($qb, $request); return $this->render('admin/employee/index.html.twig', [ 'employees' => $employees, 'form' => $form->createView() ]); } }
The code above has some huge problems:
- ๐ Handling all this filter logic inside the controller is not a good idea. Sure, you can move it into a dedicated
service, but that means creating another class file alongside
EmployeeFilterTypeto handle filters, and that still does not solve the second point in this list; - ๐ You need to carry around and match form element names.
search,seniorandorderByare keys you could store in constants to avoid repeating yourself, but this will drive you crazy as the filter logic grows.
The solution with Page Filter Form
Use Andante\PageFilterFormBundle\Form\PageFilterType as parent of your filter
form (why?) and implement target_callback option on your form elements like
this:
<?php namespace App\Form\Admin; use Symfony\Component\Form\Extension\Core\Type; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; use Andante\PageFilterFormBundle\Form\PageFilterType; use Doctrine\ORM\QueryBuilder; class EmployeeFilterType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options): void { $builder->add('search', Type\SearchType::class, [ 'target_callback' => function(QueryBuilder $qb, ?string $searchValue):void { $qb->expr()->like('employee.name',':name'); // Don't want to guess for entity alias "employee"? $qb->setParameter('name', $searchValue); // Check andanteproject/shared-query-builder } ]); $builder->add('senior', Type\CheckboxType::class, [ 'target_callback' => function(QueryBuilder $qb, bool $seniorValue):void { $qb->expr()->like('employee.senior',':senior'); $qb->setParameter('senior', $seniorValue); } ]); $builder->add('orderBy', Type\ChoiceType::class, [ 'choices' => [ 'name' => 'name', 'age' => 'birthday' ], 'target_callback' => function(QueryBuilder $qb, string $orderByValue):void { $qb->orderBy('employee.'. $orderByValue, 'asc'); } ]); } public function getParent() : string { return PageFilterType::class; } }
Implement Andante\PageFilterFormBundle\PageFilterFormTrait in your controller (or inject
Andante\PageFilterFormBundle\PageFilterManagerInterface as an argument) and use the form like this:
<?php namespace App\Controller; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use App\Repository\EmployeeRepository; use Knp\Component\Pager\PaginatorInterface; use App\Form\Admin\EmployeeFilterType; use Andante\PageFilterFormBundle\PageFilterFormTrait; class EmployeeController extends AbstractController{ use PageFilterFormTrait; public function index(Request $request, EmployeeRepository $employeeRepository, PaginatorInterface $paginator){ /** @var Doctrine\ORM\QueryBuilder $qb */ $qb = $employeeRepository->getFancyQueryBuilderLogic('employee'); $form = $this->createAndHandleFilter(EmployeeFilterType::class, $qb, $request); $employees = $paginator->paginate($qb, $request); return $this->render('admin/employee/index.html.twig', [ 'employees' => $employees, 'form' => $form->createView() ]); } }
โ Done!
- ๐ Controller is clean and easy to read;
- ๐ We have just one class taking care of filters;
- ๐ The option
target_callbacklets you avoid repeating yourself and carrying around form element names; - ๐ You can type-hint your callable ๐ฅฐ (check callback arguments);
- ๐ We got you covered solving possible nested form problems (how?);
"target_callback" option
target_callback
type: null or callable default: null
The callable is going to have 3 parameters (third is optional):
| Parameter | What | Mandatory | Description |
|---|---|---|---|
| 1 | Filter $target |
yes |
The second argument of createAndHandleFilter. It can be whatever you want: a query builder, an array, a collection, an object. It does not matter as long as you match its type with this argument signature. |
| 2 | form data | yes |
Equivalent to calling $form->getData() on the current form field. It will be a ?string for a TextType or a ?\DateTime for a DateTimeType. |
| 3 | form itself | no |
The current $form instance. |
Why use PageFilterType as form parent
You could avoid using Andante\PageFilterFormBundle\Form\PageFilterType as the parent of your form, but be aware it sets
some useful defaults you may want to replicate:
| Option | Value | Description |
|---|---|---|
method |
GET |
You probably want filters to be part of the page URL, right? |
csrf_protection |
false |
So users can share the page URL with others without running into problems. |
allow_extra_fields |
true |
Allows other URL parameters outside your form values. |
andante_smart_form_attr |
true |
Enables form elements to be rendered wherever you want on your page, even outside the form tag, while keeping them working properly (discover more). |
Render the form in twig
As long as andante_smart_form_attr is true, you can render your form like this:
<div class="header-filters"> {{ form_start(form) }} {# id="list_filter" #} {{ form_errors(form) }} {{ form_row(form.search) }} {{ form_row(form.orderBy) }} {{ form_end(form, {'render_rest': false}) }} </div> <!-- --> <!-- Some other HTML content, like a table or even another Symfony form --> <!-- --> <div class="footer-filters"> {{ form_row(form.orderBy) }} {# has attribute form="list_filter" #} </div>
โ
The form.perPage element works properly even outside the form tag (how?!).
Give us a โญ!
Built with love โค๏ธ by AndanteProject team.
