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>
This commit is contained in:
Eric Hare
2026-04-06 15:02:41 -07:00
committed by GitHub
parent 13bfd4f8bc
commit f4a3e5fd08
5 changed files with 141 additions and 63 deletions

View File

@ -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(

View File

@ -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

View File

@ -7,6 +7,7 @@ import { UseRequestProcessor } from "../../services/request-processor";
interface getUsersQueryParams {
skip: number;
limit: number;
search?: string;
}
export const useGetUsers: useMutationFunctionType<any, getUsersQueryParams> = (
@ -17,10 +18,13 @@ export const useGetUsers: useMutationFunctionType<any, getUsersQueryParams> = (
async function getUsers({
skip,
limit,
search,
}: getUsersQueryParams): Promise<Array<Users>> {
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;
}

View File

@ -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<Users[]>([]);
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);
}}
>
<IconComponent name="X" className="w-6 text-foreground" />
@ -306,11 +298,11 @@ export default function AdminPage() {
</UserManagementModal>
</div>
</div>
{isPending || isIdle ? (
{(isPending || isIdle) && userList.length === 0 ? (
<div className="flex h-full w-full items-center justify-center">
<CustomLoader remSize={12} />
</div>
) : userList.current.length === 0 && !isIdle ? (
) : userList.length === 0 && !isPending && !isIdle ? (
<>
<div className="m-4 flex items-center justify-between text-sm">
No users registered.
@ -340,10 +332,10 @@ export default function AdminPage() {
<TableHead className="h-10 w-[100px] text-right"></TableHead>
</TableRow>
</TableHeader>
{!isPending && (
{
<TableBody className="border-b">
{filterUserList.map((user: UserInputType, index) => (
<TableRow key={index}>
{userList.map((user: UserInputType, index) => (
<TableRow key={user.id}>
<TableCell className="truncate py-2 font-medium">
<ShadTooltip content={user.id}>
<span className="cursor-default">{user.id}</span>
@ -496,7 +488,7 @@ export default function AdminPage() {
</TableRow>
))}
</TableBody>
)}
}
</Table>
</div>

View File

@ -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();