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:
2026-02-15 13:54:51 +01:00
parent 88e7f319db
commit 76e16db0d8
57 changed files with 3123 additions and 181 deletions

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Dto;
/**
* Résultat paginé générique retourné par les Query Handlers.
*
* @template T
*/
final readonly class PaginatedResult
{
public const int DEFAULT_PAGE = 1;
public const int DEFAULT_LIMIT = 30;
public const int MAX_LIMIT = 100;
public const int MAX_SEARCH_LENGTH = 255;
/**
* @param T[] $items Les éléments de la page courante
* @param int $total Le nombre total d'éléments (toutes pages confondues)
* @param int $page La page courante (1-indexed)
* @param int $limit Le nombre d'éléments par page
*/
public function __construct(
public array $items,
public int $total,
public int $page,
public int $limit,
) {
}
public function totalPages(): int
{
if ($this->limit <= 0) {
return 0;
}
return (int) ceil($this->total / $this->limit);
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetAllAssignments;
use DateTimeImmutable;
final readonly class AssignmentWithNamesDto
{
public function __construct(
public string $id,
public string $teacherId,
public string $teacherFirstName,
public string $teacherLastName,
public string $classId,
public string $className,
public string $subjectId,
public string $subjectName,
public string $academicYearId,
public string $status,
public DateTimeImmutable $startDate,
public ?DateTimeImmutable $endDate,
public DateTimeImmutable $createdAt,
) {
}
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetAllAssignments;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Domain\Repository\SubjectRepository;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_slice;
use function count;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetAllAssignmentsHandler
{
public function __construct(
private TeacherAssignmentRepository $assignmentRepository,
private UserRepository $userRepository,
private ClassRepository $classRepository,
private SubjectRepository $subjectRepository,
private LoggerInterface $logger,
) {
}
/**
* @return PaginatedResult<AssignmentWithNamesDto>
*/
public function __invoke(GetAllAssignmentsQuery $query): PaginatedResult
{
$tenantId = TenantId::fromString($query->tenantId);
$assignments = $this->assignmentRepository->findAllActiveByTenant($tenantId);
// Build lookup maps for users, classes, and subjects
$users = $this->userRepository->findAllByTenant($tenantId);
/** @var array<string, array{firstName: string, lastName: string}> $userNames */
$userNames = [];
foreach ($users as $user) {
$userNames[(string) $user->id] = [
'firstName' => $user->firstName,
'lastName' => $user->lastName,
];
}
$classes = $this->classRepository->findAllActiveByTenant($tenantId);
/** @var array<string, string> $classNames */
$classNames = [];
foreach ($classes as $class) {
$classNames[(string) $class->id] = (string) $class->name;
}
$subjects = $this->subjectRepository->findAllActiveByTenant($tenantId);
/** @var array<string, string> $subjectNames */
$subjectNames = [];
foreach ($subjects as $subject) {
$subjectNames[(string) $subject->id] = (string) $subject->name;
}
$dtos = [];
foreach ($assignments as $assignment) {
$teacherId = (string) $assignment->teacherId;
$classId = (string) $assignment->classId;
$subjectId = (string) $assignment->subjectId;
if (!isset($userNames[$teacherId])) {
$this->logger->warning('Assignment {assignmentId} references unknown teacher {teacherId}', [
'assignmentId' => (string) $assignment->id,
'teacherId' => $teacherId,
]);
}
if (!isset($classNames[$classId])) {
$this->logger->warning('Assignment {assignmentId} references unknown class {classId}', [
'assignmentId' => (string) $assignment->id,
'classId' => $classId,
]);
}
if (!isset($subjectNames[$subjectId])) {
$this->logger->warning('Assignment {assignmentId} references unknown subject {subjectId}', [
'assignmentId' => (string) $assignment->id,
'subjectId' => $subjectId,
]);
}
$teacher = $userNames[$teacherId] ?? ['firstName' => '', 'lastName' => ''];
$dtos[] = new AssignmentWithNamesDto(
id: (string) $assignment->id,
teacherId: $teacherId,
teacherFirstName: $teacher['firstName'],
teacherLastName: $teacher['lastName'],
classId: $classId,
className: $classNames[$classId] ?? '',
subjectId: $subjectId,
subjectName: $subjectNames[$subjectId] ?? '',
academicYearId: (string) $assignment->academicYearId,
status: $assignment->status->value,
startDate: $assignment->startDate,
endDate: $assignment->endDate,
createdAt: $assignment->createdAt,
);
}
if ($query->search !== null && $query->search !== '') {
$searchLower = mb_strtolower($query->search);
$dtos = array_values(array_filter(
$dtos,
static fn (AssignmentWithNamesDto $dto) => str_contains(mb_strtolower($dto->teacherFirstName), $searchLower)
|| str_contains(mb_strtolower($dto->teacherLastName), $searchLower)
|| str_contains(mb_strtolower($dto->className), $searchLower)
|| str_contains(mb_strtolower($dto->subjectName), $searchLower),
));
}
$total = count($dtos);
$offset = ($query->page - 1) * $query->limit;
$items = array_slice($dtos, $offset, $query->limit);
return new PaginatedResult(
items: $items,
total: $total,
page: $query->page,
limit: $query->limit,
);
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetAllAssignments;
use App\Administration\Application\Dto\PaginatedResult;
final readonly class GetAllAssignmentsQuery
{
public int $page;
public int $limit;
public ?string $search;
public function __construct(
public string $tenantId,
int $page = PaginatedResult::DEFAULT_PAGE,
int $limit = PaginatedResult::DEFAULT_LIMIT,
?string $search = null,
) {
$this->page = max(1, $page);
$this->limit = max(1, min(PaginatedResult::MAX_LIMIT, $limit));
$this->search = $search !== null ? mb_substr(trim($search), 0, PaginatedResult::MAX_SEARCH_LENGTH) : null;
}
}

View File

@@ -4,14 +4,16 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetClasses;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Repository\ClassRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_slice;
use function count;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Handler pour récupérer les classes actives d'un tenant.
*/
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetClassesHandler
{
@@ -21,18 +23,37 @@ final readonly class GetClassesHandler
}
/**
* @return ClassDto[]
* @return PaginatedResult<ClassDto>
*/
public function __invoke(GetClassesQuery $query): array
public function __invoke(GetClassesQuery $query): PaginatedResult
{
$classes = $this->classRepository->findActiveByTenantAndYear(
TenantId::fromString($query->tenantId),
AcademicYearId::fromString($query->academicYearId),
);
return array_map(
static fn ($class) => ClassDto::fromDomain($class),
if ($query->search !== null && $query->search !== '') {
$searchLower = mb_strtolower($query->search);
$classes = array_filter(
$classes,
static fn ($class) => str_contains(mb_strtolower((string) $class->name), $searchLower)
|| ($class->level !== null && str_contains(mb_strtolower($class->level->value), $searchLower)),
);
$classes = array_values($classes);
}
$total = count($classes);
$offset = ($query->page - 1) * $query->limit;
$items = array_slice($classes, $offset, $query->limit);
return new PaginatedResult(
items: array_map(
static fn ($class) => ClassDto::fromDomain($class),
$items,
),
total: $total,
page: $query->page,
limit: $query->limit,
);
}
}

View File

@@ -4,14 +4,26 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetClasses;
use App\Administration\Application\Dto\PaginatedResult;
/**
* Query pour récupérer les classes actives d'un tenant pour une année scolaire.
*/
final readonly class GetClassesQuery
{
public int $page;
public int $limit;
public ?string $search;
public function __construct(
public string $tenantId,
public string $academicYearId,
int $page = PaginatedResult::DEFAULT_PAGE,
int $limit = PaginatedResult::DEFAULT_LIMIT,
?string $search = null,
) {
$this->page = max(1, $page);
$this->limit = max(1, min(PaginatedResult::MAX_LIMIT, $limit));
$this->search = $search !== null ? mb_substr(trim($search), 0, PaginatedResult::MAX_SEARCH_LENGTH) : null;
}
}

View File

@@ -4,17 +4,16 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetSubjects;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Repository\SubjectRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_map;
use function array_slice;
use function count;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Handler pour récupérer les matières actives d'un tenant.
*/
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetSubjectsHandler
{
@@ -24,25 +23,41 @@ final readonly class GetSubjectsHandler
}
/**
* @return SubjectDto[]
* @return PaginatedResult<SubjectDto>
*/
public function __invoke(GetSubjectsQuery $query): array
public function __invoke(GetSubjectsQuery $query): PaginatedResult
{
$subjects = $this->subjectRepository->findActiveByTenantAndSchool(
TenantId::fromString($query->tenantId),
SchoolId::fromString($query->schoolId),
);
// TODO: Récupérer les comptages d'enseignants et de classes
// quand les modules Affectations seront implémentés (T7)
if ($query->search !== null && $query->search !== '') {
$searchLower = mb_strtolower($query->search);
$subjects = array_filter(
$subjects,
static fn ($subject) => str_contains(mb_strtolower((string) $subject->name), $searchLower)
|| str_contains(mb_strtolower((string) $subject->code), $searchLower),
);
$subjects = array_values($subjects);
}
return array_map(
$total = count($subjects);
$offset = ($query->page - 1) * $query->limit;
$items = array_slice($subjects, $offset, $query->limit);
return new PaginatedResult(
items: array_map(
static fn ($subject) => SubjectDto::fromDomain(
$subject,
teacherCount: 0, // Placeholder - T7
classCount: 0, // Placeholder - T7
teacherCount: 0,
classCount: 0,
),
$subjects,
$items,
),
total: $total,
page: $query->page,
limit: $query->limit,
);
}
}

View File

@@ -4,14 +4,26 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetSubjects;
use App\Administration\Application\Dto\PaginatedResult;
/**
* Query pour récupérer les matières actives d'un tenant et d'une école.
*/
final readonly class GetSubjectsQuery
{
public int $page;
public int $limit;
public ?string $search;
public function __construct(
public string $tenantId,
public string $schoolId,
int $page = PaginatedResult::DEFAULT_PAGE,
int $limit = PaginatedResult::DEFAULT_LIMIT,
?string $search = null,
) {
$this->page = max(1, $page);
$this->limit = max(1, min(PaginatedResult::MAX_LIMIT, $limit));
$this->search = $search !== null ? mb_substr(trim($search), 0, PaginatedResult::MAX_SEARCH_LENGTH) : null;
}
}

View File

@@ -4,11 +4,16 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetUsers;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\StatutCompte;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use function array_slice;
use function count;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
@@ -21,15 +26,14 @@ final readonly class GetUsersHandler
}
/**
* @return UserDto[]
* @return PaginatedResult<UserDto>
*/
public function __invoke(GetUsersQuery $query): array
public function __invoke(GetUsersQuery $query): PaginatedResult
{
$users = $this->userRepository->findAllByTenant(
TenantId::fromString($query->tenantId),
);
// Apply filters
if ($query->role !== null) {
$filterRole = Role::tryFrom($query->role);
if ($filterRole !== null) {
@@ -50,9 +54,30 @@ final readonly class GetUsersHandler
}
}
return array_values(array_map(
fn ($user) => UserDto::fromDomain($user, $this->clock),
if ($query->search !== null && $query->search !== '') {
$searchLower = mb_strtolower($query->search);
$users = array_filter(
$users,
));
static fn ($user) => str_contains(mb_strtolower($user->firstName), $searchLower)
|| str_contains(mb_strtolower($user->lastName), $searchLower)
|| str_contains(mb_strtolower((string) $user->email), $searchLower),
);
}
$users = array_values($users);
$total = count($users);
$offset = ($query->page - 1) * $query->limit;
$items = array_slice($users, $offset, $query->limit);
return new PaginatedResult(
items: array_map(
fn ($user) => UserDto::fromDomain($user, $this->clock),
$items,
),
total: $total,
page: $query->page,
limit: $query->limit,
);
}
}

View File

@@ -4,12 +4,24 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetUsers;
use App\Administration\Application\Dto\PaginatedResult;
final readonly class GetUsersQuery
{
public int $page;
public int $limit;
public ?string $search;
public function __construct(
public string $tenantId,
public ?string $role = null,
public ?string $statut = null,
int $page = PaginatedResult::DEFAULT_PAGE,
int $limit = PaginatedResult::DEFAULT_LIMIT,
?string $search = null,
) {
$this->page = max(1, $page);
$this->limit = max(1, min(PaginatedResult::MAX_LIMIT, $limit));
$this->search = $search !== null ? mb_substr(trim($search), 0, PaginatedResult::MAX_SEARCH_LENGTH) : null;
}
}

View File

@@ -40,6 +40,13 @@ interface ClassRepository
AcademicYearId $academicYearId,
): array;
/**
* Retourne toutes les classes actives d'un tenant (toutes années confondues).
*
* @return SchoolClass[]
*/
public function findAllActiveByTenant(TenantId $tenantId): array;
/**
* Supprime une classe du repository.
*/

View File

@@ -40,6 +40,13 @@ interface SubjectRepository
SchoolId $schoolId,
): array;
/**
* Retourne toutes les matières actives d'un tenant (toutes écoles confondues).
*
* @return Subject[]
*/
public function findAllActiveByTenant(TenantId $tenantId): array;
/**
* Supprime une matière du repository.
*/

View File

@@ -67,4 +67,11 @@ interface TeacherAssignmentRepository
ClassId $classId,
TenantId $tenantId,
): array;
/**
* Retourne toutes les affectations actives d'un tenant.
*
* @return TeacherAssignment[]
*/
public function findAllActiveByTenant(TenantId $tenantId): array;
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\TraversablePaginator;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Application\Query\GetClasses\GetClassesHandler;
use App\Administration\Application\Query\GetClasses\GetClassesQuery;
@@ -12,13 +13,14 @@ use App\Administration\Infrastructure\Api\Resource\ClassResource;
use App\Administration\Infrastructure\Security\ClassVoter;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Infrastructure\Tenant\TenantContext;
use ArrayIterator;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* State Provider pour récupérer la liste des classes.
* State Provider pour récupérer la liste des classes avec pagination.
*
* @implements ProviderInterface<ClassResource>
*/
@@ -32,13 +34,9 @@ final readonly class ClassCollectionProvider implements ProviderInterface
) {
}
/**
* @return ClassResource[]
*/
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
public function provide(Operation $operation, array $uriVariables = [], array $context = []): TraversablePaginator
{
// Vérifier les permissions de lecture (sans sujet spécifique)
if (!$this->authorizationChecker->isGranted(ClassVoter::VIEW)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les classes.');
}
@@ -48,20 +46,28 @@ final readonly class ClassCollectionProvider implements ProviderInterface
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
/** @var array<string, string> $filters */
$filters = $context['filters'] ?? [];
$page = (int) ($filters['page'] ?? 1);
$itemsPerPage = (int) ($filters['itemsPerPage'] ?? 30);
$academicYearId = $this->academicYearResolver->resolve('current') ?? '';
if ($academicYearId === '') {
return [];
return new TraversablePaginator(new ArrayIterator([]), $page, $itemsPerPage, 0);
}
$query = new GetClassesQuery(
tenantId: $tenantId,
academicYearId: $academicYearId,
page: $page,
limit: $itemsPerPage,
search: isset($filters['search']) ? (string) $filters['search'] : null,
);
$classDtos = ($this->handler)($query);
$result = ($this->handler)($query);
return array_map(
$resources = array_map(
static function ($dto) {
$resource = new ClassResource();
$resource->id = $dto->id;
@@ -75,7 +81,14 @@ final readonly class ClassCollectionProvider implements ProviderInterface
return $resource;
},
$classDtos,
$result->items,
);
return new TraversablePaginator(
new ArrayIterator($resources),
$page,
$itemsPerPage,
$result->total,
);
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\TraversablePaginator;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Application\Query\GetSubjects\GetSubjectsHandler;
use App\Administration\Application\Query\GetSubjects\GetSubjectsQuery;
@@ -15,13 +16,14 @@ use App\Shared\Infrastructure\Tenant\TenantContext;
use function array_map;
use ArrayIterator;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* State Provider pour récupérer la liste des matières.
* State Provider pour récupérer la liste des matières avec pagination.
*
* @implements ProviderInterface<SubjectResource>
*/
@@ -35,13 +37,9 @@ final readonly class SubjectCollectionProvider implements ProviderInterface
) {
}
/**
* @return SubjectResource[]
*/
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
public function provide(Operation $operation, array $uriVariables = [], array $context = []): TraversablePaginator
{
// Vérifier les permissions de lecture (sans sujet spécifique)
if (!$this->authorizationChecker->isGranted(SubjectVoter::VIEW)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les matières.');
}
@@ -52,14 +50,29 @@ final readonly class SubjectCollectionProvider implements ProviderInterface
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
$schoolId = $this->schoolIdResolver->resolveForTenant($tenantId);
/** @var array<string, string> $filters */
$filters = $context['filters'] ?? [];
$page = (int) ($filters['page'] ?? 1);
$itemsPerPage = (int) ($filters['itemsPerPage'] ?? 30);
$query = new GetSubjectsQuery(
tenantId: $tenantId,
schoolId: $schoolId,
page: $page,
limit: $itemsPerPage,
search: isset($filters['search']) ? (string) $filters['search'] : null,
);
$subjectDtos = ($this->handler)($query);
$result = ($this->handler)($query);
return array_map(SubjectResource::fromDto(...), $subjectDtos);
$resources = array_map(SubjectResource::fromDto(...), $result->items);
return new TraversablePaginator(
new ArrayIterator($resources),
$page,
$itemsPerPage,
$result->total,
);
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\TraversablePaginator;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Application\Query\GetAllAssignments\AssignmentWithNamesDto;
use App\Administration\Application\Query\GetAllAssignments\GetAllAssignmentsHandler;
use App\Administration\Application\Query\GetAllAssignments\GetAllAssignmentsQuery;
use App\Administration\Infrastructure\Api\Resource\TeacherAssignmentResource;
use App\Administration\Infrastructure\Security\TeacherAssignmentVoter;
use App\Shared\Infrastructure\Tenant\TenantContext;
use function array_map;
use ArrayIterator;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* State Provider pour récupérer toutes les affectations avec pagination et recherche.
*
* @implements ProviderInterface<TeacherAssignmentResource>
*/
final readonly class TeacherAssignmentsCollectionProvider implements ProviderInterface
{
public function __construct(
private GetAllAssignmentsHandler $handler,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
) {
}
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): TraversablePaginator
{
if (!$this->authorizationChecker->isGranted(TeacherAssignmentVoter::VIEW)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les affectations.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
/** @var array<string, string> $filters */
$filters = $context['filters'] ?? [];
$page = (int) ($filters['page'] ?? 1);
$itemsPerPage = (int) ($filters['itemsPerPage'] ?? 30);
$query = new GetAllAssignmentsQuery(
tenantId: $tenantId,
page: $page,
limit: $itemsPerPage,
search: isset($filters['search']) ? (string) $filters['search'] : null,
);
$result = ($this->handler)($query);
$resources = array_map(
static function (AssignmentWithNamesDto $dto) {
$resource = new TeacherAssignmentResource();
$resource->id = $dto->id;
$resource->teacherId = $dto->teacherId;
$resource->classId = $dto->classId;
$resource->subjectId = $dto->subjectId;
$resource->academicYearId = $dto->academicYearId;
$resource->status = $dto->status;
$resource->startDate = $dto->startDate;
$resource->endDate = $dto->endDate;
$resource->createdAt = $dto->createdAt;
$resource->teacherFirstName = $dto->teacherFirstName;
$resource->teacherLastName = $dto->teacherLastName;
$resource->className = $dto->className;
$resource->subjectName = $dto->subjectName;
return $resource;
},
$result->items,
);
return new TraversablePaginator(
new ArrayIterator($resources),
$page,
$itemsPerPage,
$result->total,
);
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\TraversablePaginator;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Application\Query\GetUsers\GetUsersHandler;
use App\Administration\Application\Query\GetUsers\GetUsersQuery;
@@ -14,13 +15,14 @@ use App\Shared\Infrastructure\Tenant\TenantContext;
use function array_map;
use ArrayIterator;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* State Provider pour récupérer la liste des utilisateurs avec filtres.
* State Provider pour récupérer la liste des utilisateurs avec filtres et pagination.
*
* @implements ProviderInterface<UserResource>
*/
@@ -33,11 +35,8 @@ final readonly class UserCollectionProvider implements ProviderInterface
) {
}
/**
* @return UserResource[]
*/
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
public function provide(Operation $operation, array $uriVariables = [], array $context = []): TraversablePaginator
{
if (!$this->authorizationChecker->isGranted(UserVoter::VIEW)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les utilisateurs.');
@@ -51,14 +50,27 @@ final readonly class UserCollectionProvider implements ProviderInterface
/** @var array<string, string> $filters */
$filters = $context['filters'] ?? [];
$page = (int) ($filters['page'] ?? 1);
$itemsPerPage = (int) ($filters['itemsPerPage'] ?? 30);
$query = new GetUsersQuery(
tenantId: $tenantId,
role: isset($filters['role']) ? (string) $filters['role'] : null,
statut: isset($filters['statut']) ? (string) $filters['statut'] : null,
page: $page,
limit: $itemsPerPage,
search: isset($filters['search']) ? (string) $filters['search'] : null,
);
$userDtos = ($this->handler)($query);
$result = ($this->handler)($query);
return array_map(UserResource::fromDto(...), $userDtos);
$resources = array_map(UserResource::fromDto(...), $result->items);
return new TraversablePaginator(
new ArrayIterator($resources),
$page,
$itemsPerPage,
$result->total,
);
}
}

View File

@@ -17,6 +17,7 @@ use App\Administration\Infrastructure\Api\Processor\RemoveTeacherAssignmentProce
use App\Administration\Infrastructure\Api\Provider\TeacherAssignmentItemProvider;
use App\Administration\Infrastructure\Api\Provider\TeacherAssignmentsByClassProvider;
use App\Administration\Infrastructure\Api\Provider\TeacherAssignmentsByTeacherProvider;
use App\Administration\Infrastructure\Api\Provider\TeacherAssignmentsCollectionProvider;
use DateTimeImmutable;
use Symfony\Component\Validator\Constraints as Assert;
@@ -29,6 +30,11 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
shortName: 'TeacherAssignment',
operations: [
new GetCollection(
uriTemplate: '/teacher-assignments',
provider: TeacherAssignmentsCollectionProvider::class,
name: 'get_all_teacher_assignments',
),
new GetCollection(
uriTemplate: '/teachers/{teacherId}/assignments',
uriVariables: [
@@ -90,6 +96,14 @@ final class TeacherAssignmentResource
public ?DateTimeImmutable $createdAt = null;
public ?string $teacherFirstName = null;
public ?string $teacherLastName = null;
public ?string $className = null;
public ?string $subjectName = null;
public static function fromDomain(TeacherAssignment $assignment): self
{
$resource = new self();

View File

@@ -130,6 +130,23 @@ final readonly class DoctrineClassRepository implements ClassRepository
return array_map(fn ($row) => $this->hydrate($row), $rows);
}
#[Override]
public function findAllActiveByTenant(TenantId $tenantId): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM school_classes
WHERE tenant_id = :tenant_id
AND status = :status
ORDER BY name ASC',
[
'tenant_id' => (string) $tenantId,
'status' => ClassStatus::ACTIVE->value,
],
);
return array_map(fn ($row) => $this->hydrate($row), $rows);
}
#[Override]
public function delete(ClassId $id): void
{

View File

@@ -129,6 +129,23 @@ final readonly class DoctrineSubjectRepository implements SubjectRepository
return array_map(fn ($row) => $this->hydrate($row), $rows);
}
#[Override]
public function findAllActiveByTenant(TenantId $tenantId): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM subjects
WHERE tenant_id = :tenant_id
AND status = :status
ORDER BY name ASC',
[
'tenant_id' => (string) $tenantId,
'status' => SubjectStatus::ACTIVE->value,
],
);
return array_map(fn ($row) => $this->hydrate($row), $rows);
}
#[Override]
public function delete(SubjectId $id): void
{

View File

@@ -197,6 +197,23 @@ final readonly class DoctrineTeacherAssignmentRepository implements TeacherAssig
return array_map(fn ($row) => $this->hydrate($row), $rows);
}
#[Override]
public function findAllActiveByTenant(TenantId $tenantId): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM teacher_assignments
WHERE tenant_id = :tenant_id
AND status = :status
ORDER BY created_at ASC',
[
'tenant_id' => (string) $tenantId,
'status' => AssignmentStatus::ACTIVE->value,
],
);
return array_map(fn ($row) => $this->hydrate($row), $rows);
}
/**
* @param array<string, mixed> $row
*/

