feat: Pagination et recherche des sections admin
Les listes admin (utilisateurs, classes, matières, affectations) chargeaient toutes les données d'un coup, ce qui dégradait l'expérience avec un volume croissant. La pagination côté serveur existait dans la config API Platform mais aucun Provider ne l'exploitait. Cette implémentation ajoute la pagination serveur (30 items/page, max 100) avec recherche textuelle sur toutes les sections, des composants frontend réutilisables (Pagination + SearchInput avec debounce), et la synchronisation URL pour le partage de liens filtrés. Les Query valident leurs paramètres (clamp page/limit, trim search) pour éviter les abus. Les affectations utilisent des lookup maps pour résoudre les noms sans N+1 queries. Les pages admin gèrent les race conditions via AbortController.
This commit is contained in:
@@ -46,7 +46,10 @@ final class GetUsersHandlerTest extends TestCase
|
||||
$query = new GetUsersQuery(tenantId: self::TENANT_ID);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(3, $result);
|
||||
self::assertCount(3, $result->items);
|
||||
self::assertSame(3, $result->total);
|
||||
self::assertSame(1, $result->page);
|
||||
self::assertSame(30, $result->limit);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -60,8 +63,9 @@ final class GetUsersHandlerTest extends TestCase
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(2, $result);
|
||||
foreach ($result as $dto) {
|
||||
self::assertCount(2, $result->items);
|
||||
self::assertSame(2, $result->total);
|
||||
foreach ($result->items as $dto) {
|
||||
self::assertSame(Role::PROF->value, $dto->role);
|
||||
}
|
||||
}
|
||||
@@ -77,8 +81,9 @@ final class GetUsersHandlerTest extends TestCase
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(2, $result);
|
||||
foreach ($result as $dto) {
|
||||
self::assertCount(2, $result->items);
|
||||
self::assertSame(2, $result->total);
|
||||
foreach ($result->items as $dto) {
|
||||
self::assertSame('pending', $dto->statut);
|
||||
}
|
||||
}
|
||||
@@ -88,7 +93,6 @@ final class GetUsersHandlerTest extends TestCase
|
||||
{
|
||||
$this->seedUsers();
|
||||
|
||||
// Add user to different tenant
|
||||
$otherUser = User::inviter(
|
||||
email: new Email('other@example.com'),
|
||||
role: Role::ADMIN,
|
||||
@@ -103,13 +107,13 @@ final class GetUsersHandlerTest extends TestCase
|
||||
$query = new GetUsersQuery(tenantId: self::TENANT_ID);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(3, $result);
|
||||
self::assertCount(3, $result->items);
|
||||
self::assertSame(3, $result->total);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function calculatesInvitationExpiree(): void
|
||||
{
|
||||
// Invited 10 days ago — should be expired
|
||||
$user = User::inviter(
|
||||
email: new Email('old@example.com'),
|
||||
role: Role::PROF,
|
||||
@@ -124,8 +128,171 @@ final class GetUsersHandlerTest extends TestCase
|
||||
$query = new GetUsersQuery(tenantId: self::TENANT_ID);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertTrue($result[0]->invitationExpiree);
|
||||
self::assertCount(1, $result->items);
|
||||
self::assertTrue($result->items[0]->invitationExpiree);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function paginatesResults(): void
|
||||
{
|
||||
$this->seedUsers();
|
||||
|
||||
$query = new GetUsersQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
page: 1,
|
||||
limit: 2,
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(2, $result->items);
|
||||
self::assertSame(3, $result->total);
|
||||
self::assertSame(1, $result->page);
|
||||
self::assertSame(2, $result->limit);
|
||||
self::assertSame(2, $result->totalPages());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsSecondPage(): void
|
||||
{
|
||||
$this->seedUsers();
|
||||
|
||||
$query = new GetUsersQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
page: 2,
|
||||
limit: 2,
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(1, $result->items);
|
||||
self::assertSame(3, $result->total);
|
||||
self::assertSame(2, $result->page);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function searchesByFirstName(): void
|
||||
{
|
||||
$this->seedUsers();
|
||||
|
||||
$query = new GetUsersQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
search: 'Jean',
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(1, $result->items);
|
||||
self::assertSame('Jean', $result->items[0]->firstName);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function searchesByLastName(): void
|
||||
{
|
||||
$this->seedUsers();
|
||||
|
||||
$query = new GetUsersQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
search: 'Martin',
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(1, $result->items);
|
||||
self::assertSame('Martin', $result->items[0]->lastName);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function searchesByEmail(): void
|
||||
{
|
||||
$this->seedUsers();
|
||||
|
||||
$query = new GetUsersQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
search: 'parent@',
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(1, $result->items);
|
||||
self::assertSame('parent@example.com', $result->items[0]->email);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function searchIsCaseInsensitive(): void
|
||||
{
|
||||
$this->seedUsers();
|
||||
|
||||
$query = new GetUsersQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
search: 'jean',
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(1, $result->items);
|
||||
self::assertSame('Jean', $result->items[0]->firstName);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function searchCombinesWithRoleFilter(): void
|
||||
{
|
||||
$this->seedUsers();
|
||||
|
||||
$query = new GetUsersQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
role: Role::PROF->value,
|
||||
search: 'Jean',
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(1, $result->items);
|
||||
self::assertSame('Jean', $result->items[0]->firstName);
|
||||
self::assertSame(Role::PROF->value, $result->items[0]->role);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function searchResetsCountCorrectly(): void
|
||||
{
|
||||
$this->seedUsers();
|
||||
|
||||
$query = new GetUsersQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
search: 'nonexistent',
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(0, $result->items);
|
||||
self::assertSame(0, $result->total);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function clampsPageZeroToOne(): void
|
||||
{
|
||||
$query = new GetUsersQuery(tenantId: self::TENANT_ID, page: 0);
|
||||
self::assertSame(1, $query->page);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function clampsNegativePageToOne(): void
|
||||
{
|
||||
$query = new GetUsersQuery(tenantId: self::TENANT_ID, page: -5);
|
||||
self::assertSame(1, $query->page);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function clampsLimitZeroToOne(): void
|
||||
{
|
||||
$query = new GetUsersQuery(tenantId: self::TENANT_ID, limit: 0);
|
||||
self::assertSame(1, $query->limit);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function clampsExcessiveLimitToHundred(): void
|
||||
{
|
||||
$query = new GetUsersQuery(tenantId: self::TENANT_ID, limit: 999);
|
||||
self::assertSame(100, $query->limit);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function clampsNegativeLimitToOne(): void
|
||||
{
|
||||
$query = new GetUsersQuery(tenantId: self::TENANT_ID, limit: -10);
|
||||
self::assertSame(1, $query->limit);
|
||||
}
|
||||
|
||||
private function seedUsers(): void
|
||||
@@ -152,7 +319,6 @@ final class GetUsersHandlerTest extends TestCase
|
||||
lastName: 'Martin',
|
||||
invitedAt: new DateTimeImmutable('2026-02-01 10:00:00'),
|
||||
);
|
||||
// Activate teacher2
|
||||
$teacher2->activer(
|
||||
'$argon2id$hashed',
|
||||
new DateTimeImmutable('2026-02-02 10:00:00'),
|
||||
|
||||
Reference in New Issue
Block a user