Files
Classeo/frontend/e2e/admin-search-pagination.spec.ts
Mathias STRASSER 76e16db0d8 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.
2026-02-15 13:54:51 +01:00

340 lines
12 KiB
TypeScript

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/);
});
});
});