View File

@@ -82,6 +82,22 @@ final class InMemoryClassRepository implements ClassRepository
return $result;
}
#[Override]
public function findAllActiveByTenant(TenantId $tenantId): array
{
$result = [];
foreach ($this->byId as $class) {
if ($class->tenantId->equals($tenantId)
&& $class->status === ClassStatus::ACTIVE
) {
$result[] = $class;
}
}
return $result;
}
#[Override]
public function delete(ClassId $id): void
{

View File

@@ -99,6 +99,22 @@ final class InMemorySubjectRepository implements SubjectRepository
return $result;
}
#[Override]
public function findAllActiveByTenant(TenantId $tenantId): array
{
$result = [];
foreach ($this->byId as $subject) {
if ($subject->tenantId->equals($tenantId)
&& $subject->status === SubjectStatus::ACTIVE
) {
$result[] = $subject;
}
}
return $result;
}
#[Override]
public function delete(SubjectId $id): void
{

View File

@@ -134,4 +134,20 @@ final class InMemoryTeacherAssignmentRepository implements TeacherAssignmentRepo
return $result;
}
#[Override]
public function findAllActiveByTenant(TenantId $tenantId): array
{
$result = [];
foreach ($this->byId as $assignment) {
if ($assignment->tenantId->equals($tenantId)
&& $assignment->status === AssignmentStatus::ACTIVE
) {
$result[] = $assignment;
}
}
return $result;
}
}

