diff --git a/frontend/e2e/admin-users.spec.ts b/frontend/e2e/admin-users.spec.ts index 3fd0132..d7827af 100644 --- a/frontend/e2e/admin-users.spec.ts +++ b/frontend/e2e/admin-users.spec.ts @@ -126,6 +126,97 @@ test.describe('Admin User Management - Search and Filters', () => { const userTypeFilter = page.getByRole('combobox').filter({ hasText: /All Users/i }); await expect(userTypeFilter).toBeVisible(); }); + + test('should filter users by search query (adds search param to URL)', async ({ page }) => { + const searchInput = page.getByPlaceholder(/Search by name or email/i); + await searchInput.fill('admin'); + + // Wait for debounce and URL to update + await page.waitForFunction(() => { + const url = new URL(window.location.href); + return url.searchParams.has('search'); + }, { timeout: 2000 }); + + // Check that URL contains search parameter + expect(page.url()).toContain('search=admin'); + }); + + // Note: Active status filter URL parameter behavior is tested in the unit tests + // (UserManagementContent.test.tsx). Skipping E2E test due to flaky URL timing. + + test('should filter users by inactive status (adds active=false param to URL)', async ({ page }) => { + const statusFilter = page.getByRole('combobox').first(); + await statusFilter.click(); + + // Click on "Inactive" option and wait for URL update + await Promise.all([ + page.waitForFunction(() => { + const url = new URL(window.location.href); + return url.searchParams.get('active') === 'false'; + }, { timeout: 2000 }), + page.getByRole('option', { name: 'Inactive' }).click() + ]); + + // Check that URL contains active=false parameter + expect(page.url()).toContain('active=false'); + }); + + test('should filter users by superuser status (adds superuser param to URL)', async ({ page }) => { + const userTypeFilter = page.getByRole('combobox').nth(1); + await userTypeFilter.click(); + + // Click on "Superusers" option and wait for URL update + await Promise.all([ + page.waitForFunction(() => { + const url = new URL(window.location.href); + return url.searchParams.get('superuser') === 'true'; + }, { timeout: 2000 }), + page.getByRole('option', { name: 'Superusers' }).click() + ]); + + // Check that URL contains superuser parameter + expect(page.url()).toContain('superuser=true'); + }); + + test('should filter users by regular user status (adds superuser=false param to URL)', async ({ page }) => { + const userTypeFilter = page.getByRole('combobox').nth(1); + await userTypeFilter.click(); + + // Click on "Regular" option and wait for URL update + await Promise.all([ + page.waitForFunction(() => { + const url = new URL(window.location.href); + return url.searchParams.get('superuser') === 'false'; + }, { timeout: 2000 }), + page.getByRole('option', { name: 'Regular' }).click() + ]); + + // Check that URL contains superuser=false parameter + expect(page.url()).toContain('superuser=false'); + }); + + // Note: Combined filters URL parameter behavior is tested in the unit tests + // (UserManagementContent.test.tsx). Skipping E2E test due to flaky URL timing with multiple filters. + + test('should reset to page 1 when applying filters', async ({ page }) => { + // Go to page 2 (if it exists) + const url = new URL(page.url()); + url.searchParams.set('page', '2'); + await page.goto(url.toString()); + + // Apply a filter + const searchInput = page.getByPlaceholder(/Search by name or email/i); + await searchInput.fill('test'); + + await page.waitForFunction(() => { + const url = new URL(window.location.href); + return url.searchParams.has('search'); + }, { timeout: 2000 }); + + // URL should have page=1 or no page param (defaults to 1) + const newUrl = page.url(); + expect(newUrl).not.toContain('page=2'); + }); }); test.describe('Admin User Management - Pagination', () => { diff --git a/frontend/src/components/admin/users/UserManagementContent.tsx b/frontend/src/components/admin/users/UserManagementContent.tsx index 3d8fa74..185e3f6 100644 --- a/frontend/src/components/admin/users/UserManagementContent.tsx +++ b/frontend/src/components/admin/users/UserManagementContent.tsx @@ -26,6 +26,10 @@ export function UserManagementContent() { const filterActive = searchParams.get('active') || null; const filterSuperuser = searchParams.get('superuser') || null; + // Convert filter strings to booleans for API + const isActiveFilter = filterActive === 'true' ? true : filterActive === 'false' ? false : null; + const isSuperuserFilter = filterSuperuser === 'true' ? true : filterSuperuser === 'false' ? false : null; + // Local state const [selectedUsers, setSelectedUsers] = useState([]); const [dialogOpen, setDialogOpen] = useState(false); @@ -33,7 +37,13 @@ export function UserManagementContent() { const [editingUser, setEditingUser] = useState(null); // Fetch users with query params - const { data, isLoading } = useAdminUsers(page, 20); + const { data, isLoading } = useAdminUsers( + page, + 20, + searchQuery || null, + isActiveFilter, + isSuperuserFilter + ); const users: User[] = data?.data || []; const pagination: PaginationMeta = data?.pagination || { diff --git a/frontend/src/lib/api/hooks/useAdmin.tsx b/frontend/src/lib/api/hooks/useAdmin.tsx index ef38381..5b74973 100644 --- a/frontend/src/lib/api/hooks/useAdmin.tsx +++ b/frontend/src/lib/api/hooks/useAdmin.tsx @@ -161,16 +161,31 @@ export interface PaginatedUserResponse { * * @param page - Page number (1-indexed) * @param limit - Number of records per page + * @param search - Search query for email or name + * @param is_active - Filter by active status (true, false, or null for all) + * @param is_superuser - Filter by superuser status (true, false, or null for all) * @returns Paginated list of users */ -export function useAdminUsers(page = 1, limit = DEFAULT_PAGE_LIMIT) { +export function useAdminUsers( + page = 1, + limit = DEFAULT_PAGE_LIMIT, + search?: string | null, + is_active?: boolean | null, + is_superuser?: boolean | null +) { const { user } = useAuth(); return useQuery({ - queryKey: ['admin', 'users', page, limit], + queryKey: ['admin', 'users', page, limit, search, is_active, is_superuser], queryFn: async (): Promise => { const response = await adminListUsers({ - query: { page, limit }, + query: { + page, + limit, + ...(search ? { search } : {}), + ...(is_active !== null && is_active !== undefined ? { is_active } : {}), + ...(is_superuser !== null && is_superuser !== undefined ? { is_superuser } : {}), + }, throwOnError: false, }); diff --git a/frontend/tests/components/admin/users/UserManagementContent.test.tsx b/frontend/tests/components/admin/users/UserManagementContent.test.tsx index 3647737..fb931bc 100644 --- a/frontend/tests/components/admin/users/UserManagementContent.test.tsx +++ b/frontend/tests/components/admin/users/UserManagementContent.test.tsx @@ -391,7 +391,7 @@ describe('UserManagementContent', () => { renderWithProviders(); - expect(mockUseAdminUsers).toHaveBeenCalledWith(2, 20); + expect(mockUseAdminUsers).toHaveBeenCalledWith(2, 20, null, null, null); }); it('reads search query from URL params', () => { @@ -400,9 +400,34 @@ describe('UserManagementContent', () => { renderWithProviders(); - // Component should read the search param - // This is tested implicitly through the component render - expect(screen.getByTestId('user-list-table')).toBeInTheDocument(); + expect(mockUseAdminUsers).toHaveBeenCalledWith(1, 20, 'test', null, null); + }); + + it('reads active filter from URL params', () => { + const paramsWithActive = new URLSearchParams('active=true'); + mockUseSearchParams.mockReturnValue(paramsWithActive as any); + + renderWithProviders(); + + expect(mockUseAdminUsers).toHaveBeenCalledWith(1, 20, null, true, null); + }); + + it('reads superuser filter from URL params', () => { + const paramsWithSuperuser = new URLSearchParams('superuser=false'); + mockUseSearchParams.mockReturnValue(paramsWithSuperuser as any); + + renderWithProviders(); + + expect(mockUseAdminUsers).toHaveBeenCalledWith(1, 20, null, null, false); + }); + + it('reads all params from URL', () => { + const params = new URLSearchParams('page=3&search=admin&active=true&superuser=true'); + mockUseSearchParams.mockReturnValue(params as any); + + renderWithProviders(); + + expect(mockUseAdminUsers).toHaveBeenCalledWith(3, 20, 'admin', true, true); }); it('passes current user ID to table', () => { diff --git a/frontend/tests/lib/api/hooks/useAdmin.test.tsx b/frontend/tests/lib/api/hooks/useAdmin.test.tsx index be2a3b2..144a829 100644 --- a/frontend/tests/lib/api/hooks/useAdmin.test.tsx +++ b/frontend/tests/lib/api/hooks/useAdmin.test.tsx @@ -260,6 +260,117 @@ describe('useAdmin hooks', () => { await waitFor(() => expect(result.current.isError).toBe(true)); expect(result.current.error).toBeDefined(); }); + + it('passes search parameter to API', async () => { + mockUseAuth.mockReturnValue({ + user: { is_superuser: true } as any, + isAuthenticated: true, + isLoading: false, + login: jest.fn(), + logout: jest.fn(), + }); + + mockAdminListUsers.mockResolvedValue(mockResponse as any); + + renderHook(() => useAdminUsers(1, 50, 'test@example.com'), { wrapper }); + + await waitFor(() => { + expect(mockAdminListUsers).toHaveBeenCalledWith({ + query: { page: 1, limit: 50, search: 'test@example.com' }, + throwOnError: false, + }); + }); + }); + + it('passes is_active filter parameter to API', async () => { + mockUseAuth.mockReturnValue({ + user: { is_superuser: true } as any, + isAuthenticated: true, + isLoading: false, + login: jest.fn(), + logout: jest.fn(), + }); + + mockAdminListUsers.mockResolvedValue(mockResponse as any); + + renderHook(() => useAdminUsers(1, 50, null, true), { wrapper }); + + await waitFor(() => { + expect(mockAdminListUsers).toHaveBeenCalledWith({ + query: { page: 1, limit: 50, is_active: true }, + throwOnError: false, + }); + }); + }); + + it('passes is_superuser filter parameter to API', async () => { + mockUseAuth.mockReturnValue({ + user: { is_superuser: true } as any, + isAuthenticated: true, + isLoading: false, + login: jest.fn(), + logout: jest.fn(), + }); + + mockAdminListUsers.mockResolvedValue(mockResponse as any); + + renderHook(() => useAdminUsers(1, 50, null, null, false), { wrapper }); + + await waitFor(() => { + expect(mockAdminListUsers).toHaveBeenCalledWith({ + query: { page: 1, limit: 50, is_superuser: false }, + throwOnError: false, + }); + }); + }); + + it('passes all filter parameters to API', async () => { + mockUseAuth.mockReturnValue({ + user: { is_superuser: true } as any, + isAuthenticated: true, + isLoading: false, + login: jest.fn(), + logout: jest.fn(), + }); + + mockAdminListUsers.mockResolvedValue(mockResponse as any); + + renderHook(() => useAdminUsers(2, 20, 'admin', true, false), { wrapper }); + + await waitFor(() => { + expect(mockAdminListUsers).toHaveBeenCalledWith({ + query: { + page: 2, + limit: 20, + search: 'admin', + is_active: true, + is_superuser: false + }, + throwOnError: false, + }); + }); + }); + + it('excludes filter parameters when they are null', async () => { + mockUseAuth.mockReturnValue({ + user: { is_superuser: true } as any, + isAuthenticated: true, + isLoading: false, + login: jest.fn(), + logout: jest.fn(), + }); + + mockAdminListUsers.mockResolvedValue(mockResponse as any); + + renderHook(() => useAdminUsers(1, 50, null, null, null), { wrapper }); + + await waitFor(() => { + expect(mockAdminListUsers).toHaveBeenCalledWith({ + query: { page: 1, limit: 50 }, + throwOnError: false, + }); + }); + }); }); describe('useAdminOrganizations', () => {