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,97 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form\Admin\Filter;
use App\Entity\User;
use App\Form\Admin\Filter\AccommodationBookingFilterOptionsProvider;
use App\Repository\Groups\AccommodationBookingRepository;
use App\Repository\Groups\AccommodationRepository;
use App\Repository\UserRepository;
use PHPUnit\Framework\TestCase;
class AccommodationBookingFilterOptionsProviderTest extends TestCase
{
public function testSomebodyAssignedWithoutTheRoleStaysFilterable(): void
{
$staff = $this->user(1, '[email protected]');
$formerStaff = $this->user(2, '[email protected]');
$options = $this->provider([$staff], [$formerStaff])->formOptions(true);
self::assertSame(
['[email protected]', '[email protected]'],
array_map(static fn (User $u) => $u->getEmail(), $options['managers']),
'a booking assigned to someone who lost the groups role has to remain findable',
);
}
public function testSomebodyBothAssignedAndOnStaffIsOfferedOnce(): void
{
$manager = $this->user(1, '[email protected]');
$options = $this->provider([$manager], [$manager])->formOptions(true);
self::assertCount(1, $options['managers']);
}
public function testManagersAreSortedByEmail(): void
{
$options = $this->provider(
[$this->user(1, '[email protected]'), $this->user(2, '[email protected]')],
[$this->user(3, '[email protected]')],
)->formOptions(true);
self::assertSame(
['[email protected]', '[email protected]', '[email protected]'],
array_map(static fn (User $u) => $u->getEmail(), $options['managers']),
);
}
public function testStaffWhoCannotSeeOtherBookingsGetNoManagerFilterAtAll(): void
{
$options = $this->provider([$this->user(1, '[email protected]')], [])->formOptions(false);
self::assertFalse($options['can_filter_by_manager']);
self::assertSame([], $options['managers']);
}
public function testTheManagerFilterIsAPermissionNotAConsequenceOfEmptyData(): void
{
// Nobody holds a groups role and nothing is assigned yet — a group admin still gets to
// filter, which is what keeps the "no assignment" triage available on day one.
$options = $this->provider([], [])->formOptions(true);
self::assertTrue($options['can_filter_by_manager']);
self::assertSame([], $options['managers']);
}
/**
* @param User[] $groupsStaff
* @param User[] $assigned
*/
private function provider(array $groupsStaff, array $assigned): AccommodationBookingFilterOptionsProvider
{
$users = $this->createMock(UserRepository::class);
$users->method('findGroupsStaff')->willReturn($groupsStaff);
$bookings = $this->createMock(AccommodationBookingRepository::class);
$bookings->method('findAssignedManagers')->willReturn($assigned);
$accommodations = $this->createMock(AccommodationRepository::class);
$accommodations->method('findBy')->willReturn([]);
return new AccommodationBookingFilterOptionsProvider($users, $bookings, $accommodations);
}
private function user(int $id, string $email): User
{
$user = new User($email);
$idProperty = new \ReflectionProperty(User::class, 'id');
$idProperty->setValue($user, $id);
return $user;
}
}
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form\Model\Filter;
use App\Entity\User;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Enum\Groups\AccommodationBookingType;
use App\Form\Model\Filter\AccommodationBookingFilterDto;
use PHPUnit\Framework\TestCase;
class AccommodationBookingFilterDtoTest extends TestCase
{
public function testGroupAdminsStartOnStillLiveBookingsThatAreNotOverYet(): void
{
$filter = AccommodationBookingFilterDto::defaults(true, new User('[email protected]'));
self::assertSame(
[AccommodationBookingStatus::Draft, AccommodationBookingStatus::Open],
$filter->status,
'accepted and discarded bookings need no further work, so they stay out of the way',
);
self::assertSame((new \DateTimeImmutable('today'))->format('Y-m-d'), $filter->dateFrom?->format('Y-m-d'));
self::assertNull($filter->managedBy, 'a group admin sees everybodys bookings');
self::assertFalse($filter->managedByLocked);
}
public function testEverybodyElseStartsPinnedToTheirOwnBookings(): void
{
$user = new User('[email protected]');
$filter = AccommodationBookingFilterDto::defaults(false, $user);
self::assertSame($user, $filter->managedBy);
self::assertTrue($filter->managedByLocked);
}
public function testThePinnedManagerChipCannotBeDismissed(): void
{
$filter = AccommodationBookingFilterDto::defaults(false, new User('[email protected]'));
$chip = $this->chipFor($filter, 'Betreuer:in');
self::assertNotNull($chip);
self::assertFalse($chip->isRemovable());
}
public function testAChosenManagerChipCanBeDismissed(): void
{
$filter = new AccommodationBookingFilterDto();
$filter->managedBy = new User('[email protected]');
$chip = $this->chipFor($filter, 'Betreuer:in');
self::assertNotNull($chip);
self::assertTrue($chip->isRemovable());
self::assertSame(['managedBy'], $chip->removeKeys);
}
public function testTheUnassignedFilterReplacesTheManagerChip(): void
{
$filter = new AccommodationBookingFilterDto();
$filter->managedBy = new User('[email protected]');
$filter->unassigned = true;
$chip = $this->chipFor($filter, 'Betreuer:in');
self::assertNotNull($chip);
self::assertSame('keine Zuordnung', $chip->value);
self::assertSame(['unassigned'], $chip->removeKeys);
}
public function testAWhitespaceOnlySearchIsNotAFilter(): void
{
$filter = new AccommodationBookingFilterDto();
$filter->q = ' ';
self::assertNull($filter->searchTerm());
self::assertFalse($filter->isActive());
}
public function testEveryActiveFilterIsAccountedForAsAChip(): void
{
$filter = new AccommodationBookingFilterDto();
$filter->q = 'meier';
$filter->dateFrom = new \DateTimeImmutable('2026-08-01');
$filter->status = [AccommodationBookingStatus::Open, AccommodationBookingStatus::Accepted];
$filter->type = [AccommodationBookingType::Booking];
self::assertSame(4, $filter->activeCount());
self::assertSame('Offen, Bestätigt', $this->chipFor($filter, 'Status')?->value);
self::assertSame('Buchung', $this->chipFor($filter, 'Art')?->value);
self::assertSame('01.08.2026', $this->chipFor($filter, 'Aufenthalt ab')?->value);
}
public function testAnUntouchedFilterHasNothingToShow(): void
{
$filter = new AccommodationBookingFilterDto();
self::assertSame([], $filter->activeFilters());
self::assertFalse($filter->isActive());
}
private function chipFor(AccommodationBookingFilterDto $filter, string $label): ?\App\Model\ListFilterChip
{
foreach ($filter->activeFilters() as $chip) {
if ($label === $chip->label) {
return $chip;
}
}
return null;
}
}