View File

@@ -0,0 +1,368 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Query\GetAllAssignments;
use App\Administration\Application\Query\GetAllAssignments\GetAllAssignmentsHandler;
use App\Administration\Application\Query\GetAllAssignments\GetAllAssignmentsQuery;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassName;
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
use App\Administration\Domain\Model\Subject\Subject;
use App\Administration\Domain\Model\Subject\SubjectCode;
use App\Administration\Domain\Model\Subject\SubjectName;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySubjectRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryTeacherAssignmentRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
use App\Shared\Domain\Tenant\TenantId;
use function count;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
final class GetAllAssignmentsHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string OTHER_TENANT_ID = '550e8400-e29b-41d4-a716-446655440099';
private InMemoryTeacherAssignmentRepository $assignmentRepository;
private InMemoryUserRepository $userRepository;
private InMemoryClassRepository $classRepository;
private InMemorySubjectRepository $subjectRepository;
private GetAllAssignmentsHandler $handler;
protected function setUp(): void
{
$this->assignmentRepository = new InMemoryTeacherAssignmentRepository();
$this->userRepository = new InMemoryUserRepository();
$this->classRepository = new InMemoryClassRepository();
$this->subjectRepository = new InMemorySubjectRepository();
$this->handler = new GetAllAssignmentsHandler(
$this->assignmentRepository,
$this->userRepository,
$this->classRepository,
$this->subjectRepository,
new NullLogger(),
);
}
#[Test]
public function returnsAllActiveAssignmentsWithNames(): void
{
$this->seedData();
$query = new GetAllAssignmentsQuery(tenantId: self::TENANT_ID);
$result = ($this->handler)($query);
self::assertCount(2, $result->items);
self::assertSame(2, $result->total);
self::assertSame(1, $result->page);
self::assertSame(30, $result->limit);
// Verify denormalized names are populated
$dto = $result->items[0];
self::assertNotSame('', $dto->teacherFirstName);
self::assertNotSame('', $dto->className);
self::assertNotSame('', $dto->subjectName);
}
#[Test]
public function searchesByTeacherName(): void
{
$this->seedData();
$query = new GetAllAssignmentsQuery(
tenantId: self::TENANT_ID,
search: 'Jean',
);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
self::assertSame('Jean', $result->items[0]->teacherFirstName);
}
#[Test]
public function searchesByClassName(): void
{
$this->seedData();
$query = new GetAllAssignmentsQuery(
tenantId: self::TENANT_ID,
search: '6ème',
);
$result = ($this->handler)($query);
self::assertGreaterThanOrEqual(1, count($result->items));
self::assertStringContainsString('6ème', $result->items[0]->className);
}
#[Test]
public function searchesBySubjectName(): void
{
$this->seedData();
$query = new GetAllAssignmentsQuery(
tenantId: self::TENANT_ID,
search: 'Français',
);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
self::assertSame('Français', $result->items[0]->subjectName);
}
#[Test]
public function searchIsCaseInsensitive(): void
{
$this->seedData();
$query = new GetAllAssignmentsQuery(
tenantId: self::TENANT_ID,
search: 'jean',
);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
self::assertSame('Jean', $result->items[0]->teacherFirstName);
}
#[Test]
public function paginatesResults(): void
{
$this->seedData();
$query = new GetAllAssignmentsQuery(
tenantId: self::TENANT_ID,
page: 1,
limit: 1,
);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
self::assertSame(2, $result->total);
self::assertSame(1, $result->page);
self::assertSame(1, $result->limit);
self::assertSame(2, $result->totalPages());
}
#[Test]
public function returnsSecondPage(): void
{
$this->seedData();
$query = new GetAllAssignmentsQuery(
tenantId: self::TENANT_ID,
page: 2,
limit: 1,
);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
self::assertSame(2, $result->total);
self::assertSame(2, $result->page);
}
#[Test]
public function returnsEmptyWhenNoMatches(): void
{
$this->seedData();
$query = new GetAllAssignmentsQuery(
tenantId: self::TENANT_ID,
search: 'nonexistent',
);
$result = ($this->handler)($query);
self::assertCount(0, $result->items);
self::assertSame(0, $result->total);
}
#[Test]
public function excludesAssignmentsFromOtherTenants(): void
{
$this->seedData();
$query = new GetAllAssignmentsQuery(tenantId: self::OTHER_TENANT_ID);
$result = ($this->handler)($query);
self::assertCount(0, $result->items);
self::assertSame(0, $result->total);
}
#[Test]
public function handlesOrphanedAssignment(): void
{
// Create an assignment with no matching teacher/class/subject
$tenantId = TenantId::fromString(self::TENANT_ID);
$now = new DateTimeImmutable('2026-02-01 10:00:00');
$orphanedClass = SchoolClass::creer(
tenantId: $tenantId,
schoolId: SchoolId::generate(),
academicYearId: AcademicYearId::generate(),
name: new ClassName('Orphan Class'),
level: null,
capacity: null,
createdAt: $now,
);
$this->classRepository->save($orphanedClass);
$orphanedSubject = Subject::creer(
tenantId: $tenantId,
schoolId: SchoolId::generate(),
name: new SubjectName('Orphan Subject'),
code: new SubjectCode('ORPH'),
color: null,
createdAt: $now,
);
$this->subjectRepository->save($orphanedSubject);
// Teacher does NOT exist in userRepository
$orphanedTeacher = User::inviter(
email: new Email('orphan@example.com'),
role: Role::PROF,
tenantId: TenantId::fromString(self::OTHER_TENANT_ID), // Different tenant — won't be found
schoolName: 'Autre',
firstName: 'Ghost',
lastName: 'Teacher',
invitedAt: $now,
);
$this->userRepository->save($orphanedTeacher);
$assignment = TeacherAssignment::creer(
tenantId: $tenantId,
teacherId: $orphanedTeacher->id,
classId: $orphanedClass->id,
subjectId: $orphanedSubject->id,
academicYearId: AcademicYearId::generate(),
createdAt: $now,
);
$this->assignmentRepository->save($assignment);
$query = new GetAllAssignmentsQuery(tenantId: self::TENANT_ID);
$result = ($this->handler)($query);
// Assignment should appear with empty teacher names (orphaned reference logged as warning)
self::assertCount(1, $result->items);
self::assertSame('', $result->items[0]->teacherFirstName);
self::assertSame('', $result->items[0]->teacherLastName);
}
#[Test]
public function clampsInvalidPageToOne(): void
{
$query = new GetAllAssignmentsQuery(
tenantId: self::TENANT_ID,
page: -1,
);
self::assertSame(1, $query->page);
}
#[Test]
public function clampsExcessiveLimitToMax(): void
{
$query = new GetAllAssignmentsQuery(
tenantId: self::TENANT_ID,
limit: 999,
);
self::assertSame(100, $query->limit);
}
private function seedData(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$schoolId = SchoolId::generate();
$academicYearId = AcademicYearId::generate();
$now = new DateTimeImmutable('2026-02-01 10:00:00');
// Create teachers
$teacher1 = User::inviter(
email: new Email('teacher1@example.com'),
role: Role::PROF,
tenantId: $tenantId,
schoolName: 'École Alpha',
firstName: 'Jean',
lastName: 'Dupont',
invitedAt: $now,
);
$this->userRepository->save($teacher1);
$teacher2 = User::inviter(
email: new Email('teacher2@example.com'),
role: Role::PROF,
tenantId: $tenantId,
schoolName: 'École Alpha',
firstName: 'Marie',
lastName: 'Martin',
invitedAt: $now,
);
$this->userRepository->save($teacher2);
// Create classes
$class1 = SchoolClass::creer(
tenantId: $tenantId,
schoolId: $schoolId,
academicYearId: $academicYearId,
name: new ClassName('6ème A'),
level: SchoolLevel::SIXIEME,
capacity: 30,
createdAt: $now,
);
$this->classRepository->save($class1);
// Create subjects
$subject1 = Subject::creer(
tenantId: $tenantId,
schoolId: $schoolId,
name: new SubjectName('Mathématiques'),
code: new SubjectCode('MATH'),
color: null,
createdAt: $now,
);
$this->subjectRepository->save($subject1);
$subject2 = Subject::creer(
tenantId: $tenantId,
schoolId: $schoolId,
name: new SubjectName('Français'),
code: new SubjectCode('FR'),
color: null,
createdAt: $now,
);
$this->subjectRepository->save($subject2);
// Create assignments
$assignment1 = TeacherAssignment::creer(
tenantId: $tenantId,
teacherId: $teacher1->id,
classId: $class1->id,
subjectId: $subject1->id,
academicYearId: $academicYearId,
createdAt: $now,
);
$this->assignmentRepository->save($assignment1);
$assignment2 = TeacherAssignment::creer(
tenantId: $tenantId,
teacherId: $teacher2->id,
classId: $class1->id,
subjectId: $subject2->id,
academicYearId: $academicYearId,
createdAt: $now,
);
$this->assignmentRepository->save($assignment2);
}
}

View File

