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:
339
frontend/e2e/admin-search-pagination.spec.ts
Normal file
339
frontend/e2e/admin-search-pagination.spec.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user