feat: admin list filtering and search

This commit is contained in:
Björn Fromme
2026-08-06 10:57:36 +02:00
parent aa8fa5c1b2
commit dd658bf9d6
41 changed files with 1977 additions and 44 deletions
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Controller\Traits\ListFilterTrait;
use App\Entity\User;
use App\Form\Admin\Filter\AccommodationBookingFilterOptionsProvider;
use App\Form\Admin\Filter\AccommodationBookingFilterType;
use App\Form\Model\Filter\AccommodationBookingFilterDto;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Renders the filter modal, pre-filled with whatever the list is currently showing.
*
* There is no submit action to go with it: the form is a GET form pointing at the list itself,
* so applying a filter is a plain navigation.
*/
#[IsGranted('ROLE_GROUPS_MANAGER')]
class FilterController extends AbstractController
{
use ListFilterTrait;
public function __construct(
private readonly AccommodationBookingFilterOptionsProvider $filterOptions,
) {
}
#[Route('/admin/accommodation-booking/filter', name: 'app_admin_accommodationbooking_filter', methods: ['GET'])]
public function filter(Request $request): Response
{
// ROLE_ADMIN inherits ROLE_GROUPS_ADMIN, so this single check covers both.
$seesAllBookings = $this->isGranted('ROLE_GROUPS_ADMIN');
$user = $this->getUser();
$filterView = $this->createListFilterView(
$request,
AccommodationBookingFilterType::class,
AccommodationBookingFilterDto::defaults($seesAllBookings, $user instanceof User ? $user : null),
'app_admin_accommodationbooking',
$this->filterOptions->formOptions($seesAllBookings),
);
return $this->render('admin/accommodation_booking/modal_filter.html.twig', [
'form' => $filterView,
'route' => 'app_admin_accommodationbooking',
]);
}
}
@@ -4,6 +4,11 @@ declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Controller\Traits\ListFilterTrait;
use App\Entity\User;
use App\Form\Admin\Filter\AccommodationBookingFilterOptionsProvider;
use App\Form\Admin\Filter\AccommodationBookingFilterType;
use App\Form\Model\Filter\AccommodationBookingFilterDto;
use App\Repository\Groups\AccommodationBookingRepository;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -15,8 +20,11 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_MANAGER')]
class IndexController extends AbstractController
{
use ListFilterTrait;
public function __construct(
private readonly AccommodationBookingRepository $bookingRepository,
private readonly AccommodationBookingFilterOptionsProvider $filterOptions,
private readonly PaginatorInterface $paginator,
) {
}
@@ -24,21 +32,33 @@ class IndexController extends AbstractController
#[Route('/admin/accommodation-booking', name: 'app_admin_accommodationbooking')]
public function index(Request $request): Response
{
$today = new \DateTimeImmutable('today');
// ROLE_ADMIN inherits ROLE_GROUPS_ADMIN, so this single check covers both.
$seesAllBookings = $this->isGranted('ROLE_GROUPS_ADMIN');
$user = $this->getUser();
$user = $user instanceof User ? $user : null;
$qb = $this
->bookingRepository
->createQueryBuilder('booking')
->select('booking', 'managed_by')
->leftJoin('booking.accommodation', 'accommodation')
->leftJoin('booking.managedBy', 'managed_by')
->addSelect('accommodation')
->where('booking.dateTo >= :today')
->setParameter('today', $today)
;
// The form writes into $filter, so after this call it holds either the defaults or
// whatever the query string asked for.
$filter = AccommodationBookingFilterDto::defaults($seesAllBookings, $user);
$filterView = $this->createListFilterView(
$request,
AccommodationBookingFilterType::class,
$filter,
'app_admin_accommodationbooking',
$this->filterOptions->formOptions($seesAllBookings),
);
// The manager fields are not part of the form for this role, but the scope is pinned
// here as well so that a hand-written query parameter cannot widen it either.
if (!$seesAllBookings) {
$filter->managedBy = $user;
$filter->unassigned = false;
$filter->managedByLocked = true;
}
$pagination = $this->paginator->paginate(
$qb,
$this->bookingRepository->createFilteredQueryBuilder($filter),
$request->query->getInt('page', 1),
$request->query->getInt('limit', 20),
[
@@ -49,6 +69,8 @@ class IndexController extends AbstractController
return $this->render('admin/accommodation_booking/index.html.twig', [
'pagination' => $pagination,
'filter' => $filter,
'filter_form' => $filterView,
]);
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\BookingEditDraft;
use App\Controller\Traits\ListFilterTrait;
use App\Form\Admin\Filter\BookingEditDraftFilterType;
use App\Form\Model\Filter\BookingEditDraftFilterDto;
use App\Repository\BookingEditDraftRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class FilterController extends AbstractController
{
use ListFilterTrait;
public function __construct(
private readonly BookingEditDraftRepository $bookingEditDraftRepository,
) {
}
#[Route('/admin/booking-edit-draft/filter', name: 'app_admin_bookingeditdraft_filter', methods: ['GET'])]
public function filter(Request $request): Response
{
$filterView = $this->createListFilterView(
$request,
BookingEditDraftFilterType::class,
new BookingEditDraftFilterDto(),
'app_admin_bookingeditdraft',
['users' => $this->bookingEditDraftRepository->findDraftOwners()],
);
return $this->render('admin/_modal_filter.html.twig', [
'form' => $filterView,
'route' => 'app_admin_bookingeditdraft',
]);
}
}
@@ -4,7 +4,10 @@ declare(strict_types=1);
namespace App\Controller\Admin\BookingEditDraft;
use App\Controller\Traits\ListFilterTrait;
use App\Controller\Traits\ReturnUrlTrait;
use App\Form\Admin\Filter\BookingEditDraftFilterType;
use App\Form\Model\Filter\BookingEditDraftFilterDto;
use App\Repository\BookingEditDraftRepository;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -16,6 +19,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class IndexController extends AbstractController
{
use ListFilterTrait;
use ReturnUrlTrait;
public function __construct(
@@ -27,14 +31,18 @@ class IndexController extends AbstractController
#[Route('/admin/booking-edit-draft', name: 'app_admin_bookingeditdraft')]
public function index(Request $request): Response
{
$qb = $this
->bookingEditDraftRepository
->createQueryBuilder('booking_edit_draft')
->leftJoin('booking_edit_draft.user', 'user')
;
$filter = new BookingEditDraftFilterDto();
$filterView = $this->createListFilterView(
$request,
BookingEditDraftFilterType::class,
$filter,
'app_admin_bookingeditdraft',
['users' => $this->bookingEditDraftRepository->findDraftOwners()],
);
$pagination = $this->paginator->paginate(
$qb,
$this->bookingEditDraftRepository->createFilteredQueryBuilder($filter),
$request->query->getInt('page', 1),
$request->query->getInt('limit', 20),
[
@@ -45,6 +53,8 @@ class IndexController extends AbstractController
return $this->render('admin/booking_edit_draft/index.html.twig', [
'pagination' => $pagination,
'filter' => $filter,
'filter_form' => $filterView,
]);
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\Log;
use App\Controller\Traits\ListFilterTrait;
use App\Form\Admin\Filter\LogEntryFilterType;
use App\Form\Model\Filter\LogEntryFilterDto;
use App\Repository\LogEntryRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')]
class FilterController extends AbstractController
{
use ListFilterTrait;
public function __construct(
private readonly LogEntryRepository $logEntryRepository,
) {
}
#[Route('/admin/log/filter', name: 'app_admin_log_filter', methods: ['GET'])]
public function filter(Request $request): Response
{
$filterView = $this->createListFilterView(
$request,
LogEntryFilterType::class,
new LogEntryFilterDto(),
'app_admin_log',
['channels' => $this->logEntryRepository->findChannels()],
);
return $this->render('admin/_modal_filter.html.twig', [
'form' => $filterView,
'route' => 'app_admin_log',
]);
}
}
+17 -5
View File
@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\Controller\Admin\Log;
use App\Controller\Traits\ListFilterTrait;
use App\Form\Admin\Filter\LogEntryFilterType;
use App\Form\Model\Filter\LogEntryFilterDto;
use App\Repository\LogEntryRepository;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -15,6 +18,8 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')]
class IndexController extends AbstractController
{
use ListFilterTrait;
public function __construct(
private readonly LogEntryRepository $logEntryRepository,
private readonly PaginatorInterface $paginator,
@@ -24,13 +29,18 @@ class IndexController extends AbstractController
#[Route('/admin/log', name: 'app_admin_log')]
public function index(Request $request): Response
{
$qb = $this
->logEntryRepository
->createQueryBuilder('log_entry')
;
$filter = new LogEntryFilterDto();
$filterView = $this->createListFilterView(
$request,
LogEntryFilterType::class,
$filter,
'app_admin_log',
['channels' => $this->logEntryRepository->findChannels()],
);
$pagination = $this->paginator->paginate(
$qb,
$this->logEntryRepository->createFilteredQueryBuilder($filter),
$request->query->getInt('page', 1),
$request->query->getInt('limit', 50),
[
@@ -41,6 +51,8 @@ class IndexController extends AbstractController
return $this->render('admin/log/index.html.twig', [
'pagination' => $pagination,
'filter' => $filter,
'filter_form' => $filterView,
]);
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\User;
use App\Controller\Traits\ListFilterTrait;
use App\Form\Admin\Filter\UserFilterType;
use App\Form\Model\Filter\UserFilterDto;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')]
class FilterController extends AbstractController
{
use ListFilterTrait;
#[Route('/admin/user/filter', name: 'app_admin_user_filter', methods: ['GET'])]
public function filter(Request $request): Response
{
$filterView = $this->createListFilterView(
$request,
UserFilterType::class,
new UserFilterDto(),
'app_admin_user',
['roles' => UserFilterType::ROLES],
);
return $this->render('admin/_modal_filter.html.twig', [
'form' => $filterView,
'route' => 'app_admin_user',
]);
}
}
+17 -5
View File
@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\Controller\Admin\User;
use App\Controller\Traits\ListFilterTrait;
use App\Form\Admin\Filter\UserFilterType;
use App\Form\Model\Filter\UserFilterDto;
use App\Repository\UserRepository;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -15,6 +18,8 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')]
class IndexController extends AbstractController
{
use ListFilterTrait;
public function __construct(
private readonly UserRepository $userRepository,
private readonly PaginatorInterface $paginator,
@@ -24,13 +29,18 @@ class IndexController extends AbstractController
#[Route('/admin/user', name: 'app_admin_user')]
public function index(Request $request): Response
{
$qb = $this
->userRepository
->createQueryBuilder('user')
;
$filter = new UserFilterDto();
$filterView = $this->createListFilterView(
$request,
UserFilterType::class,
$filter,
'app_admin_user',
['roles' => UserFilterType::ROLES],
);
$pagination = $this->paginator->paginate(
$qb,
$this->userRepository->createFilteredQueryBuilder($filter),
$request->query->getInt('page', 1),
$request->query->getInt('limit', 50),
[
@@ -41,6 +51,8 @@ class IndexController extends AbstractController
return $this->render('admin/user/index.html.twig', [
'pagination' => $pagination,
'filter' => $filter,
'filter_form' => $filterView,
]);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Controller\Traits;
use App\Form\Model\Filter\AbstractListFilterDto;
use Symfony\Component\Form\FormView;
use Symfony\Component\HttpFoundation\Request;
/**
* Shared wiring between a list view and its filter modal — both need the same form, bound to
* the same query string.
*/
trait ListFilterTrait
{
/**
* Builds the filter form and binds it to the query string, but only when the request is
* marked as explicitly filtered. On a bare URL the passed-in defaults survive untouched,
* which is what makes "reset" simply mean "link to the route without a query string".
*
* The form writes straight into $defaults, so callers read the resolved filter from the
* object they passed in — keeping its concrete type, same as the entity edit forms — and
* get back only what they still need for rendering.
*
* @param class-string $formType
* @param array<string, mixed> $options
*/
protected function createListFilterView(
Request $request,
string $formType,
AbstractListFilterDto $defaults,
string $listRoute,
array $options = [],
): FormView {
$listUrl = $this->generateUrl($listRoute);
// The modal lives inside <body>, so swapping the body's content both refreshes the list
// and closes the modal in a single request — no OOB swap and no redirect round trip.
$form = $this->createForm($formType, $defaults, $options + [
'action' => $listUrl,
'hx_get' => $listUrl,
'hx_target' => 'body',
'hx_swap' => 'innerHTML',
'hx_push_url' => true,
]);
if ($request->query->has(AbstractListFilterDto::MARKER)) {
$form->handleRequest($request);
}
return $form->createView();
}
}