@@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Query\GetClasses;
use App\Administration\Application\Query\GetClasses\GetClassesHandler;
use App\Administration\Application\Query\GetClasses\GetClassesQuery;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassName;
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class GetClassesHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440010';
private InMemoryClassRepository $classRepository;
private GetClassesHandler $handler;
protected function setUp(): void
{
$this->classRepository = new InMemoryClassRepository();
$this->handler = new GetClassesHandler($this->classRepository);
}
#[Test]
public function returnsAllActiveClassesForTenantAndYear(): void
{
$this->seedClasses();
$query = new GetClassesQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
);
$result = ($this->handler)($query);
self::assertCount(3, $result->items);
self::assertSame(3, $result->total);
self::assertSame(1, $result->page);
self::assertSame(30, $result->limit);
}
#[Test]
public function filtersClassesByName(): void
{
$this->seedClasses();
$query = new GetClassesQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
search: '6ème',
);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
self::assertSame('6ème A', $result->items[0]->name);
}
#[Test]
public function filtersClassesByLevel(): void
{
$this->seedClasses();
$query = new GetClassesQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
search: SchoolLevel::CM2->value,
);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
self::assertSame('CM2 B', $result->items[0]->name);
}
#[Test]
public function searchIsCaseInsensitive(): void
{
$this->seedClasses();
$query = new GetClassesQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
search: 'cm2',
);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
}
#[Test]
public function paginatesResults(): void
{
$this->seedClasses();
$query = new GetClassesQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_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->seedClasses();
$query = new GetClassesQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_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 returnsEmptyWhenNoMatches(): void
{
$this->seedClasses();
$query = new GetClassesQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
search: 'nonexistent',
);
$result = ($this->handler)($query);
self::assertCount(0, $result->items);
self::assertSame(0, $result->total);
}
#[Test]
public function clampsInvalidPageToOne(): void
{
$this->seedClasses();
$query = new GetClassesQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
page: -1,
);
self::assertSame(1, $query->page);
}
#[Test]
public function clampsExcessiveLimitToMax(): void
{
$query = new GetClassesQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
limit: 999,
);
self::assertSame(100, $query->limit);
}
private function seedClasses(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$schoolId = SchoolId::generate();
$academicYearId = AcademicYearId::fromString(self::ACADEMIC_YEAR_ID);
$now = new DateTimeImmutable('2026-02-01 10:00:00');
$this->classRepository->save(SchoolClass::creer(
tenantId: $tenantId,
schoolId: $schoolId,
academicYearId: $academicYearId,
name: new ClassName('6ème A'),
level: SchoolLevel::SIXIEME,
capacity: 30,
createdAt: $now,
));
$this->classRepository->save(SchoolClass::creer(
tenantId: $tenantId,
schoolId: $schoolId,
academicYearId: $academicYearId,
name: new ClassName('CM2 B'),
level: SchoolLevel::CM2,
capacity: 25,
createdAt: $now,
));
$this->classRepository->save(SchoolClass::creer(
tenantId: $tenantId,
schoolId: $schoolId,
academicYearId: $academicYearId,
name: new ClassName('CP Alpha'),
level: SchoolLevel::CP,
capacity: 20,
createdAt: $now,
));
}
}

View File

@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Query\GetSubjects;
use App\Administration\Application\Query\GetSubjects\GetSubjectsHandler;
use App\Administration\Application\Query\GetSubjects\GetSubjectsQuery;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Model\Subject\Subject;
use App\Administration\Domain\Model\Subject\SubjectCode;
use App\Administration\Domain\Model\Subject\SubjectColor;
use App\Administration\Domain\Model\Subject\SubjectName;
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySubjectRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class GetSubjectsHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440020';
private InMemorySubjectRepository $subjectRepository;
private GetSubjectsHandler $handler;
protected function setUp(): void
{
$this->subjectRepository = new InMemorySubjectRepository();
$this->handler = new GetSubjectsHandler($this->subjectRepository);
}
#[Test]
public function returnsAllActiveSubjectsForTenantAndSchool(): void
{
$this->seedSubjects();
$query = new GetSubjectsQuery(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
);
$result = ($this->handler)($query);
self::assertCount(3, $result->items);
self::assertSame(3, $result->total);
self::assertSame(1, $result->page);
self::assertSame(30, $result->limit);
}
#[Test]
public function filtersSubjectsByName(): void
{
$this->seedSubjects();
$query = new GetSubjectsQuery(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
search: 'Mathématiques',
);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
self::assertSame('Mathématiques', $result->items[0]->name);
}
#[Test]
public function filtersSubjectsByCode(): void
{
$this->seedSubjects();
$query = new GetSubjectsQuery(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
search: 'FR',
);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
self::assertSame('FR', $result->items[0]->code);
}
#[Test]
public function searchIsCaseInsensitive(): void
{
$this->seedSubjects();
$query = new GetSubjectsQuery(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
search: 'math',
);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
}
#[Test]
public function paginatesResults(): void
{
$this->seedSubjects();
$query = new GetSubjectsQuery(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_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->seedSubjects();
$query = new GetSubjectsQuery(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_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 returnsEmptyWhenNoMatches(): void
{
$this->seedSubjects();
$query = new GetSubjectsQuery(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
search: 'nonexistent',
);
$result = ($this->handler)($query);
self::assertCount(0, $result->items);
self::assertSame(0, $result->total);
}
#[Test]
public function clampsInvalidPageToOne(): void
{
$query = new GetSubjectsQuery(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
page: -1,
);
self::assertSame(1, $query->page);
}
#[Test]
public function clampsExcessiveLimitToMax(): void
{
$query = new GetSubjectsQuery(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
limit: 999,
);
self::assertSame(100, $query->limit);
}
private function seedSubjects(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$schoolId = SchoolId::fromString(self::SCHOOL_ID);
$now = new DateTimeImmutable('2026-02-01 10:00:00');
$this->subjectRepository->save(Subject::creer(
tenantId: $tenantId,
schoolId: $schoolId,
name: new SubjectName('Mathématiques'),
code: new SubjectCode('MATH'),
color: new SubjectColor('#3B82F6'),
createdAt: $now,
));
$this->subjectRepository->save(Subject::creer(
tenantId: $tenantId,
schoolId: $schoolId,
name: new SubjectName('Français'),
code: new SubjectCode('FR'),
color: new SubjectColor('#EF4444'),
createdAt: $now,
));
$this->subjectRepository->save(Subject::creer(
tenantId: $tenantId,
schoolId: $schoolId,
name: new SubjectName('Histoire-Géo'),
code: new SubjectCode('HG'),
color: new SubjectColor('#10B981'),
createdAt: $now,
));
}
}

View File

@@ -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'),

View File

@@ -96,7 +96,7 @@ test.describe('Activation with Parent-Child Auto-Link', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);

View File

@@ -0,0 +1,339 @@
import { test, expect } from '@playwright/test';
import { execSync } from 'child_process';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
const urlMatch = baseUrl.match(/:(\d+)$/);
const PORT = urlMatch ? urlMatch[1] : '4173';
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
const ADMIN_EMAIL = 'e2e-search-admin@example.com';
const ADMIN_PASSWORD = 'SearchTest123';
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
test.describe('Admin Search & Pagination (Story 2.8b)', () => {
test.beforeAll(async () => {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
{ encoding: 'utf-8' }
);
});
async function loginAsAdmin(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
// ============================================================================
// USERS PAGE - Search & Pagination
// ============================================================================
test.describe('Users Page', () => {
test('displays search input on users page', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
const searchInput = page.locator('input[type="search"]');
await expect(searchInput).toBeVisible({ timeout: 10000 });
await expect(searchInput).toHaveAttribute('placeholder', /rechercher/i);
});
test('search filters users and shows results', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
// Wait for initial load
await expect(
page.locator('.users-table, .empty-state')
).toBeVisible({ timeout: 10000 });
const searchInput = page.locator('input[type="search"]');
await searchInput.fill('e2e-search-admin');
// Wait for debounce + API response
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
// Should find our test admin user
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
});
test('search with no results shows empty state', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
await expect(
page.locator('.users-table, .empty-state')
).toBeVisible({ timeout: 10000 });
const searchInput = page.locator('input[type="search"]');
await searchInput.fill('zzz-nonexistent-user-xyz');
// Wait for debounce + API response
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
// Should show "Aucun résultat" empty state
await expect(page.locator('.empty-state')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.empty-state')).toContainText(/aucun résultat/i);
});
test('search term is synced to URL', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
await expect(
page.locator('.users-table, .empty-state')
).toBeVisible({ timeout: 10000 });
const searchInput = page.locator('input[type="search"]');
await searchInput.fill('test-search');
// Wait for debounce
await page.waitForTimeout(500);
// URL should contain search param
await expect(page).toHaveURL(/[?&]search=test-search/);
});
test('search term from URL is restored on page load', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users?search=admin`);
await page.waitForLoadState('networkidle');
const searchInput = page.locator('input[type="search"]');
await expect(searchInput).toHaveValue('admin');
});
test('clear search button resets results', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
await expect(
page.locator('.users-table, .empty-state')
).toBeVisible({ timeout: 10000 });
const searchInput = page.locator('input[type="search"]');
await searchInput.fill('test');
// Wait for debounce
await page.waitForTimeout(500);
// Clear button should appear
const clearButton = page.locator('.search-clear');
await expect(clearButton).toBeVisible();
await clearButton.click();
// Search input should be empty
await expect(searchInput).toHaveValue('');
});
test('Escape key clears search', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
await expect(
page.locator('.users-table, .empty-state')
).toBeVisible({ timeout: 10000 });
const searchInput = page.locator('input[type="search"]');
await searchInput.fill('test');
await page.waitForTimeout(500);
await searchInput.press('Escape');
await expect(searchInput).toHaveValue('');
});
test('filters work together with search', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
await expect(
page.locator('.users-table, .empty-state')
).toBeVisible({ timeout: 10000 });
// Apply a role filter
await page.locator('#filter-role').selectOption('ROLE_ADMIN');
await page.getByRole('button', { name: /filtrer/i }).click();
await page.waitForLoadState('networkidle');
// Then search
const searchInput = page.locator('input[type="search"]');
await searchInput.fill('e2e-search');
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
// URL should have both params
await expect(page).toHaveURL(/search=e2e-search/);
});
});
// ============================================================================
// CLASSES PAGE - Search & Pagination
// ============================================================================
test.describe('Classes Page', () => {
test('displays search input on classes page', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/classes`);
await page.waitForLoadState('networkidle');
const searchInput = page.locator('input[type="search"]');
await expect(searchInput).toBeVisible({ timeout: 10000 });
await expect(searchInput).toHaveAttribute('placeholder', /rechercher/i);
});
test('search with no results shows empty state', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/classes`);
await page.waitForLoadState('networkidle');
// Wait for initial load
await expect(
page.locator('.classes-grid, .empty-state, .loading-state')
).toBeVisible({ timeout: 10000 });
const searchInput = page.locator('input[type="search"]');
await searchInput.fill('zzz-nonexistent-class-xyz');
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
await expect(page.locator('.empty-state')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.empty-state')).toContainText(/aucun résultat/i);
});
test('search term is synced to URL', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/classes`);
await page.waitForLoadState('networkidle');
await expect(
page.locator('.classes-grid, .empty-state, .loading-state')
).toBeVisible({ timeout: 10000 });
const searchInput = page.locator('input[type="search"]');
await searchInput.fill('6eme');
await page.waitForTimeout(500);
await expect(page).toHaveURL(/[?&]search=6eme/);
});
});
// ============================================================================
// SUBJECTS PAGE - Search & Pagination
// ============================================================================
test.describe('Subjects Page', () => {
test('displays search input on subjects page', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/subjects`);
await page.waitForLoadState('networkidle');
const searchInput = page.locator('input[type="search"]');
await expect(searchInput).toBeVisible({ timeout: 10000 });
await expect(searchInput).toHaveAttribute('placeholder', /rechercher/i);
});
test('search with no results shows empty state', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/subjects`);
await page.waitForLoadState('networkidle');
await expect(
page.locator('.subjects-grid, .empty-state, .loading-state')
).toBeVisible({ timeout: 10000 });
const searchInput = page.locator('input[type="search"]');
await searchInput.fill('zzz-nonexistent-subject-xyz');
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
await expect(page.locator('.empty-state')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.empty-state')).toContainText(/aucun résultat/i);
});
test('search term is synced to URL', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/subjects`);
await page.waitForLoadState('networkidle');
await expect(
page.locator('.subjects-grid, .empty-state, .loading-state')
).toBeVisible({ timeout: 10000 });
const searchInput = page.locator('input[type="search"]');
await searchInput.fill('MATH');
await page.waitForTimeout(500);
await expect(page).toHaveURL(/[?&]search=MATH/);
});
});
// ============================================================================
// ASSIGNMENTS PAGE - Search & Pagination
// ============================================================================
test.describe('Assignments Page', () => {
test('displays search input on assignments page', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/assignments`);
await page.waitForLoadState('networkidle');
const searchInput = page.locator('input[type="search"]');
await expect(searchInput).toBeVisible({ timeout: 15000 });
await expect(searchInput).toHaveAttribute('placeholder', /rechercher/i);
});
test('search with no results shows empty state', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/assignments`);
await page.waitForLoadState('networkidle');
// Wait for initial load
await expect(
page.locator('.table-container, .empty-state, .loading-state')
).toBeVisible({ timeout: 15000 });
const searchInput = page.locator('input[type="search"]');
await searchInput.fill('zzz-nonexistent-teacher-xyz');
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
await expect(page.locator('.empty-state')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.empty-state')).toContainText(/aucun résultat/i);
});
test('search term is synced to URL', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/assignments`);
await page.waitForLoadState('networkidle');
await expect(
page.locator('.table-container, .empty-state, .loading-state')
).toBeVisible({ timeout: 15000 });
const searchInput = page.locator('input[type="search"]');
await searchInput.fill('Dupont');
await page.waitForTimeout(500);
await expect(page).toHaveURL(/[?&]search=Dupont/);
});
});
});

