From f4a3e5fd08cf37503165f477b4b7c13f2e9cdeb5 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Mon, 6 Apr 2026 15:02:41 -0700 Subject: [PATCH] fix: Search beyond the first page of users (#12203) * fix: Search beyond the first page of users * Add a test for search functionality * Update users.py * Update index.tsx * Update index.tsx * Update auto-login-off.spec.ts * Update auto-login-off.spec.ts * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- src/backend/base/langflow/api/v1/users.py | 13 ++- src/backend/tests/unit/api/v1/test_users.py | 70 ++++++++++++ .../API/queries/auth/use-get-users-page.ts | 10 +- src/frontend/src/pages/AdminPage/index.tsx | 106 ++++++++---------- .../core/features/auto-login-off.spec.ts | 5 + 5 files changed, 141 insertions(+), 63 deletions(-) diff --git a/src/backend/base/langflow/api/v1/users.py b/src/backend/base/langflow/api/v1/users.py index 169e751eaa..ba13cf0c6a 100644 --- a/src/backend/base/langflow/api/v1/users.py +++ b/src/backend/base/langflow/api/v1/users.py @@ -59,13 +59,20 @@ async def read_all_users( *, skip: int = 0, limit: int = 10, + search: str | None = None, session: DbSession, ) -> UsersResponse: """Retrieve a list of users from the database with pagination.""" - query: SelectOfScalar = select(User).offset(skip).limit(limit) - users = (await session.exec(query)).fetchall() - + query: SelectOfScalar = select(User) count_query = select(func.count()).select_from(User) + + if search: + search_filter = User.username.ilike(f"%{search}%") # type: ignore[attr-defined] + query = query.where(search_filter) + count_query = count_query.where(search_filter) + + query = query.offset(skip).limit(limit) + users = (await session.exec(query)).fetchall() total_count = (await session.exec(count_query)).first() return UsersResponse( diff --git a/src/backend/tests/unit/api/v1/test_users.py b/src/backend/tests/unit/api/v1/test_users.py index 9efd9dbbda..a5d5fb7a72 100644 --- a/src/backend/tests/unit/api/v1/test_users.py +++ b/src/backend/tests/unit/api/v1/test_users.py @@ -161,6 +161,76 @@ async def test_patch_user_self_deactivation_forbidden_superuser( assert "can't deactivate your own user account" in result["detail"] +async def test_read_all_users_search(client: AsyncClient, logged_in_headers_super_user): + """Test that the search parameter filters users by username across all pages.""" + # Create several users with distinct usernames + usernames = ["alice_search", "bob_search", "charlie_search"] + created_ids = [] + for username in usernames: + response = await client.post( + "api/v1/users/", + json={"username": username, "password": "password123"}, + headers=logged_in_headers_super_user, + ) + assert response.status_code == status.HTTP_201_CREATED + created_ids.append(response.json()["id"]) + + # Search for "alice" — should return exactly one match + response = await client.get( + "api/v1/users/?search=alice", + headers=logged_in_headers_super_user, + ) + assert response.status_code == status.HTTP_200_OK + result = response.json() + assert result["total_count"] == 1 + assert result["users"][0]["username"] == "alice_search" + + # Search for "_search" — should match all three created users + response = await client.get( + "api/v1/users/?search=_search", + headers=logged_in_headers_super_user, + ) + assert response.status_code == status.HTTP_200_OK + result = response.json() + assert result["total_count"] == 3 + returned_usernames = {u["username"] for u in result["users"]} + assert returned_usernames == set(usernames) + + # Search for a non-existent username — should return zero results + response = await client.get( + "api/v1/users/?search=nonexistentuser", + headers=logged_in_headers_super_user, + ) + assert response.status_code == status.HTTP_200_OK + result = response.json() + assert result["total_count"] == 0 + assert result["users"] == [] + + # Search is case-insensitive + response = await client.get( + "api/v1/users/?search=ALICE", + headers=logged_in_headers_super_user, + ) + assert response.status_code == status.HTTP_200_OK + result = response.json() + assert result["total_count"] == 1 + assert result["users"][0]["username"] == "alice_search" + + # Search combined with pagination: limit=1 should return 1 user but total_count=3 + response = await client.get( + "api/v1/users/?search=_search&limit=1", + headers=logged_in_headers_super_user, + ) + assert response.status_code == status.HTTP_200_OK + result = response.json() + assert result["total_count"] == 3 + assert len(result["users"]) == 1 + + # Clean up + for user_id in created_ids: + await client.delete(f"api/v1/users/{user_id}", headers=logged_in_headers_super_user) + + async def test_patch_user_deactivate_other_user_allowed(client: AsyncClient, logged_in_headers_super_user): """Test that a superuser can deactivate another user's account.""" # Create a new user diff --git a/src/frontend/src/controllers/API/queries/auth/use-get-users-page.ts b/src/frontend/src/controllers/API/queries/auth/use-get-users-page.ts index 688019807a..c9b5b9e376 100644 --- a/src/frontend/src/controllers/API/queries/auth/use-get-users-page.ts +++ b/src/frontend/src/controllers/API/queries/auth/use-get-users-page.ts @@ -7,6 +7,7 @@ import { UseRequestProcessor } from "../../services/request-processor"; interface getUsersQueryParams { skip: number; limit: number; + search?: string; } export const useGetUsers: useMutationFunctionType = ( @@ -17,10 +18,13 @@ export const useGetUsers: useMutationFunctionType = ( async function getUsers({ skip, limit, + search, }: getUsersQueryParams): Promise> { - const res = await api.get( - `${getURL("USERS")}/?skip=${skip}&limit=${limit}`, - ); + let url = `${getURL("USERS")}/?skip=${skip}&limit=${limit}`; + if (search) { + url += `&search=${encodeURIComponent(search)}`; + } + const res = await api.get(url); if (res.status === 200) { return res.data; } diff --git a/src/frontend/src/pages/AdminPage/index.tsx b/src/frontend/src/pages/AdminPage/index.tsx index bb49841aa6..5f555c3e1a 100644 --- a/src/frontend/src/pages/AdminPage/index.tsx +++ b/src/frontend/src/pages/AdminPage/index.tsx @@ -1,5 +1,5 @@ -import { cloneDeep } from "lodash"; -import { useContext, useEffect, useRef, useState } from "react"; +import { cloneDeep, debounce } from "lodash"; +import { useCallback, useContext, useEffect, useState } from "react"; import PaginatorComponent from "@/components/common/paginatorComponent"; import { useAddUser, @@ -59,71 +59,62 @@ export default function AdminPage() { const { mutate: mutateUpdateUser } = useUpdateUser(); const { mutate: mutateAddUser } = useAddUser(); - const userList = useRef([]); - - useEffect(() => { - setTimeout(() => { - getUsers(); - }, 500); - }, []); - - const [filterUserList, setFilterUserList] = useState(userList.current); + const [userList, setUserList] = useState([]); const { mutate: mutateGetUsers, isPending, isIdle } = useGetUsers({}); - function getUsers() { - mutateGetUsers( - { - skip: size * (index - 1), - limit: size, - }, - { - onSuccess: (users) => { - setTotalRowsCount(users["total_count"]); - userList.current = users["users"]; - setFilterUserList(users["users"]); + const fetchUsers = useCallback( + (skip: number, limit: number, search?: string) => { + mutateGetUsers( + { skip, limit, search: search || undefined }, + { + onSuccess: (users) => { + setTotalRowsCount(users["total_count"]); + setUserList(users["users"]); + }, + onError: () => {}, }, - onError: () => {}, - }, - ); - } + ); + }, + [mutateGetUsers], + ); + + useEffect(() => { + setTimeout(() => { + fetchUsers(size * (index - 1), size); + }, 500); + }, []); function handleChangePagination(pageIndex: number, pageSize: number) { setPageSize(pageSize); setPageIndex(pageIndex); - - mutateGetUsers( - { - skip: pageSize * (pageIndex - 1), - limit: pageSize, - }, - { - onSuccess: (users) => { - setTotalRowsCount(users["total_count"]); - userList.current = users["users"]; - setFilterUserList(users["users"]); - }, - }, - ); + fetchUsers(pageSize * (pageIndex - 1), pageSize, inputValue); } function resetFilter() { + setInputValue(""); setPageIndex(PAGINATION_PAGE); setPageSize(PAGINATION_SIZE); - getUsers(); + fetchUsers(0, PAGINATION_SIZE); } + const debouncedSearch = useCallback( + debounce((search: string) => { + setPageIndex(PAGINATION_PAGE); + fetchUsers(0, size, search); + }, 300), + [size, fetchUsers], + ); + + useEffect(() => { + return () => { + debouncedSearch.cancel(); + }; + }, [debouncedSearch]); + function handleFilterUsers(input: string) { setInputValue(input); - - if (input === "") { - setFilterUserList(userList.current); - } else { - const filteredList = userList.current.filter((user: Users) => - user.username.toLowerCase().includes(input.toLowerCase()), - ); - setFilterUserList(filteredList); - } + debouncedSearch(input); } function handleDeleteUser(user) { @@ -276,7 +267,8 @@ export default function AdminPage() { className="cursor-pointer" onClick={() => { setInputValue(""); - setFilterUserList(userList.current); + setPageIndex(PAGINATION_PAGE); + fetchUsers(0, size); }} > @@ -306,11 +298,11 @@ export default function AdminPage() { - {isPending || isIdle ? ( + {(isPending || isIdle) && userList.length === 0 ? (
- ) : userList.current.length === 0 && !isIdle ? ( + ) : userList.length === 0 && !isPending && !isIdle ? ( <>
No users registered. @@ -340,10 +332,10 @@ export default function AdminPage() { - {!isPending && ( + { - {filterUserList.map((user: UserInputType, index) => ( - + {userList.map((user: UserInputType, index) => ( + {user.id} @@ -496,7 +488,7 @@ export default function AdminPage() { ))} - )} + }
diff --git a/src/frontend/tests/core/features/auto-login-off.spec.ts b/src/frontend/tests/core/features/auto-login-off.spec.ts index b7b959580a..57afc42ffd 100644 --- a/src/frontend/tests/core/features/auto-login-off.spec.ts +++ b/src/frontend/tests/core/features/auto-login-off.spec.ts @@ -111,7 +111,12 @@ test( await page.waitForSelector("text=new user added", { timeout: 30000 }); + const searchResponse = page.waitForResponse( + (response) => + response.url().includes("/api/v1/users") && response.status() === 200, + ); await page.getByPlaceholder("Username").last().fill(randomName); + await searchResponse; await page.getByTestId("icon-Pencil").last().click();