feat: admin list filtering and search
This commit is contained in:
@@ -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 everybody’s 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Repository\Filter;
|
||||
|
||||
use App\Repository\Filter\AppliesListFiltersTrait;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Query\Expr;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AppliesListFiltersTraitTest extends TestCase
|
||||
{
|
||||
public function testEachTokenHasToMatchAtLeastOneField(): void
|
||||
{
|
||||
$qb = $this->queryBuilder();
|
||||
|
||||
$this->subject()->search($qb, 'meier bonn', ['b.groupName', 'b.city']);
|
||||
|
||||
$dql = (string) $qb->getDQL();
|
||||
self::assertStringContainsString('b.groupName LIKE :searchTerm0 OR b.city LIKE :searchTerm0', $dql);
|
||||
self::assertStringContainsString('b.groupName LIKE :searchTerm1 OR b.city LIKE :searchTerm1', $dql);
|
||||
self::assertSame('%meier%', $qb->getParameter('searchTerm0')?->getValue());
|
||||
self::assertSame('%bonn%', $qb->getParameter('searchTerm1')?->getValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider emptyTerms
|
||||
*/
|
||||
public function testAnEmptyTermNarrowsNothing(?string $term): void
|
||||
{
|
||||
$qb = $this->queryBuilder();
|
||||
|
||||
$this->subject()->search($qb, $term, ['b.groupName']);
|
||||
|
||||
self::assertCount(0, $qb->getParameters());
|
||||
self::assertStringNotContainsString('LIKE', (string) $qb->getDQL());
|
||||
}
|
||||
|
||||
public static function emptyTerms(): iterable
|
||||
{
|
||||
yield 'null' => [null];
|
||||
yield 'empty' => [''];
|
||||
yield 'whitespace' => [" \t "];
|
||||
}
|
||||
|
||||
public function testWildcardsTypedByTheUserStayLiteral(): void
|
||||
{
|
||||
$qb = $this->queryBuilder();
|
||||
|
||||
$this->subject()->search($qb, '100%_rabatt', ['b.groupName']);
|
||||
|
||||
self::assertSame('%100\\%\\_rabatt%', $qb->getParameter('searchTerm0')?->getValue());
|
||||
}
|
||||
|
||||
public function testTheDateRangeMatchesAnyOverlappingPeriod(): void
|
||||
{
|
||||
$qb = $this->queryBuilder();
|
||||
$from = new \DateTimeImmutable('2026-08-01');
|
||||
$to = new \DateTimeImmutable('2026-08-31');
|
||||
|
||||
$this->subject()->overlap($qb, 'b.dateFrom', 'b.dateTo', $from, $to);
|
||||
|
||||
$dql = (string) $qb->getDQL();
|
||||
// A stay overlaps the filtered period when it ends after it starts and starts before
|
||||
// it ends — the fields are deliberately crossed over.
|
||||
self::assertStringContainsString('b.dateTo >= :filterDateFrom', $dql);
|
||||
self::assertStringContainsString('b.dateFrom <= :filterDateTo', $dql);
|
||||
self::assertSame($from, $qb->getParameter('filterDateFrom')?->getValue());
|
||||
self::assertSame($to, $qb->getParameter('filterDateTo')?->getValue());
|
||||
}
|
||||
|
||||
public function testAnOpenEndedRangeOnlyConstrainsTheEndItHas(): void
|
||||
{
|
||||
$qb = $this->queryBuilder();
|
||||
|
||||
$this->subject()->overlap($qb, 'b.dateFrom', 'b.dateTo', new \DateTimeImmutable('2026-08-01'), null);
|
||||
|
||||
self::assertStringNotContainsString('filterDateTo', (string) $qb->getDQL());
|
||||
}
|
||||
|
||||
public function testTheLastDayOfARangeIsIncludedForTimestamps(): void
|
||||
{
|
||||
$qb = $this->queryBuilder();
|
||||
|
||||
$this->subject()->within($qb, 'b.createdAt', null, new \DateTimeImmutable('2026-08-31'));
|
||||
|
||||
// Anything logged during 31 August still counts, so the bound moves to the next midnight.
|
||||
self::assertStringContainsString('b.createdAt < :filterDateTo', (string) $qb->getDQL());
|
||||
self::assertSame(
|
||||
'2026-09-01',
|
||||
$qb->getParameter('filterDateTo')?->getValue()->format('Y-m-d'),
|
||||
);
|
||||
}
|
||||
|
||||
private function subject(): object
|
||||
{
|
||||
return new class {
|
||||
use AppliesListFiltersTrait;
|
||||
|
||||
/** @param string[] $fields */
|
||||
public function search(QueryBuilder $qb, ?string $term, array $fields): void
|
||||
{
|
||||
$this->applySearchTerm($qb, $term, $fields);
|
||||
}
|
||||
|
||||
public function overlap(QueryBuilder $qb, string $fromField, string $toField, ?\DateTimeImmutable $from, ?\DateTimeImmutable $to): void
|
||||
{
|
||||
$this->applyDateOverlap($qb, $fromField, $toField, $from, $to);
|
||||
}
|
||||
|
||||
public function within(QueryBuilder $qb, string $field, ?\DateTimeImmutable $from, ?\DateTimeImmutable $to): void
|
||||
{
|
||||
$this->applyDateWithin($qb, $field, $from, $to);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function queryBuilder(): QueryBuilder
|
||||
{
|
||||
$em = $this->createMock(EntityManagerInterface::class);
|
||||
$em->method('getExpressionBuilder')->willReturn(new Expr());
|
||||
|
||||
return (new QueryBuilder($em))->select('b')->from('Booking', 'b');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user