View File

@@ -37,7 +37,7 @@ async function loginAsAdmin(page: Page) {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
@@ -132,7 +132,7 @@ test.describe('Child Selector', () => {
await page.locator('#email').fill(PARENT_EMAIL);
await page.locator('#password').fill(PARENT_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -38,7 +38,7 @@ test.describe('Admin Class Detail Page [P1]', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -42,7 +42,7 @@ test.describe('Classes Management (Story 2.1)', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -427,7 +427,7 @@ test.describe('Dashboard', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -94,7 +94,7 @@ test.describe('Guardian Management', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -59,7 +59,7 @@ test.describe('Login Flow', () => {
// Submit and wait for navigation to dashboard
await Promise.all([
page.waitForURL('/dashboard', { timeout: 10000 }),
page.waitForURL('/dashboard', { timeout: 30000 }),
submitButton.click()
]);
@@ -350,7 +350,7 @@ test.describe('Login Flow', () => {
await submitButton.click();
// Should redirect to dashboard (successful login)
await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 });
await expect(page).toHaveURL(/\/dashboard/, { timeout: 30000 });
});
test('user cannot login on different tenant', async ({ page }) => {
@@ -383,7 +383,7 @@ test.describe('Login Flow', () => {
await submitButton.click();
// Should redirect to dashboard (successful login)
await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 });
await expect(page).toHaveURL(/\/dashboard/, { timeout: 30000 });
});
});
});

View File

@@ -46,7 +46,7 @@ test.describe('Pedagogy - Grading Mode Configuration (Story 2.4)', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -39,7 +39,7 @@ test.describe('Periods Management (Story 2.3)', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -0,0 +1,212 @@
import { test, expect } from '@playwright/test';
import { execSync } from 'child_process';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Extract port from PLAYWRIGHT_BASE_URL or use default (4173 matches playwright.config.ts)
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
const urlMatch = baseUrl.match(/:(\d+)$/);
const PORT = urlMatch ? urlMatch[1] : '4173';
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
// Test credentials per role
const ADMIN_EMAIL = 'e2e-rbac-admin@example.com';
const ADMIN_PASSWORD = 'RbacAdmin123';
const TEACHER_EMAIL = 'e2e-rbac-teacher@example.com';
const TEACHER_PASSWORD = 'RbacTeacher123';
const PARENT_EMAIL = 'e2e-rbac-parent@example.com';
const PARENT_PASSWORD = 'RbacParent123';
test.describe('Role-Based Access Control [P0]', () => {
test.describe.configure({ mode: 'serial' });
test.beforeAll(async () => {
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
// Create admin user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
{ encoding: 'utf-8' }
);
// Create teacher user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
{ encoding: 'utf-8' }
);
// Create parent user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${PARENT_EMAIL} --password=${PARENT_PASSWORD} --role=ROLE_PARENT 2>&1`,
{ encoding: 'utf-8' }
);
});
async function loginAs(
page: import('@playwright/test').Page,
email: string,
password: string
) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(email);
await page.locator('#password').fill(password);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
// ============================================================================
// Admin access - should have access to all /admin pages
// ============================================================================
test.describe('Admin Access', () => {
test('[P0] admin user can access /admin/users page', async ({ page }) => {
await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD);
await page.goto(`${ALPHA_URL}/admin/users`);
// Admin should see the users management page
await expect(page).toHaveURL(/\/admin\/users/);
await expect(
page.locator('.users-table, .empty-state')
).toBeVisible({ timeout: 10000 });
});
test('[P0] admin user can access /admin/classes page', async ({ page }) => {
await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD);
await page.goto(`${ALPHA_URL}/admin/classes`);
await expect(page).toHaveURL(/\/admin\/classes/);
await expect(
page.getByRole('heading', { name: /gestion des classes/i })
).toBeVisible({ timeout: 10000 });
});
test('[P0] admin user can access /admin/pedagogy page', async ({ page }) => {
await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
await expect(page).toHaveURL(/\/admin\/pedagogy/);
});
test('[P0] admin user can access /admin page', async ({ page }) => {
await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD);
await page.goto(`${ALPHA_URL}/admin`);
await expect(page).toHaveURL(/\/admin/);
await expect(
page.getByRole('heading', { name: /administration/i })
).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// Teacher access - should NOT have access to /admin pages
// ============================================================================
test.describe('Teacher Access Restrictions', () => {
test('[P0] teacher cannot access /admin/users page', async ({ page }) => {
await loginAs(page, TEACHER_EMAIL, TEACHER_PASSWORD);
await page.goto(`${ALPHA_URL}/admin/users`);
// Admin guard redirects non-admin users to /dashboard
await page.waitForURL(/\/dashboard/, { timeout: 30000 });
expect(page.url()).toContain('/dashboard');
});
test('[P0] teacher cannot access /admin page', async ({ page }) => {
await loginAs(page, TEACHER_EMAIL, TEACHER_PASSWORD);
await page.goto(`${ALPHA_URL}/admin`);
// Admin guard redirects non-admin users to /dashboard
await page.waitForURL(/\/dashboard/, { timeout: 30000 });
expect(page.url()).toContain('/dashboard');
});
});
// ============================================================================
// Parent access - should NOT have access to /admin pages
// ============================================================================
test.describe('Parent Access Restrictions', () => {
test('[P0] parent cannot access /admin/users page', async ({ page }) => {
await loginAs(page, PARENT_EMAIL, PARENT_PASSWORD);
await page.goto(`${ALPHA_URL}/admin/users`);
// Admin guard redirects non-admin users to /dashboard
await page.waitForURL(/\/dashboard/, { timeout: 30000 });
expect(page.url()).toContain('/dashboard');
});
test('[P0] parent cannot access /admin/classes page', async ({ page }) => {
await loginAs(page, PARENT_EMAIL, PARENT_PASSWORD);
await page.goto(`${ALPHA_URL}/admin/classes`);
// Admin guard redirects non-admin users to /dashboard
await page.waitForURL(/\/dashboard/, { timeout: 30000 });
expect(page.url()).toContain('/dashboard');
});
test('[P0] parent cannot access /admin page', async ({ page }) => {
await loginAs(page, PARENT_EMAIL, PARENT_PASSWORD);
await page.goto(`${ALPHA_URL}/admin`);
// Admin guard redirects non-admin users to /dashboard
await page.waitForURL(/\/dashboard/, { timeout: 30000 });
expect(page.url()).toContain('/dashboard');
});
});
// ============================================================================
// Unauthenticated user - should be redirected to /login
// ============================================================================
test.describe('Unauthenticated Access', () => {
test('[P0] unauthenticated user is redirected from /settings/sessions to /login', async ({ page }) => {
// Clear any existing session
await page.context().clearCookies();
await page.goto(`${ALPHA_URL}/settings/sessions`);
// Should be redirected to login
await expect(page).toHaveURL(/\/login/, { timeout: 10000 });
});
test('[P0] unauthenticated user is redirected from /admin/users to /login', async ({ page }) => {
await page.context().clearCookies();
await page.goto(`${ALPHA_URL}/admin/users`);
// Should be redirected away from /admin/users (to /login or /dashboard)
await page.waitForURL((url) => !url.toString().includes('/admin/users'), { timeout: 10000 });
expect(page.url()).not.toContain('/admin/users');
});
});
// ============================================================================
// Navigation reflects role permissions
// ============================================================================
test.describe('Navigation Reflects Permissions', () => {
test('[P0] admin layout shows admin navigation links', async ({ page }) => {
await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD);
await page.goto(`${ALPHA_URL}/admin`);
// Admin layout should show navigation links (scoped to header nav to avoid action cards)
const nav = page.locator('.header-nav');
await expect(nav.getByRole('link', { name: 'Utilisateurs' })).toBeVisible({ timeout: 15000 });
await expect(nav.getByRole('link', { name: 'Classes' })).toBeVisible();
});
test('[P0] teacher sees dashboard without admin navigation', async ({ page }) => {
await loginAs(page, TEACHER_EMAIL, TEACHER_PASSWORD);
// Teacher should be on dashboard
await expect(page).toHaveURL(/\/dashboard/);
// Teacher should not see admin-specific navigation in the dashboard layout
// The dashboard header should not have admin links like "Utilisateurs"
const adminUsersLink = page.locator('.header-nav').getByRole('link', { name: 'Utilisateurs' });
await expect(adminUsersLink).not.toBeVisible();
});
});
});

View File

@@ -49,7 +49,7 @@ async function login(page: import('@playwright/test').Page, email: string) {
await page.locator('#email').fill(email);
await page.locator('#password').fill(TEST_PASSWORD);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(getTenantUrl('/dashboard'), { timeout: 10000 });
await page.waitForURL(getTenantUrl('/dashboard'), { timeout: 30000 });
}
test.describe('Sessions Management', () => {

View File

@@ -57,7 +57,7 @@ test.describe('Settings Page [P1]', () => {
await page.locator('#email').fill(email);
await page.locator('#password').fill(USER_PASSWORD);
await Promise.all([
page.waitForURL(getTenantUrl('/dashboard'), { timeout: 10000 }),
page.waitForURL(getTenantUrl('/dashboard'), { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
@@ -197,6 +197,6 @@ test.describe('Settings Page [P1]', () => {
// Click the logo button
await page.locator('.logo-button').click();
await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 });
await expect(page).toHaveURL(/\/dashboard/, { timeout: 30000 });
});
});

View File

@@ -86,7 +86,7 @@ test.describe('Student Management', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
@@ -100,11 +100,11 @@ test.describe('Student Management', () => {
* Waiting for one of these ensures the component is interactive.
*/
async function waitForGuardianSection(page: import('@playwright/test').Page) {
await expect(page.locator('.guardian-section')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.guardian-section')).toBeVisible({ timeout: 15000 });
await expect(
page.getByText(/aucun parent\/tuteur lié/i)
.or(page.locator('.guardian-list'))
).toBeVisible({ timeout: 10000 });
).toBeVisible({ timeout: 15000 });
}
// ============================================================================
@@ -151,8 +151,20 @@ test.describe('Student Management', () => {
await waitForGuardianSection(page);
// Clean up guardians if any exist (cross-browser interference: parallel
// browser projects share the same DB, so another browser may have added
// guardians between our beforeAll cleanup and this test).
while (await page.locator('.guardian-item').count() > 0) {
const item = page.locator('.guardian-item').first();
await item.getByRole('button', { name: /retirer/i }).click();
await expect(item.getByText(/confirmer/i)).toBeVisible({ timeout: 5000 });
await item.getByRole('button', { name: /oui/i }).click();
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
await page.waitForTimeout(500);
}
// Should show the empty state
await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible();
await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible({ timeout: 15000 });
// The "add guardian" button should be visible
await expect(page.getByRole('button', { name: /ajouter un parent/i })).toBeVisible();
@@ -342,6 +354,12 @@ test.describe('Student Management', () => {
page.locator('.users-table, .empty-state')
).toBeVisible({ timeout: 10000 });
// Search for the student user (pagination may hide them beyond page 1)
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(STUDENT_EMAIL);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
// The student email should appear in the users table
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
const studentRow = page.locator('tr', { has: page.locator(`text=${STUDENT_EMAIL}`) });
@@ -355,6 +373,12 @@ test.describe('Student Management', () => {
// Wait for users table
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
// Search for the student user (pagination may hide them beyond page 1)
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(STUDENT_EMAIL);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
// Find the student row and verify role
const studentRow = page.locator('tr', { has: page.locator(`text=${STUDENT_EMAIL}`) });
await expect(studentRow).toContainText(/élève/i);

View File

@@ -42,7 +42,7 @@ test.describe('Subjects Management (Story 2.2)', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -49,7 +49,7 @@ async function loginAsAdmin(page: import('@playwright/test').Page) {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -51,7 +51,7 @@ test.describe('User Blocking Mid-Session [P1]', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
@@ -61,7 +61,7 @@ test.describe('User Blocking Mid-Session [P1]', () => {
await page.locator('#email').fill(TARGET_EMAIL);
await page.locator('#password').fill(TARGET_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
@@ -70,6 +70,12 @@ test.describe('User Blocking Mid-Session [P1]', () => {
await page.goto(`${ALPHA_URL}/admin/users`);
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
// Search for the target user (pagination may hide them beyond page 1)
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(TARGET_EMAIL);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) });
await expect(targetRow).toBeVisible();
@@ -93,6 +99,12 @@ test.describe('User Blocking Mid-Session [P1]', () => {
await page.goto(`${ALPHA_URL}/admin/users`);
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
// Search for the target user (pagination may hide them beyond page 1)
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(TARGET_EMAIL);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) });
await expect(targetRow).toBeVisible();
@@ -181,7 +193,7 @@ test.describe('User Blocking Mid-Session [P1]', () => {
await userPage.getByRole('button', { name: /se connecter/i }).click();
// Should redirect to dashboard (successful login)
await expect(userPage).toHaveURL(/\/dashboard/, { timeout: 10000 });
await expect(userPage).toHaveURL(/\/dashboard/, { timeout: 30000 });
} finally {
await userContext.close();
}
@@ -196,6 +208,12 @@ test.describe('User Blocking Mid-Session [P1]', () => {
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
// Search for the admin user (pagination may hide them beyond page 1)
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(ADMIN_EMAIL);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
// Find the admin's own row
const adminRow = page.locator('tr', { has: page.locator(`text=${ADMIN_EMAIL}`) });
await expect(adminRow).toBeVisible();

View File

@@ -41,7 +41,7 @@ test.describe('User Blocking', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
@@ -53,6 +53,12 @@ test.describe('User Blocking', () => {
// Wait for users table to load
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
// Search for the target user (pagination may hide them beyond page 1)
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(TARGET_EMAIL);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
// Find the target user row
const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) });
await expect(targetRow).toBeVisible();
@@ -86,6 +92,12 @@ test.describe('User Blocking', () => {
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
// Search for the target user (pagination may hide them beyond page 1)
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(TARGET_EMAIL);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
// Find the suspended target user row
const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) });
await expect(targetRow).toBeVisible();
@@ -111,6 +123,12 @@ test.describe('User Blocking', () => {
await page.goto(`${ALPHA_URL}/admin/users`);
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
// Search for the target user (pagination may hide them beyond page 1)
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(TARGET_EMAIL);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) });
await expect(async () => {
await targetRow.getByRole('button', { name: /bloquer/i }).click();
@@ -141,6 +159,12 @@ test.describe('User Blocking', () => {
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
// Search for the admin user (pagination may hide them beyond page 1)
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(ADMIN_EMAIL);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
// Find the admin's own row
const adminRow = page.locator('tr', { has: page.locator(`text=${ADMIN_EMAIL}`) });
await expect(adminRow).toBeVisible();

View File

@@ -34,7 +34,7 @@ test.describe('User Creation', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
@@ -71,6 +71,12 @@ test.describe('User Creation', () => {
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.alert-success')).toContainText(INVITED_EMAIL);
// Search for the newly created user (pagination may hide them beyond page 1)
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(INVITED_EMAIL);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
// Verify the user appears in the table
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
const newUserRow = page.locator('tr', { has: page.locator(`text=${INVITED_EMAIL}`) });

View File

@@ -80,7 +80,13 @@ export default tseslint.config(
HTMLDivElement: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
URLSearchParams: 'readonly'
URLSearchParams: 'readonly',
HTMLInputElement: 'readonly',
KeyboardEvent: 'readonly',
AbortController: 'readonly',
DOMException: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly'
}
},
plugins: {

View File

@@ -0,0 +1,167 @@
<script lang="ts">
let { currentPage, totalPages, onPageChange }: {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
} = $props();
let clampedPage = $derived(Math.max(1, Math.min(currentPage, Math.max(1, totalPages))));
let pages = $derived(computePages(clampedPage, totalPages));
function computePages(current: number, total: number): (number | '...')[] {
if (total <= 7) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const result: (number | '...')[] = [1];
if (current > 3) {
result.push('...');
}
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
for (let i = start; i <= end; i++) {
result.push(i);
}
if (current < total - 2) {
result.push('...');
}
result.push(total);
return result;
}
</script>
{#if totalPages > 1}
<nav class="pagination" aria-label="Pagination">
<button
class="pagination-btn"
disabled={clampedPage <= 1}
onclick={() => onPageChange(clampedPage - 1)}
aria-label="Page précédente"
>
← Précédent
</button>
<div class="pagination-pages">
{#each pages as p}
{#if p === '...'}
<span class="pagination-ellipsis" aria-hidden="true"></span>
{:else}
<button
class="pagination-page"
class:active={p === clampedPage}
onclick={() => onPageChange(p as number)}
aria-label="Page {p}"
aria-current={p === clampedPage ? 'page' : undefined}
>
{p}
</button>
{/if}
{/each}
</div>
<button
class="pagination-btn"
disabled={clampedPage >= totalPages}
onclick={() => onPageChange(clampedPage + 1)}
aria-label="Page suivante"
>
Suivant →
</button>
</nav>
{/if}
<style>
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem 0;
}
.pagination-btn {
padding: 0.5rem 1rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
background: white;
color: #374151;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s;
}
.pagination-btn:hover:not(:disabled) {
background: #f3f4f6;
border-color: #9ca3af;
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-pages {
display: flex;
align-items: center;
gap: 0.25rem;
}
.pagination-page {
min-width: 2.25rem;
height: 2.25rem;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
background: white;
color: #374151;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s;
}
.pagination-page:hover {
background: #f3f4f6;
border-color: #9ca3af;
}
.pagination-btn:focus-visible,
.pagination-page:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.pagination-page.active {
background: #3b82f6;
border-color: #3b82f6;
color: white;
}
.pagination-ellipsis {
min-width: 2.25rem;
height: 2.25rem;
display: flex;
align-items: center;
justify-content: center;
color: #6b7280;
font-size: 0.875rem;
}
@media (max-width: 640px) {
.pagination {
flex-wrap: wrap;
}
.pagination-btn {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
}
}
</style>

View File

@@ -0,0 +1,133 @@
<script lang="ts">
let { value = '', onSearch, placeholder = 'Rechercher...', debounceMs = 300 }: {
value?: string;
onSearch: (value: string) => void;
placeholder?: string;
debounceMs?: number;
} = $props();
let inputValue = $state(value);
let timeoutId: ReturnType<typeof setTimeout> | null = null;
$effect(() => {
inputValue = value;
});
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
inputValue = target.value;
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
onSearch(inputValue);
}, debounceMs);
}
function handleClear() {
inputValue = '';
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
onSearch('');
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
handleClear();
}
}
</script>
<div class="search-input-wrapper">
<svg class="search-icon" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clip-rule="evenodd" />
</svg>
<input
type="search"
class="search-input"
{placeholder}
value={inputValue}
oninput={handleInput}
onkeydown={handleKeydown}
aria-label={placeholder}
/>
{#if inputValue}
<button
class="search-clear"
onclick={handleClear}
aria-label="Effacer la recherche"
type="button"
>
</button>
{/if}
</div>
<style>
.search-input-wrapper {
position: relative;
display: flex;
align-items: center;
max-width: 24rem;
}
.search-icon {
position: absolute;
left: 0.75rem;
width: 1.25rem;
height: 1.25rem;
color: #9ca3af;
pointer-events: none;
}
.search-input {
width: 100%;
padding: 0.5rem 2.25rem 0.5rem 2.5rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
color: #374151;
background: white;
transition: border-color 0.15s, box-shadow 0.15s;
}
.search-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.search-input::placeholder {
color: #9ca3af;
}
/* Hide browser-default search clear button */
.search-input::-webkit-search-cancel-button {
-webkit-appearance: none;
}
.search-clear {
position: absolute;
right: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border: none;
border-radius: 50%;
background: #e5e7eb;
color: #6b7280;
font-size: 0.75rem;
cursor: pointer;
transition: background-color 0.15s;
}
.search-clear:hover {
background: #d1d5db;
color: #374151;
}
</style>

View File

@@ -1,7 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch } from '$lib/auth';
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
import { untrack } from 'svelte';
// Types
interface TeacherAssignment {
@@ -14,6 +18,10 @@
startDate: string;
endDate: string | null;
createdAt: string;
teacherFirstName: string | null;
teacherLastName: string | null;
className: string | null;
subjectName: string | null;
}
interface User {
@@ -48,6 +56,13 @@
let error = $state<string | null>(null);
let successMessage = $state<string | null>(null);
// Pagination & Search
let currentPage = $state(Number(page.url.searchParams.get('page')) || 1);
let searchTerm = $state(page.url.searchParams.get('search') ?? '');
let totalItems = $state(0);
const itemsPerPage = 30;
let totalPages = $derived(Math.ceil(totalItems / itemsPerPage));
// Create modal
let showCreateModal = $state(false);
let selectedTeacherId = $state('');
@@ -61,8 +76,8 @@
let isDeleting = $state(false);
// Load everything on mount
onMount(() => {
loadAll();
$effect(() => {
untrack(() => loadAll());
});
async function loadAll() {
@@ -71,11 +86,11 @@
error = null;
const apiUrl = getApiBaseUrl();
// Load reference data in parallel
// Load reference data and assignments in parallel
const [teachersRes, classesRes, subjectsRes] = await Promise.all([
authenticatedFetch(`${apiUrl}/users?role=ROLE_PROF`),
authenticatedFetch(`${apiUrl}/classes`),
authenticatedFetch(`${apiUrl}/subjects`)
authenticatedFetch(`${apiUrl}/users?role=ROLE_PROF&itemsPerPage=100`),
authenticatedFetch(`${apiUrl}/classes?itemsPerPage=100`),
authenticatedFetch(`${apiUrl}/subjects?itemsPerPage=100`)
]);
if (!teachersRes.ok) throw new Error('Erreur lors du chargement des enseignants');
@@ -92,7 +107,6 @@
classes = extractCollection(classesData);
subjects = extractCollection(subjectsData);
// Load assignments for each class in parallel
await loadAssignments();
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
@@ -101,28 +115,29 @@
}
}
let assignmentsAbortController: AbortController | null = null;
async function loadAssignments() {
assignmentsAbortController?.abort();
const controller = new AbortController();
assignmentsAbortController = controller;
const apiUrl = getApiBaseUrl();
const params = new URLSearchParams();
params.set('page', String(currentPage));
params.set('itemsPerPage', String(itemsPerPage));
if (searchTerm) params.set('search', searchTerm);
const response = await authenticatedFetch(`${apiUrl}/teacher-assignments?${params.toString()}`, { signal: controller.signal });
if (classes.length === 0) {
assignments = [];
return;
if (controller.signal.aborted) return;
if (!response.ok) {
throw new Error('Erreur lors du chargement des affectations');
}
const results = await Promise.all(
classes.map(async (cls) => {
try {
const res = await authenticatedFetch(`${apiUrl}/classes/${cls.id}/teachers`);
if (!res.ok) return [];
const data = await res.json();
return extractCollection(data) as TeacherAssignment[];
} catch {
return [];
}
})
);
assignments = results.flat();
const data = await response.json();
assignments = data['hydra:member'] ?? data['member'] ?? [];
totalItems = data['hydra:totalItems'] ?? data['totalItems'] ?? assignments.length;
}
function extractCollection<T>(data: Record<string, unknown>): T[] {
@@ -134,26 +149,44 @@
return [];
}
function getTeacherName(teacherId: string): string {
const teacher = teachers.find((t) => t.id === teacherId);
return teacher ? `${teacher.firstName} ${teacher.lastName}` : teacherId;
}
function getClassName(classId: string): string {
const cls = classes.find((c) => c.id === classId);
return cls ? cls.name : classId;
}
function getSubjectName(subjectId: string): string {
const subject = subjects.find((s) => s.id === subjectId);
return subject ? subject.name : subjectId;
}
function getSubjectColor(subjectId: string): string | null {
const subject = subjects.find((s) => s.id === subjectId);
return subject?.color ?? null;
}
function updateUrl() {
const params = new URLSearchParams();
if (currentPage > 1) params.set('page', String(currentPage));
if (searchTerm) params.set('search', searchTerm);
const query = params.toString();
goto(`?${query}`, { replaceState: true, noScroll: true, keepFocus: true });
}
function handleSearch(value: string) {
searchTerm = value;
currentPage = 1;
updateUrl();
reloadAssignments();
}
function handlePageChange(newPage: number) {
currentPage = newPage;
updateUrl();
reloadAssignments();
}
async function reloadAssignments() {
try {
isLoading = true;
error = null;
await loadAssignments();
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isLoading = false;
}
}
function openCreateModal() {
showCreateModal = true;
selectedTeacherId = '';
@@ -194,7 +227,7 @@
successMessage = 'Affectation créée avec succès';
closeCreateModal();
await loadAssignments();
await reloadAssignments();
globalThis.setTimeout(() => {
successMessage = null;
}, 3000);
@@ -237,7 +270,7 @@
successMessage = 'Affectation retirée avec succès';
closeDeleteModal();
await loadAssignments();
await reloadAssignments();
globalThis.setTimeout(() => {
successMessage = null;
}, 3000);
@@ -247,9 +280,6 @@
isDeleting = false;
}
}
// Only show active assignments
const activeAssignments = $derived(assignments.filter((a) => a.status === 'active'));
</script>
<svelte:head>
@@ -282,17 +312,29 @@
</div>
{/if}
<SearchInput
value={searchTerm}
onSearch={handleSearch}
placeholder="Rechercher par enseignant, classe, matière..."
/>
{#if isLoading}
<div class="loading-state">
<div class="loading-state" aria-live="polite" role="status">
<div class="spinner"></div>
<p>Chargement des affectations...</p>
</div>
{:else if activeAssignments.length === 0}
{:else if assignments.length === 0}
<div class="empty-state">
<span class="empty-icon">&#x1F4CB;</span>
{#if searchTerm}
<h2>Aucun résultat</h2>
<p>Aucune affectation ne correspond à votre recherche</p>
<button class="btn-secondary" onclick={() => handleSearch('')}>Effacer la recherche</button>
{:else}
<h2>Aucune affectation</h2>
<p>Commencez par affecter un enseignant à une classe et une matière</p>
<button class="btn-primary" onclick={openCreateModal}>Nouvelle affectation</button>
{/if}
</div>
{:else}
<div class="table-container">
@@ -308,22 +350,22 @@
</tr>
</thead>
<tbody>
{#each activeAssignments as assignment (assignment.id)}
{#each assignments as assignment (assignment.id)}
<tr>
<td class="teacher-cell">
<span class="teacher-name">{getTeacherName(assignment.teacherId)}</span>
<span class="teacher-name">{assignment.teacherFirstName} {assignment.teacherLastName}</span>
</td>
<td>{getClassName(assignment.classId)}</td>
<td>{assignment.className}</td>
<td>
{#if getSubjectColor(assignment.subjectId)}
<span
class="subject-badge"
style="background-color: {getSubjectColor(assignment.subjectId)}; color: white"
>
{getSubjectName(assignment.subjectId)}
{assignment.subjectName}
</span>
{:else}
{getSubjectName(assignment.subjectId)}
{assignment.subjectName}
{/if}
</td>
<td>
@@ -345,6 +387,7 @@
</tbody>
</table>
</div>
<Pagination {currentPage} {totalPages} onPageChange={handlePageChange} />
{/if}
</div>
@@ -449,9 +492,9 @@
<div class="modal-body">
<p id="delete-modal-description">
Retirer <strong>{getTeacherName(assignmentToDelete.teacherId)}</strong>
de <strong>{getSubjectName(assignmentToDelete.subjectId)}</strong>
en <strong>{getClassName(assignmentToDelete.classId)}</strong> ?
Retirer <strong>{assignmentToDelete.teacherFirstName} {assignmentToDelete.teacherLastName}</strong>
de <strong>{assignmentToDelete.subjectName}</strong>
en <strong>{assignmentToDelete.className}</strong> ?
</p>
<p class="delete-warning">
Les notes existantes seront conservées, mais l'enseignant ne pourra plus en ajouter.

View File

@@ -1,8 +1,12 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch } from '$lib/auth';
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
import { SCHOOL_LEVEL_OPTIONS } from '$lib/constants/schoolLevels';
import { untrack } from 'svelte';
// Types
interface SchoolClass {
@@ -23,6 +27,13 @@
let showDeleteModal = $state(false);
let classToDelete = $state<SchoolClass | null>(null);
// Pagination & Search
let currentPage = $state(Number(page.url.searchParams.get('page')) || 1);
let searchTerm = $state(page.url.searchParams.get('search') ?? '');
let totalItems = $state(0);
const itemsPerPage = 30;
let totalPages = $derived(Math.ceil(totalItems / itemsPerPage));
// Form state
let newClassName = $state('');
let newClassLevel = $state<string | null>(null);
@@ -31,32 +42,69 @@
let isDeleting = $state(false);
// Load classes on mount
let loadAbortController: AbortController | null = null;
$effect(() => {
loadClasses();
untrack(() => loadClasses());
});
async function loadClasses() {
loadAbortController?.abort();
const controller = new AbortController();
loadAbortController = controller;
try {
isLoading = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/classes`);
const params = new URLSearchParams();
params.set('page', String(currentPage));
params.set('itemsPerPage', String(itemsPerPage));
if (searchTerm) params.set('search', searchTerm);
const url = `${apiUrl}/classes?${params.toString()}`;
const response = await authenticatedFetch(url, { signal: controller.signal });
if (controller.signal.aborted) return;
if (!response.ok) {
throw new Error('Erreur lors du chargement des classes');
}
const data = await response.json();
// API Platform peut retourner hydra:member, member, ou un tableau direct
classes = data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []);
totalItems = data['hydra:totalItems'] ?? data['totalItems'] ?? classes.length;
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return;
error = e instanceof Error ? e.message : 'Erreur inconnue';
// Use demo data for now
classes = [];
totalItems = 0;
} finally {
if (!controller.signal.aborted) {
isLoading = false;
}
}
}
function updateUrl() {
const params = new URLSearchParams();
if (currentPage > 1) params.set('page', String(currentPage));
if (searchTerm) params.set('search', searchTerm);
const query = params.toString();
goto(`?${query}`, { replaceState: true, noScroll: true, keepFocus: true });
}
function handleSearch(value: string) {
searchTerm = value;
currentPage = 1;
updateUrl();
loadClasses();
}
function handlePageChange(newPage: number) {
currentPage = newPage;
updateUrl();
loadClasses();
}
async function handleCreateClass() {
if (!newClassName.trim()) return;
@@ -165,17 +213,29 @@
</div>
{/if}
<SearchInput
value={searchTerm}
onSearch={handleSearch}
placeholder="Rechercher par nom, niveau..."
/>
{#if isLoading}
<div class="loading-state">
<div class="loading-state" aria-live="polite" role="status">
<div class="spinner"></div>
<p>Chargement des classes...</p>
</div>
{:else if classes.length === 0}
<div class="empty-state">
<span class="empty-icon">🏫</span>
{#if searchTerm}
<h2>Aucun résultat</h2>
<p>Aucune classe ne correspond à votre recherche</p>
<button class="btn-secondary" onclick={() => handleSearch('')}>Effacer la recherche</button>
{:else}
<h2>Aucune classe</h2>
<p>Commencez par créer votre première classe</p>
<button class="btn-primary" onclick={openCreateModal}>Créer une classe</button>
{/if}
</div>
{:else}
<div class="classes-grid">
@@ -212,6 +272,7 @@
</div>
{/each}
</div>
<Pagination {currentPage} {totalPages} onPageChange={handlePageChange} />
{/if}
</div>

View File

@@ -1,7 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch } from '$lib/auth';
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
import { untrack } from 'svelte';
// Types
interface Subject {
@@ -37,6 +41,13 @@
let showDeleteModal = $state(false);
let subjectToDelete = $state<Subject | null>(null);
// Pagination & Search
let currentPage = $state(Number(page.url.searchParams.get('page')) || 1);
let searchTerm = $state(page.url.searchParams.get('search') ?? '');
let totalItems = $state(0);
const itemsPerPage = 30;
let totalPages = $derived(Math.ceil(totalItems / itemsPerPage));
// Form state
let newSubjectName = $state('');
let newSubjectCode = $state('');
@@ -45,31 +56,69 @@
let isDeleting = $state(false);
// Load subjects on mount
let loadAbortController: AbortController | null = null;
$effect(() => {
loadSubjects();
untrack(() => loadSubjects());
});
async function loadSubjects() {
loadAbortController?.abort();
const controller = new AbortController();
loadAbortController = controller;
try {
isLoading = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/subjects`);
const params = new URLSearchParams();
params.set('page', String(currentPage));
params.set('itemsPerPage', String(itemsPerPage));
if (searchTerm) params.set('search', searchTerm);
const url = `${apiUrl}/subjects?${params.toString()}`;
const response = await authenticatedFetch(url, { signal: controller.signal });
if (controller.signal.aborted) return;
if (!response.ok) {
throw new Error('Erreur lors du chargement des matières');
}
const data = await response.json();
// API Platform peut retourner hydra:member, member, ou un tableau direct
subjects = data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []);
totalItems = data['hydra:totalItems'] ?? data['totalItems'] ?? subjects.length;
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return;
error = e instanceof Error ? e.message : 'Erreur inconnue';
subjects = [];
totalItems = 0;
} finally {
if (!controller.signal.aborted) {
isLoading = false;
}
}
}
function updateUrl() {
const params = new URLSearchParams();
if (currentPage > 1) params.set('page', String(currentPage));
if (searchTerm) params.set('search', searchTerm);
const query = params.toString();
goto(`?${query}`, { replaceState: true, noScroll: true, keepFocus: true });
}
function handleSearch(value: string) {
searchTerm = value;
currentPage = 1;
updateUrl();
loadSubjects();
}
function handlePageChange(newPage: number) {
currentPage = newPage;
updateUrl();
loadSubjects();
}
async function handleCreateSubject() {
if (!newSubjectName.trim() || !newSubjectCode.trim()) return;
@@ -216,17 +265,29 @@
</div>
{/if}
<SearchInput
value={searchTerm}
onSearch={handleSearch}
placeholder="Rechercher par nom, code..."
/>
{#if isLoading}
<div class="loading-state">
<div class="loading-state" aria-live="polite" role="status">
<div class="spinner"></div>
<p>Chargement des matières...</p>
</div>
{:else if subjects.length === 0}
<div class="empty-state">
<span class="empty-icon">📚</span>
{#if searchTerm}
<h2>Aucun résultat</h2>
<p>Aucune matière ne correspond à votre recherche</p>
<button class="btn-secondary" onclick={() => handleSearch('')}>Effacer la recherche</button>
{:else}
<h2>Aucune matière</h2>
<p>Commencez par créer votre première matière</p>
<button class="btn-primary" onclick={openCreateModal}>Créer une matière</button>
{/if}
</div>
{:else}
<div class="subjects-grid">
@@ -268,6 +329,7 @@
</div>
{/each}
</div>
<Pagination {currentPage} {totalPages} onPageChange={handlePageChange} />
{/if}
</div>

View File

@@ -1,6 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch, getCurrentUserId } from '$lib/auth';
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
import { untrack } from 'svelte';
import { updateUserRoles } from '$features/roles/api/roles';
// Types
@@ -49,8 +54,15 @@
let showCreateModal = $state(false);
// Filters
let filterRole = $state<string>('');
let filterStatut = $state<string>('');
let filterRole = $state<string>(page.url.searchParams.get('role') ?? '');
let filterStatut = $state<string>(page.url.searchParams.get('statut') ?? '');
// Pagination & Search
let currentPage = $state(Number(page.url.searchParams.get('page')) || 1);
let searchTerm = $state(page.url.searchParams.get('search') ?? '');
let totalItems = $state(0);
const itemsPerPage = 30;
let totalPages = $derived(Math.ceil(totalItems / itemsPerPage));
// Form state
let newFirstName = $state('');
@@ -74,21 +86,31 @@
let isSavingRoles = $state(false);
// Load users on mount
let loadAbortController: AbortController | null = null;
$effect(() => {
loadUsers();
untrack(() => loadUsers());
});
async function loadUsers() {
loadAbortController?.abort();
const controller = new AbortController();
loadAbortController = controller;
try {
isLoading = true;
error = null;
const apiUrl = getApiBaseUrl();
const params = new URLSearchParams();
params.set('page', String(currentPage));
params.set('itemsPerPage', String(itemsPerPage));
if (searchTerm) params.set('search', searchTerm);
if (filterRole) params.set('role', filterRole);
if (filterStatut) params.set('statut', filterStatut);
const queryString = params.toString();
const url = `${apiUrl}/users${queryString ? `?${queryString}` : ''}`;
const response = await authenticatedFetch(url);
const url = `${apiUrl}/users?${params.toString()}`;
const response = await authenticatedFetch(url, { signal: controller.signal });
if (controller.signal.aborted) return;
if (!response.ok) {
throw new Error('Erreur lors du chargement des utilisateurs');
@@ -96,21 +118,54 @@
const data = await response.json();
users = data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []);
totalItems = data['hydra:totalItems'] ?? data['totalItems'] ?? users.length;
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return;
error = e instanceof Error ? e.message : 'Erreur inconnue';
users = [];
totalItems = 0;
} finally {
if (!controller.signal.aborted) {
isLoading = false;
}
}
}
function updateUrl() {
const params = new URLSearchParams();
if (currentPage > 1) params.set('page', String(currentPage));
if (searchTerm) params.set('search', searchTerm);
if (filterRole) params.set('role', filterRole);
if (filterStatut) params.set('statut', filterStatut);
const query = params.toString();
goto(`?${query}`, { replaceState: true, noScroll: true, keepFocus: true });
}
function handleSearch(value: string) {
searchTerm = value;
currentPage = 1;
updateUrl();
loadUsers();
}
function handlePageChange(newPage: number) {
currentPage = newPage;
updateUrl();
loadUsers();
}
function applyFilters() {
currentPage = 1;
updateUrl();
loadUsers();
}
function resetFilters() {
filterRole = '';
filterStatut = '';
searchTerm = '';
currentPage = 1;
updateUrl();
loadUsers();
}
@@ -474,17 +529,29 @@
</div>
</div>
<SearchInput
value={searchTerm}
onSearch={handleSearch}
placeholder="Rechercher par nom, email..."
/>
{#if isLoading}
<div class="loading-state">
<div class="loading-state" aria-live="polite" role="status">
<div class="spinner"></div>
<p>Chargement des utilisateurs...</p>
</div>
{:else if users.length === 0}
<div class="empty-state">
<span class="empty-icon">👥</span>
{#if searchTerm || filterRole || filterStatut}
<h2>Aucun résultat</h2>
<p>Aucun utilisateur ne correspond à vos critères de recherche</p>
<button class="btn-secondary" onclick={resetFilters}>Réinitialiser les filtres</button>
{:else}
<h2>Aucun utilisateur</h2>
<p>Commencez par inviter votre premier utilisateur</p>
<button class="btn-primary" onclick={openCreateModal}>Inviter un utilisateur</button>
{/if}
</div>
{:else}
<div class="users-table-container">
@@ -577,6 +644,7 @@
</tbody>
</table>
</div>
<Pagination {currentPage} {totalPages} onPageChange={handlePageChange} />
{/if}
</div>

View File

@@ -3,7 +3,7 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
preprocess: vitePreprocess({ style: process.env.VITEST ? false : undefined }),
kit: {
adapter: adapter(),

View File

@@ -0,0 +1,119 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
describe('Pagination', () => {
it('renders nothing when totalPages <= 1', () => {
const { container } = render(Pagination, {
props: { currentPage: 1, totalPages: 1, onPageChange: vi.fn() }
});
expect(container.querySelector('nav')).toBeNull();
});
it('renders nothing when totalPages is 0', () => {
const { container } = render(Pagination, {
props: { currentPage: 1, totalPages: 0, onPageChange: vi.fn() }
});
expect(container.querySelector('nav')).toBeNull();
});
it('renders all pages when totalPages <= 7', () => {
render(Pagination, {
props: { currentPage: 1, totalPages: 5, onPageChange: vi.fn() }
});
for (let i = 1; i <= 5; i++) {
expect(screen.getByRole('button', { name: `Page ${i}` })).toBeTruthy();
}
});
it('renders ellipsis for large page counts', () => {
const { container } = render(Pagination, {
props: { currentPage: 10, totalPages: 20, onPageChange: vi.fn() }
});
const ellipses = container.querySelectorAll('.pagination-ellipsis');
expect(ellipses.length).toBeGreaterThanOrEqual(1);
});
it('marks current page with aria-current="page"', () => {
render(Pagination, {
props: { currentPage: 3, totalPages: 5, onPageChange: vi.fn() }
});
const currentButton = screen.getByRole('button', { name: 'Page 3' });
expect(currentButton.getAttribute('aria-current')).toBe('page');
const otherButton = screen.getByRole('button', { name: 'Page 1' });
expect(otherButton.getAttribute('aria-current')).toBeNull();
});
it('disables "Précédent" button on page 1', () => {
render(Pagination, {
props: { currentPage: 1, totalPages: 5, onPageChange: vi.fn() }
});
const prevButton = screen.getByRole('button', { name: 'Page précédente' });
expect(prevButton.hasAttribute('disabled')).toBe(true);
});
it('disables "Suivant" button on last page', () => {
render(Pagination, {
props: { currentPage: 5, totalPages: 5, onPageChange: vi.fn() }
});
const nextButton = screen.getByRole('button', { name: 'Page suivante' });
expect(nextButton.hasAttribute('disabled')).toBe(true);
});
it('calls onPageChange with correct page on click', async () => {
const onPageChange = vi.fn();
render(Pagination, {
props: { currentPage: 2, totalPages: 5, onPageChange }
});
await fireEvent.click(screen.getByRole('button', { name: 'Page 3' }));
expect(onPageChange).toHaveBeenCalledWith(3);
});
it('calls onPageChange on next button click', async () => {
const onPageChange = vi.fn();
render(Pagination, {
props: { currentPage: 2, totalPages: 5, onPageChange }
});
await fireEvent.click(screen.getByRole('button', { name: 'Page suivante' }));
expect(onPageChange).toHaveBeenCalledWith(3);
});
it('calls onPageChange on previous button click', async () => {
const onPageChange = vi.fn();
render(Pagination, {
props: { currentPage: 3, totalPages: 5, onPageChange }
});
await fireEvent.click(screen.getByRole('button', { name: 'Page précédente' }));
expect(onPageChange).toHaveBeenCalledWith(2);
});
it('clamps currentPage > totalPages gracefully', () => {
render(Pagination, {
props: { currentPage: 9999, totalPages: 5, onPageChange: vi.fn() }
});
// Page 5 should be marked as active (clamped)
const lastPageButton = screen.getByRole('button', { name: 'Page 5' });
expect(lastPageButton.getAttribute('aria-current')).toBe('page');
});
it('has aria-hidden on ellipsis elements', () => {
const { container } = render(Pagination, {
props: { currentPage: 10, totalPages: 20, onPageChange: vi.fn() }
});
const ellipses = container.querySelectorAll('.pagination-ellipsis');
ellipses.forEach((el) => {
expect(el.getAttribute('aria-hidden')).toBe('true');
});
});
});

View File

@@ -0,0 +1,111 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
describe('SearchInput', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('renders input with default placeholder', () => {
render(SearchInput, {
props: { onSearch: vi.fn() }
});
expect(screen.getByPlaceholderText('Rechercher...')).toBeTruthy();
});
it('renders input with custom placeholder', () => {
render(SearchInput, {
props: { onSearch: vi.fn(), placeholder: 'Chercher un utilisateur...' }
});
expect(screen.getByPlaceholderText('Chercher un utilisateur...')).toBeTruthy();
});
it('debounces input and calls onSearch after delay', async () => {
const onSearch = vi.fn();
render(SearchInput, {
props: { onSearch, debounceMs: 300 }
});
const input = screen.getByRole('searchbox');
await fireEvent.input(input, { target: { value: 'test' } });
// Should not be called immediately
expect(onSearch).not.toHaveBeenCalled();
// Advance past the debounce time
vi.advanceTimersByTime(300);
expect(onSearch).toHaveBeenCalledOnce();
expect(onSearch).toHaveBeenCalledWith('test');
});
it('cancels pending debounce on new input', async () => {
const onSearch = vi.fn();
render(SearchInput, {
props: { onSearch, debounceMs: 300 }
});
const input = screen.getByRole('searchbox');
// Type first value
await fireEvent.input(input, { target: { value: 'te' } });
vi.advanceTimersByTime(200);
// Type second value before debounce fires
await fireEvent.input(input, { target: { value: 'test' } });
vi.advanceTimersByTime(300);
// Should only be called once with the final value
expect(onSearch).toHaveBeenCalledOnce();
expect(onSearch).toHaveBeenCalledWith('test');
});
it('clears input on Escape key', async () => {
const onSearch = vi.fn();
render(SearchInput, {
props: { value: 'initial', onSearch }
});
const input = screen.getByRole('searchbox');
await fireEvent.keyDown(input, { key: 'Escape' });
expect(onSearch).toHaveBeenCalledWith('');
});
it('calls onSearch immediately on clear button click', async () => {
const onSearch = vi.fn();
render(SearchInput, {
props: { value: 'something', onSearch }
});
const clearButton = screen.getByRole('button', { name: 'Effacer la recherche' });
await fireEvent.click(clearButton);
// Should be called immediately (no debounce)
expect(onSearch).toHaveBeenCalledWith('');
});
it('shows clear button only when input has value', () => {
const { container } = render(SearchInput, {
props: { onSearch: vi.fn() }
});
// No clear button initially (empty value)
expect(container.querySelector('.search-clear')).toBeNull();
});
it('shows clear button when initial value is provided', () => {
const { container } = render(SearchInput, {
props: { value: 'test', onSearch: vi.fn() }
});
expect(container.querySelector('.search-clear')).not.toBeNull();
});
});